@@ -195,6 +195,8 @@ const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
195195const CONFIG_BOOLEAN_FLAGS = new Set ( [ 'help' , 'h' , 'json' ] ) ;
196196const AMR_STRING_FLAGS = new Set ( [ 'daemon-url' ] ) ;
197197const AMR_BOOLEAN_FLAGS = new Set ( [ 'help' , 'h' , 'json' , 'refresh' ] ) ;
198+ const COLLAB_STRING_FLAGS = new Set ( [ 'daemon-url' , 'project' , 'member' , 'name' , 'role' , 'design-system' ] ) ;
199+ const COLLAB_BOOLEAN_FLAGS = new Set ( [ 'help' , 'h' , 'json' ] ) ;
198200const PROJECT_STRING_FLAGS = new Set ( [
199201 'daemon-url' , 'name' , 'skill' , 'design-system' , 'plugin' , 'metadata-json' ,
200202 'pending-prompt' , 'project' , 'conversation' , 'message' , 'prompt' ,
@@ -311,6 +313,7 @@ const SUBCOMMAND_MAP = {
311313 media : runMedia ,
312314 mcp : runMcp ,
313315 amr : runAmr ,
316+ collab : runCollab ,
314317 research : runResearch ,
315318 plugin : runPlugin ,
316319 ui : runUi ,
@@ -689,6 +692,242 @@ Options:
689692 }
690693}
691694
695+ // ---------------------------------------------------------------------------
696+ // Subcommand: od collab … (team-edition collaboration)
697+ // ---------------------------------------------------------------------------
698+
699+ function printCollabHelp ( ) {
700+ console . log ( `Usage:
701+ od collab status <projectId> [--json]
702+ od collab presence <projectId> [--json]
703+ od collab heartbeat <projectId> --member <id> [--name <name>] [--role owner|admin|member] [--json]
704+ od collab leave <projectId> --member <id> [--json]
705+ od collab changed <projectId> [--json]
706+ od collab publish <projectId> [--json]
707+ od collab share <projectId> [--json]
708+ od collab pull <projectId> [--json]
709+ od collab share-resource <design-systems|plugins|skills> <id> [--json]
710+ od collab team-resources <design-systems|plugins|skills> [--json]
711+ od collab share-design-system <designSystemId> [--json]
712+ od collab team-design-systems [--json]
713+
714+ Team-edition collaboration: presence overlay + sync trigger. The
715+ client is authoritative about whether it is in a shared context, so it drives
716+ the trigger; the daemon coalesces author edits and flushes at a run boundary,
717+ advancing the published head version members poll to learn when to pull.
718+ \`share\` is the team-share intent: it requests the project be published so
719+ members can pull it, and reports the sync state (local_only / pending_upload /
720+ synced / sync_failed). \`share-resource <kind> <id>\` promotes a personal design
721+ system, plugin, or skill into the team scope through the resource hub, and
722+ \`team-resources <kind>\` lists the ones already shared (the \`*-design-system\`
723+ forms are kept as aliases).
724+
725+ Options:
726+ --project <id> Project id (alternative to the positional argument).
727+ --design-system <id> Design system id for share-design-system.
728+ --member <id> Member id for the presence heartbeat / leave.
729+ --name <name> Display name attached to a heartbeat.
730+ --role <role> owner | admin | member.
731+ --json Emit raw JSON.
732+ --daemon-url <url> Override daemon URL.
733+
734+ Examples:
735+ od collab presence p1 --json
736+ od collab heartbeat p1 --member m-42 --name "Ma Shu" --role member
737+ od collab publish p1
738+ od collab share-resource plugins my-plugin --json
739+ od collab team-resources skills --json
740+ od collab share-design-system user:palette-x --json
741+ od collab status p1 --json` ) ;
742+ }
743+
744+ async function runCollab ( args ) {
745+ const sub = args [ 0 ] ;
746+ if ( ! sub || sub === 'help' || args . includes ( '--help' ) || args . includes ( '-h' ) ) {
747+ printCollabHelp ( ) ;
748+ process . exit ( ! sub ? 2 : 0 ) ;
749+ }
750+ const rest = args . slice ( 1 ) ;
751+ let flags ;
752+ try {
753+ flags = parseFlags ( rest , { string : COLLAB_STRING_FLAGS , boolean : COLLAB_BOOLEAN_FLAGS } ) ;
754+ } catch ( err ) {
755+ console . error ( err . message ) ;
756+ process . exit ( 2 ) ;
757+ }
758+ // Team resource sharing (design systems / plugins / skills) is workspace-scoped
759+ // — it takes a resource id, not a project id — so it runs before the project-id
760+ // requirement below. `share-resource <kind> <id>` / `team-resources <kind>` are
761+ // the generic forms; the design-system aliases are kept for compatibility.
762+ const RESOURCE_BASE_PATHS = new Set ( [ 'design-systems' , 'plugins' , 'skills' ] ) ;
763+ if (
764+ sub === 'share-resource' ||
765+ sub === 'team-resources' ||
766+ sub === 'share-design-system' ||
767+ sub === 'team-design-systems'
768+ ) {
769+ const base = await cliDaemonBaseUrl ( flags ) ;
770+ const emit = ( payload , plain ) =>
771+ flags . json ? process . stdout . write ( JSON . stringify ( payload , null , 2 ) + '\n' ) : plain ( ) ;
772+ const wsRequest = async ( method , path , body ) => {
773+ let resp ;
774+ try {
775+ resp = await fetch ( `${ base } ${ path } ` , {
776+ method,
777+ ...( body !== undefined
778+ ? { headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( body ) }
779+ : { } ) ,
780+ } ) ;
781+ } catch ( err ) {
782+ surfaceFetchError ( err , base ) ;
783+ process . exit ( 3 ) ;
784+ }
785+ if ( ! resp . ok ) return structuredHttpFailure ( resp ) ;
786+ return resp . json ( ) ;
787+ } ;
788+
789+ // Resolve the resource kind (URL base path), whether this lists or shares,
790+ // and the target id — from either the aliases or the generic <kind> <id>.
791+ const positionals = positionalArgs ( rest , COLLAB_STRING_FLAGS ) ;
792+ let basePath ;
793+ let isList ;
794+ let resourceId ;
795+ if ( sub === 'team-design-systems' ) {
796+ basePath = 'design-systems' ;
797+ isList = true ;
798+ } else if ( sub === 'share-design-system' ) {
799+ basePath = 'design-systems' ;
800+ isList = false ;
801+ resourceId = flags [ 'design-system' ] || positionals [ 0 ] ;
802+ } else {
803+ basePath = positionals [ 0 ] ;
804+ if ( ! RESOURCE_BASE_PATHS . has ( basePath ) ) {
805+ console . error ( 'kind must be one of: design-systems | plugins | skills' ) ;
806+ process . exit ( 2 ) ;
807+ }
808+ isList = sub === 'team-resources' ;
809+ resourceId = positionals [ 1 ] ;
810+ }
811+
812+ if ( isList ) {
813+ const body = await wsRequest ( 'GET' , `/api/workspace/${ basePath } /team` ) ;
814+ return emit ( body , ( ) => {
815+ const ids = Array . isArray ( body ?. ids ) ? body . ids : [ ] ;
816+ if ( ids . length === 0 ) return console . log ( `no shared ${ basePath } ` ) ;
817+ for ( const id of ids ) console . log ( id ) ;
818+ } ) ;
819+ }
820+ if ( ! resourceId ) {
821+ console . error ( 'missing <id>' ) ;
822+ process . exit ( 2 ) ;
823+ }
824+ const body = await wsRequest (
825+ 'POST' ,
826+ `/api/workspace/${ basePath } /${ encodeURIComponent ( resourceId ) } /share` ,
827+ ) ;
828+ return emit ( body , ( ) =>
829+ console . log ( `shared=${ body ?. shared ?? false } \tversion=${ body ?. version ?? '-' } ` ) ,
830+ ) ;
831+ }
832+
833+ const projectId =
834+ flags . project || positionalArgs ( rest , COLLAB_STRING_FLAGS ) [ 0 ] || process . env . OD_PROJECT_ID ;
835+ if ( ! projectId ) {
836+ console . error ( 'missing <projectId> (positional, --project, or OD_PROJECT_ID)' ) ;
837+ process . exit ( 2 ) ;
838+ }
839+ const base = await cliDaemonBaseUrl ( flags ) ;
840+ const encoded = encodeURIComponent ( projectId ) ;
841+
842+ const request = async ( method , path , body ) => {
843+ let resp ;
844+ try {
845+ resp = await fetch ( `${ base } /api/projects/${ encoded } ${ path } ` , {
846+ method,
847+ ...( body !== undefined
848+ ? { headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( body ) }
849+ : { } ) ,
850+ } ) ;
851+ } catch ( err ) {
852+ surfaceFetchError ( err , base ) ;
853+ process . exit ( 3 ) ;
854+ }
855+ if ( ! resp . ok ) return structuredHttpFailure ( resp ) ;
856+ return resp . json ( ) ;
857+ } ;
858+
859+ const emit = ( payload , plain ) => {
860+ if ( flags . json ) return process . stdout . write ( JSON . stringify ( payload , null , 2 ) + '\n' ) ;
861+ return plain ( ) ;
862+ } ;
863+
864+ switch ( sub ) {
865+ case 'status' : {
866+ const body = await request ( 'GET' , '/collab/status' ) ;
867+ return emit ( body , ( ) => {
868+ console . log ( `publishedVersion\t${ body ?. publishedVersion ?? '-' } ` ) ;
869+ console . log ( `syncState\t${ body ?. syncState ?? '-' } ` ) ;
870+ } ) ;
871+ }
872+ case 'share' : {
873+ // Team-share intent: request the project be published so members can pull.
874+ const body = await request ( 'POST' , '/collab/sync-intent' , {
875+ event : 'project_team_share_requested' ,
876+ projectId,
877+ } ) ;
878+ return emit ( body , ( ) => console . log ( `ok\tsyncState=${ body ?. syncState ?? '-' } ` ) ) ;
879+ }
880+ case 'pull' : {
881+ // Member pull: fetch the published head (E extracts the bytes behind C's trigger).
882+ const body = await request ( 'POST' , '/collab/pull' ) ;
883+ return emit ( body , ( ) => console . log ( `pulled\tversion=${ body ?. version ?? '-' } ` ) ) ;
884+ }
885+ case 'presence' : {
886+ const body = await request ( 'GET' , '/presence' ) ;
887+ return emit ( body , ( ) => {
888+ const present = Array . isArray ( body ?. present ) ? body . present : [ ] ;
889+ if ( present . length === 0 ) return console . log ( 'no members present' ) ;
890+ for ( const m of present ) console . log ( `${ m . memberId } \t${ m . name ?? '-' } \t${ m . role ?? '-' } ` ) ;
891+ } ) ;
892+ }
893+ case 'heartbeat' : {
894+ if ( ! flags . member ) {
895+ console . error ( 'missing --member <id>' ) ;
896+ process . exit ( 2 ) ;
897+ }
898+ const memberBody = {
899+ memberId : flags . member ,
900+ ...( flags . name ? { name : flags . name } : { } ) ,
901+ ...( flags . role ? { role : flags . role } : { } ) ,
902+ } ;
903+ const body = await request ( 'POST' , '/presence/heartbeat' , memberBody ) ;
904+ return emit ( body , ( ) => {
905+ const present = Array . isArray ( body ?. present ) ? body . present : [ ] ;
906+ console . log ( `ok\t${ present . length } present` ) ;
907+ } ) ;
908+ }
909+ case 'leave' : {
910+ if ( ! flags . member ) {
911+ console . error ( 'missing --member <id>' ) ;
912+ process . exit ( 2 ) ;
913+ }
914+ const body = await request ( 'POST' , '/presence/leave' , { memberId : flags . member } ) ;
915+ return emit ( body , ( ) => console . log ( 'left' ) ) ;
916+ }
917+ case 'changed' : {
918+ const body = await request ( 'POST' , '/collab/changed' ) ;
919+ return emit ( body , ( ) => console . log ( 'change queued' ) ) ;
920+ }
921+ case 'publish' : {
922+ const body = await request ( 'POST' , '/collab/publish' ) ;
923+ return emit ( body , ( ) => console . log ( 'publish requested' ) ) ;
924+ }
925+ default :
926+ console . error ( `unknown subcommand: od collab ${ sub } ` ) ;
927+ process . exit ( 2 ) ;
928+ }
929+ }
930+
692931// ---------------------------------------------------------------------------
693932// Subcommand: od research …
694933// ---------------------------------------------------------------------------
0 commit comments