@genspark/cli 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import { parseLastSeenIndex, eventStdoutLine } from './commands/chat-events.js';
39
39
  import { mediaItemsKeyFor, mediaSummaryLines } from './mediaSummary.js';
40
40
  import { registerMeshCommand, getMeshInvocation, runMesh, } from './commands/mesh.js';
41
41
  import { executeCloneUrl, normalizeCloneUrlForGit, } from './commands/sb-git-clone.js';
42
+ import { actionScopedDescription, serviceActionViews, } from './serviceActions.js';
42
43
  const require = createRequire(import.meta.url);
43
44
  const { version: VERSION } = require('../package.json');
44
45
  const DEFAULT_BASE_URL = 'https://www.genspark.ai';
@@ -576,6 +577,50 @@ function parseBooleanFlagValue(value) {
576
577
  return false;
577
578
  throw new InvalidArgumentError(`expected true/false. If '${value}' was meant as an argument, place it before the flag or pass an explicit value (--flag true ${value}).`);
578
579
  }
580
+ /**
581
+ * Register one schema parameter as a Commander option.
582
+ *
583
+ * Shared by the union-flag command and the per-action subcommands so both
584
+ * views parse a given flag identically. Booleans accept an optional literal
585
+ * value (`--flag` alone is true, `--flag false` is false — a bare presence
586
+ * flag reads the literal as a stray positional and stays true, the silent
587
+ * inversion that once sent a phone number into a place-id lookup). Single
588
+ * -char aliases are short flags (-n); multi-char aliases must be long flags
589
+ * (--page) — commander treats `-page` as a literal short flag. A flag with
590
+ * an injected default always uses `option` (never `requiredOption`):
591
+ * Commander treats a defaulted flag as satisfied.
592
+ */
593
+ function addSchemaOption(cmd, name, param, o) {
594
+ const paramType = Array.isArray(param.type) ? param.type[0] : param.type;
595
+ const shortPrefix = o.shortAlias
596
+ ? `${o.shortAlias.length > 1 ? '--' : '-'}${o.shortAlias}, `
597
+ : '';
598
+ const desc = o.description || param.description || name;
599
+ if (paramType === 'array') {
600
+ const flag = `${shortPrefix}--${name} <values...>`;
601
+ if (o.isRequired) {
602
+ cmd.requiredOption(flag, desc);
603
+ }
604
+ else {
605
+ cmd.option(flag, desc);
606
+ }
607
+ }
608
+ else if (paramType === 'boolean') {
609
+ cmd.option(`${shortPrefix}--${name} [value]`, desc, parseBooleanFlagValue);
610
+ }
611
+ else {
612
+ const flag = `${shortPrefix}--${name} <value>`;
613
+ if (o.injectedDefault !== undefined) {
614
+ cmd.option(flag, desc, o.injectedDefault);
615
+ }
616
+ else if (o.isRequired) {
617
+ cmd.requiredOption(flag, desc);
618
+ }
619
+ else {
620
+ cmd.option(flag, desc);
621
+ }
622
+ }
623
+ }
579
624
  /**
580
625
  * Register a dynamic tool command from a server-provided schema.
581
626
  *
@@ -656,6 +701,17 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
656
701
  // everything optional in that case; the action handler re-validates the
657
702
  // required set AFTER the file merge (#46011).
658
703
  const argsFileInvoked = process.argv.some(a => a === '--args-file' || a.startsWith('--args-file='));
704
+ // Per-action subcommand mode (issue #51377): when the server sends the
705
+ // per-action parameter map, each action registers below as a real
706
+ // Commander subcommand with flags scoped to it. The parent must then NOT
707
+ // own the union flags: in Commander's default (non-positional) mode a
708
+ // parent consumes every option it knows from the whole line BEFORE
709
+ // dispatching, so union flags on the parent would swallow `--chat_id`
710
+ // away from `gsk teams read_chat --chat_id ...`. With no union flags the
711
+ // tokens flow down as unknowns to the dispatched subcommand, which
712
+ // accepts its own and rejects wrong-action flags at parse time. Hidden
713
+ // legacy forms keep the union view.
714
+ const actionViews = cmdOpts?.deprecated ? null : serviceActionViews(tool);
659
715
  // If primary_arg is set, make it a positional argument
660
716
  if (tool.cli.primary_arg && props[tool.cli.primary_arg]) {
661
717
  const param = props[tool.cli.primary_arg];
@@ -674,9 +730,14 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
674
730
  : `[${argName}]`;
675
731
  cmd.argument(bracket, param.description || argName);
676
732
  }
677
- // Add remaining params as --options
733
+ // Add remaining params as --options (union view). Skipped in per-action
734
+ // subcommand mode — see the actionViews comment above; the parent then
735
+ // keeps only the [action] positional (bare invocation → action listing;
736
+ // unknown action names still reach the server) and --args-file.
678
737
  const aliases = tool.cli.parameter_aliases || {};
679
738
  for (const [name, param] of Object.entries(props)) {
739
+ if (actionViews)
740
+ break;
680
741
  if (name === tool.cli.primary_arg)
681
742
  continue;
682
743
  // For create_task, make params optional so --acp mode works without them.
@@ -690,51 +751,16 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
690
751
  argsFileInvoked
691
752
  ? false
692
753
  : required.includes(name);
693
- const paramType = Array.isArray(param.type) ? param.type[0] : param.type;
694
- // Single-char aliases are short flags (-n); multi-char aliases must be
695
- // long flags (--page) — commander treats `-page` as a literal short flag,
696
- // so the documented `--page` form would fail with "unknown option".
697
- const shortAlias = aliases[name];
698
- const shortPrefix = shortAlias
699
- ? `${shortAlias.length > 1 ? '--' : '-'}${shortAlias}, `
700
- : '';
701
754
  // Auto-inject default for vd_project_id on tools in the `vd` group so
702
- // users don't retype it on every call. When a default is present we
703
- // always use `option` (not `requiredOption`) — Commander treats a flag
704
- // with a default value as satisfied and skips the required-missing
705
- // check, which is the behaviour we want here.
755
+ // users don't retype it on every call.
706
756
  const injectedDefault = tool.cli.group === 'vd' && name === 'vd_project_id'
707
757
  ? vdProjectIdDefault
708
758
  : undefined;
709
- if (paramType === 'array') {
710
- const flag = `${shortPrefix}--${name} <values...>`;
711
- if (isRequired) {
712
- cmd.requiredOption(flag, param.description || name);
713
- }
714
- else {
715
- cmd.option(flag, param.description || name);
716
- }
717
- }
718
- else if (paramType === 'boolean') {
719
- // Accept an optional literal value: `--flag` alone means true, but
720
- // `--flag false` must mean false. With a bare presence flag Commander
721
- // treats the literal as a stray positional and the flag still reads
722
- // true — that silent inversion once sent a phone number into a Maps
723
- // place-id lookup (`--is_place_id false`).
724
- cmd.option(`${shortPrefix}--${name} [value]`, param.description || name, parseBooleanFlagValue);
725
- }
726
- else {
727
- const flag = `${shortPrefix}--${name} <value>`;
728
- if (injectedDefault !== undefined) {
729
- cmd.option(flag, param.description || name, injectedDefault);
730
- }
731
- else if (isRequired) {
732
- cmd.requiredOption(flag, param.description || name);
733
- }
734
- else {
735
- cmd.option(flag, param.description || name);
736
- }
737
- }
759
+ addSchemaOption(cmd, name, param, {
760
+ isRequired,
761
+ shortAlias: aliases[name],
762
+ injectedDefault,
763
+ });
738
764
  }
739
765
  // For tools that expose an `attachments` array (email send/draft/reply/
740
766
  // forward across gmail + outlook), also surface human-friendly aliases
@@ -746,7 +772,7 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
746
772
  (Array.isArray(props.attachments.type)
747
773
  ? props.attachments.type.includes('array')
748
774
  : props.attachments.type === 'array');
749
- if (hasAttachmentsParam) {
775
+ if (hasAttachmentsParam && !actionViews) {
750
776
  cmd.option('--attach <values...>', 'Attach local files or URLs (alias for --attachments; repeatable)');
751
777
  cmd.option('--attachment <values...>', 'Attach local files or URLs (alias for --attachments; repeatable)');
752
778
  }
@@ -758,9 +784,11 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
758
784
  'quotes, newlines) instead of double-quoted flags. Server ' +
759
785
  'parameters only — CLI-only options (e.g. --local_file) must ' +
760
786
  'still be passed as flags.');
761
- // Add -o/--output-file option for tools that produce downloadable files
787
+ // Add -o/--output-file option for tools that produce downloadable files.
788
+ // In per-action subcommand mode the subcommands own the flag — on the
789
+ // parent it would be consumed pre-dispatch and lost (see actionViews).
762
790
  const outputFileKeys = tool.cli.output_file_keys || [];
763
- if (outputFileKeys.length > 0) {
791
+ if (outputFileKeys.length > 0 && !actionViews) {
764
792
  cmd.option('-o, --output-file <path>', 'Download the generated file to a local path');
765
793
  }
766
794
  // task ask (task_continue) shares the async-first submit → --follow
@@ -826,657 +854,708 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
826
854
  if (tool.name === 'skills_save') {
827
855
  cmd.argument('[path]', 'Local skill directory to save (default: current directory)');
828
856
  }
829
- // Generic action handler
830
- cmd.action(async (primaryArgValue, opts) => {
831
- // Commander passes opts as second arg when there's an argument,
832
- // or as first arg when there's no argument
833
- if (typeof primaryArgValue === 'object' && primaryArgValue !== null) {
834
- opts = primaryArgValue;
835
- primaryArgValue = undefined;
836
- }
837
- // Service tool invoked with no action list the available actions
838
- // instead of failing on the (intentionally optional) action positional.
839
- const actionEnum = tool.cli.primary_arg
840
- ? props[tool.cli.primary_arg]?.enum
841
- : undefined;
842
- if (tool.cli.primary_arg === 'action' &&
843
- Array.isArray(actionEnum) &&
844
- primaryArgValue === undefined &&
845
- // The action may arrive via --args-file (read later in the
846
- // handler) — don't short-circuit to the action listing then.
847
- !opts.argsFile) {
848
- logError(tool.description);
849
- logError(`Available actions: ${actionEnum.join(', ')}`);
850
- logError(`Usage: gsk ${cmd.name()} <action> [options] ` +
851
- `(run \`gsk ${cmd.name()} --help\` for per-action options)`);
852
- process.exit(1);
853
- }
854
- try {
855
- // get-project: local file lookup for ACP session project info.
856
- // Handled here (not as a Commander subcommand) to avoid
857
- // Commander's subcommand-vs-positional-arg conflict.
858
- if (tool.name === 'create_task' && primaryArgValue === 'get-project') {
859
- const { loadPersistedSession, sanitizeId } = await import('./acp-serve.js');
860
- const rawId = opts.sessionId || '';
861
- const sessionId = sanitizeId(rawId);
862
- if (!sessionId) {
863
- logError('Invalid or missing --session-id');
864
- process.exit(1);
865
- }
866
- const data = loadPersistedSession(sessionId);
867
- if (!data) {
868
- output({ status: 'pending' });
869
- process.exitCode = 1;
870
- return;
871
- }
872
- const baseUrl = getGlobalOptions().baseUrl.replace(/\/$/, '');
873
- output({
874
- session_id: data.sessionId,
875
- project_id: data.projectId,
876
- project_url: `${baseUrl}/agents?id=${data.projectId}`,
877
- task_type: data.taskType,
878
- });
879
- return;
857
+ // Generic invocation body — shared by the union command's action handler
858
+ // and (for service tools) the per-action subcommands, which call it with
859
+ // the action name pre-bound. `invokedCmd` is whichever Commander command
860
+ // actually parsed this invocation (operand access below reads its .args).
861
+ const runToolInvocation = async (primaryArgValue, opts, invokedCmd = cmd) => {
862
+ {
863
+ // Commander passes opts as second arg when there's an argument,
864
+ // or as first arg when there's no argument
865
+ if (typeof primaryArgValue === 'object' && primaryArgValue !== null) {
866
+ opts = primaryArgValue;
867
+ primaryArgValue = undefined;
880
868
  }
881
- // ACP mode: enter stdio bridge instead of one-shot execution
882
- if (tool.name === 'create_task' && opts.acp) {
883
- const taskType = primaryArgValue;
884
- if (!taskType) {
885
- logError('Task type is required for ACP mode. Usage: gsk task <type> --acp');
886
- process.exit(1);
887
- }
888
- delete opts.acp;
889
- const { startAcpBridge } = await import('./acp-serve.js');
890
- await startAcpBridge(taskType, clientFactory().getOptions());
891
- return;
869
+ // Service tool invoked with no action list the available actions
870
+ // instead of failing on the (intentionally optional) action positional.
871
+ const actionEnum = tool.cli.primary_arg
872
+ ? props[tool.cli.primary_arg]?.enum
873
+ : undefined;
874
+ if (tool.cli.primary_arg === 'action' &&
875
+ Array.isArray(actionEnum) &&
876
+ primaryArgValue === undefined &&
877
+ // The action may arrive via --args-file (read later in the
878
+ // handler) — don't short-circuit to the action listing then.
879
+ !opts.argsFile) {
880
+ logError(tool.description);
881
+ logError(`Available actions: ${actionEnum.join(', ')}`);
882
+ logError(`Usage: gsk ${cmd.name()} <action> [options] ` +
883
+ `(run \`gsk ${cmd.name()} --help\` for per-action options)`);
884
+ process.exit(1);
892
885
  }
893
- // For create_task (non-ACP): validate required params manually.
894
- // Skipped when --args-file is present required params may live in
895
- // the file, and the post-merge revalidation below enforces them.
896
- if (tool.name === 'create_task' && !opts.argsFile) {
897
- for (const reqParam of required) {
898
- if (reqParam === tool.cli.primary_arg)
899
- continue;
900
- if (opts[reqParam] === undefined || opts[reqParam] === null) {
901
- logError(`Missing required option: --${reqParam}`);
886
+ try {
887
+ // get-project: local file lookup for ACP session project info.
888
+ // Handled here (not as a Commander subcommand) to avoid
889
+ // Commander's subcommand-vs-positional-arg conflict.
890
+ if (tool.name === 'create_task' && primaryArgValue === 'get-project') {
891
+ const { loadPersistedSession, sanitizeId } = await import('./acp-serve.js');
892
+ const rawId = opts.sessionId || '';
893
+ const sessionId = sanitizeId(rawId);
894
+ if (!sessionId) {
895
+ logError('Invalid or missing --session-id');
902
896
  process.exit(1);
903
897
  }
898
+ const data = loadPersistedSession(sessionId);
899
+ if (!data) {
900
+ output({ status: 'pending' });
901
+ process.exitCode = 1;
902
+ return;
903
+ }
904
+ const baseUrl = getGlobalOptions().baseUrl.replace(/\/$/, '');
905
+ output({
906
+ session_id: data.sessionId,
907
+ project_id: data.projectId,
908
+ project_url: `${baseUrl}/agents?id=${data.projectId}`,
909
+ task_type: data.taskType,
910
+ });
911
+ return;
904
912
  }
905
- }
906
- const client = clientFactory();
907
- // Deprecated legacy form: warn on stderr (agents only relearn from
908
- // invocation-time feedback, not from hidden help text) and tag the
909
- // request so the server's deprecation telemetry can count it.
910
- if (cmdOpts?.deprecated) {
911
- // Group-qualify the legacy label so `gsk phone-call status` and
912
- // the flat `gsk call_status` count as distinct legacy forms.
913
- const legacyName = tool.cli.group
914
- ? `${tool.cli.group} ${cmdName}`
915
- : cmdName;
916
- console.error(`[DEPRECATED] \`gsk ${legacyName}\` still works but is deprecated — ` +
917
- `use \`${cmdOpts.deprecated.replacement}\` instead.`);
918
- client.setInvokedAs(`${legacyName} (legacy)`);
919
- }
920
- else {
921
- client.setInvokedAs(tool.cli.path && tool.cli.path.length >= 2
922
- ? tool.cli.path.join(' ')
923
- : tool.cli.group
924
- ? `${tool.cli.group} ${cmdName}`
925
- : cmdName);
926
- }
927
- // Extract and remove client-side-only options before sending to API
928
- const outputFilePath = opts.outputFile;
929
- delete opts.outputFile;
930
- delete opts.acp; // ACP flag is client-side only
931
- // -o exports an artifact of the FINISHED project — an async submit
932
- // stub has nothing to export yet, so an export request implies the
933
- // live follow.
934
- const attachTask = !!opts.follow || !!outputFilePath;
935
- delete opts.follow; // client-side only
936
- delete opts.sessionId; // get-project flag is client-side only
937
- const argsFilePath = opts.argsFile;
938
- delete opts.argsFile; // client-side only; merged below
939
- // Required checks were relaxed because --args-file appeared in argv;
940
- // an explicitly EMPTY value (`--args-file=` / `--args-file ""`,
941
- // parsed as '') would skip the merge AND the revalidation below — a
942
- // validation bypass, not a degenerate no-op. Refuse it. undefined is
943
- // deliberately allowed: argv detection can false-positive on a
944
- // literal "--args-file" VALUE of another option, and the server-side
945
- // required validation still covers that arm.
946
- if (argsFilePath !== undefined && !argsFilePath) {
947
- logError('--args-file requires a non-empty path (or "-" for stdin)');
948
- process.exit(1);
949
- }
950
- const args = { ...opts };
951
- // Set primary arg
952
- if (tool.cli.primary_arg && primaryArgValue !== undefined) {
953
- args[tool.cli.primary_arg] = primaryArgValue;
954
- }
955
- // Merge --attach / --attachment aliases into args.attachments
956
- // before sending to the API so the server sees a single field.
957
- // Order: --attachments first (canonical), then --attach, then
958
- // --attachment, mirroring how Commander stores variadics.
959
- if (hasAttachmentsParam) {
960
- const merged = [];
961
- for (const key of ['attachments', 'attach', 'attachment']) {
962
- const val = opts[key];
963
- if (Array.isArray(val)) {
964
- for (const item of val) {
965
- if (typeof item === 'string')
966
- merged.push(item);
913
+ // ACP mode: enter stdio bridge instead of one-shot execution
914
+ if (tool.name === 'create_task' && opts.acp) {
915
+ const taskType = primaryArgValue;
916
+ if (!taskType) {
917
+ logError('Task type is required for ACP mode. Usage: gsk task <type> --acp');
918
+ process.exit(1);
919
+ }
920
+ delete opts.acp;
921
+ const { startAcpBridge } = await import('./acp-serve.js');
922
+ await startAcpBridge(taskType, clientFactory().getOptions());
923
+ return;
924
+ }
925
+ // For create_task (non-ACP): validate required params manually.
926
+ // Skipped when --args-file is present — required params may live in
927
+ // the file, and the post-merge revalidation below enforces them.
928
+ if (tool.name === 'create_task' && !opts.argsFile) {
929
+ for (const reqParam of required) {
930
+ if (reqParam === tool.cli.primary_arg)
931
+ continue;
932
+ if (opts[reqParam] === undefined || opts[reqParam] === null) {
933
+ logError(`Missing required option: --${reqParam}`);
934
+ process.exit(1);
967
935
  }
968
936
  }
969
937
  }
970
- if (merged.length > 0) {
971
- args.attachments = merged;
938
+ const client = clientFactory();
939
+ // Deprecated legacy form: warn on stderr (agents only relearn from
940
+ // invocation-time feedback, not from hidden help text) and tag the
941
+ // request so the server's deprecation telemetry can count it.
942
+ if (cmdOpts?.deprecated) {
943
+ // Group-qualify the legacy label so `gsk phone-call status` and
944
+ // the flat `gsk call_status` count as distinct legacy forms.
945
+ const legacyName = tool.cli.group
946
+ ? `${tool.cli.group} ${cmdName}`
947
+ : cmdName;
948
+ console.error(`[DEPRECATED] \`gsk ${legacyName}\` still works but is deprecated — ` +
949
+ `use \`${cmdOpts.deprecated.replacement}\` instead.`);
950
+ client.setInvokedAs(`${legacyName} (legacy)`);
972
951
  }
973
952
  else {
974
- delete args.attachments;
975
- }
976
- // The aliases are CLI-only; the server doesn't know about them.
977
- delete args.attach;
978
- delete args.attachment;
979
- }
980
- // Merge --args-file after every flag-derived write (positional,
981
- // attachment aliases) so file keys genuinely win: the file is the
982
- // deliberate rich payload, flags are the convenience layer (#46011).
983
- // It sits before the numeric coercion below so file-provided
984
- // numeric strings get the same schema-driven coercion flags do.
985
- if (argsFilePath) {
986
- let fileArgs;
987
- try {
988
- const { readArgsFile } = await import('./argsFile.js');
989
- fileArgs = readArgsFile(argsFilePath);
953
+ client.setInvokedAs(tool.cli.path && tool.cli.path.length >= 2
954
+ ? tool.cli.path.join(' ')
955
+ : tool.cli.group
956
+ ? `${tool.cli.group} ${cmdName}`
957
+ : cmdName);
990
958
  }
991
- catch (err) {
992
- logError(err.message);
959
+ // Extract and remove client-side-only options before sending to API
960
+ const outputFilePath = opts.outputFile;
961
+ delete opts.outputFile;
962
+ delete opts.acp; // ACP flag is client-side only
963
+ // -o exports an artifact of the FINISHED project — an async submit
964
+ // stub has nothing to export yet, so an export request implies the
965
+ // live follow.
966
+ const attachTask = !!opts.follow || !!outputFilePath;
967
+ delete opts.follow; // client-side only
968
+ delete opts.sessionId; // get-project flag is client-side only
969
+ // In per-action subcommand mode the PARENT registers --args-file
970
+ // (for the action-in-file invocation) and therefore consumes the
971
+ // flag pre-dispatch even when a subcommand runs — fall back to the
972
+ // parent's parsed value so `gsk teams read_chat --args-file f.json`
973
+ // is honored, never silently dropped.
974
+ const argsFilePath = (opts.argsFile ??
975
+ (invokedCmd !== cmd ? cmd.opts().argsFile : undefined));
976
+ delete opts.argsFile; // client-side only; merged below
977
+ // Required checks were relaxed because --args-file appeared in argv;
978
+ // an explicitly EMPTY value (`--args-file=` / `--args-file ""`,
979
+ // parsed as '') would skip the merge AND the revalidation below — a
980
+ // validation bypass, not a degenerate no-op. Refuse it. undefined is
981
+ // deliberately allowed: argv detection can false-positive on a
982
+ // literal "--args-file" VALUE of another option, and the server-side
983
+ // required validation still covers that arm.
984
+ if (argsFilePath !== undefined && !argsFilePath) {
985
+ logError('--args-file requires a non-empty path (or "-" for stdin)');
993
986
  process.exit(1);
994
987
  }
995
- // The file carries SERVER parameters only. CLI-only options
996
- // (--local_file, --file, --out, ...) are processed from flags by
997
- // client-side blocks below that never see the file — honoring
998
- // them here would silently do nothing, so warn and drop instead.
999
- for (const key of Object.keys(fileArgs)) {
1000
- if (!(key in props)) {
1001
- warn(`--args-file: "${key}" is not a server parameter of ` +
1002
- `${tool.name} and was ignored CLI-only options must be ` +
1003
- 'passed as flags.');
1004
- delete fileArgs[key];
988
+ const args = { ...opts };
989
+ // Set primary arg
990
+ if (tool.cli.primary_arg && primaryArgValue !== undefined) {
991
+ args[tool.cli.primary_arg] = primaryArgValue;
992
+ }
993
+ // Merge --attach / --attachment aliases into args.attachments
994
+ // before sending to the API so the server sees a single field.
995
+ // Order: --attachments first (canonical), then --attach, then
996
+ // --attachment, mirroring how Commander stores variadics.
997
+ if (hasAttachmentsParam) {
998
+ const merged = [];
999
+ for (const key of ['attachments', 'attach', 'attachment']) {
1000
+ const val = opts[key];
1001
+ if (Array.isArray(val)) {
1002
+ for (const item of val) {
1003
+ if (typeof item === 'string')
1004
+ merged.push(item);
1005
+ }
1006
+ }
1007
+ }
1008
+ if (merged.length > 0) {
1009
+ args.attachments = merged;
1010
+ }
1011
+ else {
1012
+ delete args.attachments;
1005
1013
  }
1014
+ // The aliases are CLI-only; the server doesn't know about them.
1015
+ delete args.attach;
1016
+ delete args.attachment;
1006
1017
  }
1007
- Object.assign(args, fileArgs);
1008
- // Required params were registered optional because the file may
1009
- // carry them (Commander validates before the file is readable)
1010
- // re-validate the merged set now. CLI-populated params are
1011
- // exempt: skills_save's `files` is schema-required but filled by
1012
- // the directory collector further down, which deliberately owns
1013
- // the value (file-wins does not apply to collector-owned params).
1014
- for (const reqParam of required) {
1015
- if (tool.name === 'skills_save' && reqParam === 'files')
1016
- continue;
1017
- if (args[reqParam] === undefined || args[reqParam] === null) {
1018
- // The primary arg is a positional — there is no --<name>
1019
- // flag for it, so the recovery hint must not invent one.
1020
- const hint = reqParam === tool.cli.primary_arg
1021
- ? `pass it as the positional argument or include it in --args-file`
1022
- : `pass --${reqParam} or include it in --args-file`;
1023
- logError(`Missing required parameter: ${reqParam} (${hint})`);
1018
+ // Merge --args-file after every flag-derived write (positional,
1019
+ // attachment aliases) so file keys genuinely win: the file is the
1020
+ // deliberate rich payload, flags are the convenience layer (#46011).
1021
+ // It sits before the numeric coercion below so file-provided
1022
+ // numeric strings get the same schema-driven coercion flags do.
1023
+ if (argsFilePath) {
1024
+ let fileArgs;
1025
+ try {
1026
+ const { readArgsFile } = await import('./argsFile.js');
1027
+ fileArgs = readArgsFile(argsFilePath);
1028
+ }
1029
+ catch (err) {
1030
+ logError(err.message);
1024
1031
  process.exit(1);
1025
1032
  }
1026
- }
1027
- }
1028
- // Coerce numeric strings to numbers based on schema
1029
- for (const [name, param] of Object.entries(props)) {
1030
- if (args[name] !== undefined) {
1031
- const paramType = Array.isArray(param.type)
1032
- ? param.type[0]
1033
- : param.type;
1034
- if (paramType === 'integer' || paramType === 'number') {
1035
- const num = Number(args[name]);
1036
- if (!isNaN(num)) {
1037
- args[name] = paramType === 'integer' ? Math.floor(num) : num;
1033
+ // The file carries SERVER parameters only. CLI-only options
1034
+ // (--local_file, --file, --out, ...) are processed from flags by
1035
+ // client-side blocks below that never see the file — honoring
1036
+ // them here would silently do nothing, so warn and drop instead.
1037
+ for (const key of Object.keys(fileArgs)) {
1038
+ if (!(key in props)) {
1039
+ warn(`--args-file: "${key}" is not a server parameter of ` +
1040
+ `${tool.name} and was ignored — CLI-only options must be ` +
1041
+ 'passed as flags.');
1042
+ delete fileArgs[key];
1043
+ }
1044
+ }
1045
+ Object.assign(args, fileArgs);
1046
+ // Required params were registered optional because the file may
1047
+ // carry them (Commander validates before the file is readable) —
1048
+ // re-validate the merged set now. CLI-populated params are
1049
+ // exempt: skills_save's `files` is schema-required but filled by
1050
+ // the directory collector further down, which deliberately owns
1051
+ // the value (file-wins does not apply to collector-owned params).
1052
+ for (const reqParam of required) {
1053
+ if (tool.name === 'skills_save' && reqParam === 'files')
1054
+ continue;
1055
+ if (args[reqParam] === undefined || args[reqParam] === null) {
1056
+ // The primary arg is a positional — there is no --<name>
1057
+ // flag for it, so the recovery hint must not invent one.
1058
+ const hint = reqParam === tool.cli.primary_arg
1059
+ ? `pass it as the positional argument or include it in --args-file`
1060
+ : `pass --${reqParam} or include it in --args-file`;
1061
+ logError(`Missing required parameter: ${reqParam} (${hint})`);
1062
+ process.exit(1);
1038
1063
  }
1039
1064
  }
1040
1065
  }
1041
- }
1042
- // aidrive: strip client-side-only options before sending to API.
1043
- // --local_file and --override are only meaningful to the CLI and must
1044
- // never be forwarded to the server (other aidrive actions like ls,
1045
- // mkdir, move don't know about them).
1046
- if (tool.name === 'aidrive') {
1047
- delete args.local_file;
1048
- delete args.override;
1049
- }
1050
- // sb-git --execute is client-side only; never forward.
1051
- let sbGitExecuteDest;
1052
- let sbGitExecuteRequested = false;
1053
- if (tool.name === 'sb-git') {
1054
- const execVal = args.execute;
1055
- if (execVal !== undefined) {
1056
- sbGitExecuteRequested = true;
1057
- sbGitExecuteDest =
1058
- typeof execVal === 'string' && execVal.length > 0
1059
- ? execVal
1060
- : undefined;
1061
- }
1062
- delete args.execute;
1063
- }
1064
- // skills pull --out is client-side only; never forward. `pull` always
1065
- // materialises to disk (default ./<slug>); --out just overrides where.
1066
- let skillsPullOut;
1067
- if (tool.name === 'skills_pull') {
1068
- const outVal = args.out;
1069
- skillsPullOut =
1070
- typeof outVal === 'string' && outVal.length > 0 ? outVal : undefined;
1071
- delete args.out;
1072
- }
1073
- // skills sync: --check/--mount-dir/--store-dir are CLI-only. The
1074
- // digest `state` sent to the server is always computed here (from
1075
- // the on-disk state file the materializer loop owns), never
1076
- // accepted as user input.
1077
- let skillsSyncCheck = false;
1078
- let skillsSyncStoreDir = '';
1079
- let skillsSyncMountDir = '';
1080
- if (tool.name === 'skills_sync') {
1081
- const { loadState, defaultStoreDir, defaultMountDir } = await import('./commands/skills-sync.js');
1082
- skillsSyncCheck = !!args.check;
1083
- skillsSyncStoreDir = args.storeDir
1084
- ? expandHome(args.storeDir)
1085
- : defaultStoreDir();
1086
- skillsSyncMountDir = args.mountDir
1087
- ? expandHome(args.mountDir)
1088
- : defaultMountDir();
1089
- delete args.check;
1090
- delete args.storeDir;
1091
- delete args.mountDir;
1092
- args.state = JSON.stringify(loadState(skillsSyncStoreDir));
1093
- }
1094
- // skills save: read the local directory (default cwd) and ship its
1095
- // files inline — the inverse of `skills pull`. Client-side because
1096
- // only the local machine can walk a directory tree.
1097
- if (tool.name === 'skills_save') {
1098
- const { collectSkillDir } = await import('./commands/skills-save.js');
1099
- const dir = pathModule.resolve(primaryArgValue || '.');
1100
- let collected;
1101
- try {
1102
- collected = collectSkillDir(dir);
1103
- }
1104
- catch (e) {
1105
- logError(`skills save: ${e.message}`);
1106
- process.exit(1);
1066
+ // Coerce numeric strings to numbers based on schema
1067
+ for (const [name, param] of Object.entries(props)) {
1068
+ if (args[name] !== undefined) {
1069
+ const paramType = Array.isArray(param.type)
1070
+ ? param.type[0]
1071
+ : param.type;
1072
+ if (paramType === 'integer' || paramType === 'number') {
1073
+ const num = Number(args[name]);
1074
+ if (!isNaN(num)) {
1075
+ args[name] = paramType === 'integer' ? Math.floor(num) : num;
1076
+ }
1077
+ }
1078
+ }
1107
1079
  }
1108
- if (collected.sidecarOwner) {
1109
- info(`Warning: ${dir} carries provenance for ${collected.sidecarOwner} ` +
1110
- `saving will fork this skill into your own catalog.`);
1080
+ // aidrive: strip client-side-only options before sending to API.
1081
+ // --local_file and --override are only meaningful to the CLI and must
1082
+ // never be forwarded to the server (other aidrive actions like ls,
1083
+ // mkdir, move don't know about them).
1084
+ if (tool.name === 'aidrive') {
1085
+ delete args.local_file;
1086
+ delete args.override;
1111
1087
  }
1112
- // Collector-owned: `files` must be {path, content} read from the
1113
- // local directory, so this assignment intentionally overrides any
1114
- // --args-file value (the file-wins rule covers flag-shaped params
1115
- // only, not CLI-populated ones).
1116
- args.files = collected.files;
1117
- }
1118
- // aidrive upload ergonomics: `gsk aidrive upload <path>` is the natural
1119
- // invocation and the dominant fumble — the bare path is an excess
1120
- // operand that Commander tolerates but never maps to an option, so the
1121
- // request otherwise reaches the API with no content and fails with a
1122
- // misleading "Missing file_content". When no explicit content source was
1123
- // given, adopt a path-like operand as --local_file (cmd.args[0] is the
1124
- // 'upload' action; later operands are candidate paths, directories
1125
- // skipped) so the streaming upload below handles it; if there is still
1126
- // no usable source, fail with an actionable message that names
1127
- // --local_file rather than the opaque backend error.
1128
- if (tool.name === 'aidrive' &&
1129
- // args.action, not the positional: the action may arrive via
1130
- // --args-file (sb-git's clone-url gate uses the same pattern).
1131
- args.action === 'upload' &&
1132
- !opts.local_file &&
1133
- // args, not opts: file_content is a server param and may have
1134
- // arrived via --args-file (merged into args above).
1135
- !args.file_content) {
1136
- // Skip the first operand only when it really is the action: with
1137
- // the action arriving via --args-file, Commander binds a bare
1138
- // path to the [action] positional slot, so cmd.args[0] is itself
1139
- // a path candidate.
1140
- const operandCandidates = primaryArgValue !== undefined && primaryArgValue === args.action
1141
- ? cmd.args.slice(1)
1142
- : cmd.args;
1143
- const pathOperand = pickExistingUploadOperand(operandCandidates);
1144
- if (pathOperand) {
1145
- opts.local_file = pathOperand;
1088
+ // sb-git --execute is client-side only; never forward.
1089
+ let sbGitExecuteDest;
1090
+ let sbGitExecuteRequested = false;
1091
+ if (tool.name === 'sb-git') {
1092
+ const execVal = args.execute;
1093
+ if (execVal !== undefined) {
1094
+ sbGitExecuteRequested = true;
1095
+ sbGitExecuteDest =
1096
+ typeof execVal === 'string' && execVal.length > 0
1097
+ ? execVal
1098
+ : undefined;
1099
+ }
1100
+ delete args.execute;
1146
1101
  }
1147
- else {
1148
- logError('upload needs a file source: use `--local_file <path>` for a ' +
1149
- 'local file (any size, binaries OK) or `--file_content <text>` ' +
1150
- 'for inline text. Example: gsk aidrive upload --local_file ' +
1151
- './report.pdf --upload_path /report.pdf');
1152
- process.exit(1);
1102
+ // skills pull --out is client-side only; never forward. `pull` always
1103
+ // materialises to disk (default ./<slug>); --out just overrides where.
1104
+ let skillsPullOut;
1105
+ if (tool.name === 'skills_pull') {
1106
+ const outVal = args.out;
1107
+ skillsPullOut =
1108
+ typeof outVal === 'string' && outVal.length > 0 ? outVal : undefined;
1109
+ delete args.out;
1153
1110
  }
1154
- }
1155
- // aidrive: handle --local_file for the upload action.
1156
- // Stream the file directly to AI Drive via the dedicated tool_cli
1157
- // endpoint (POST /api/tool_cli/aidrive/upload) — no blob middleman.
1158
- if (tool.name === 'aidrive' &&
1159
- args.action === 'upload' &&
1160
- opts.local_file) {
1161
- const localFilePath = opts.local_file;
1162
- const override = !!opts.override;
1163
- const workspace = args.workspace || 'personal';
1164
- if (!fs.existsSync(localFilePath)) {
1165
- logError(`File not found: ${localFilePath}`);
1166
- process.exit(1);
1111
+ // skills sync: --check/--mount-dir/--store-dir are CLI-only. The
1112
+ // digest `state` sent to the server is always computed here (from
1113
+ // the on-disk state file the materializer loop owns), never
1114
+ // accepted as user input.
1115
+ let skillsSyncCheck = false;
1116
+ let skillsSyncStoreDir = '';
1117
+ let skillsSyncMountDir = '';
1118
+ if (tool.name === 'skills_sync') {
1119
+ const { loadState, defaultStoreDir, defaultMountDir } = await import('./commands/skills-sync.js');
1120
+ skillsSyncCheck = !!args.check;
1121
+ skillsSyncStoreDir = args.storeDir
1122
+ ? expandHome(args.storeDir)
1123
+ : defaultStoreDir();
1124
+ skillsSyncMountDir = args.mountDir
1125
+ ? expandHome(args.mountDir)
1126
+ : defaultMountDir();
1127
+ delete args.check;
1128
+ delete args.storeDir;
1129
+ delete args.mountDir;
1130
+ args.state = JSON.stringify(loadState(skillsSyncStoreDir));
1167
1131
  }
1168
- // args.upload_path is safe here: args = { ...opts } at line above, and
1169
- // only local_file/override were deleted from args upload_path is intact.
1170
- const rawUploadPath = args.upload_path ||
1171
- `/${pathModule.basename(localFilePath)}`;
1172
- const uploadPath = rawUploadPath.startsWith('/')
1173
- ? rawUploadPath
1174
- : `/${rawUploadPath}`;
1175
- const localStat = fs.statSync(localFilePath);
1176
- const files = collectUploadFiles(localFilePath);
1177
- if (localStat.isDirectory()) {
1178
- if (files.length === 0) {
1179
- logError(`Folder has no files to upload: ${localFilePath}`);
1132
+ // skills save: read the local directory (default cwd) and ship its
1133
+ // files inline the inverse of `skills pull`. Client-side because
1134
+ // only the local machine can walk a directory tree.
1135
+ if (tool.name === 'skills_save') {
1136
+ const { collectSkillDir } = await import('./commands/skills-save.js');
1137
+ const dir = pathModule.resolve(primaryArgValue || '.');
1138
+ let collected;
1139
+ try {
1140
+ collected = collectSkillDir(dir);
1141
+ }
1142
+ catch (e) {
1143
+ logError(`skills save: ${e.message}`);
1180
1144
  process.exit(1);
1181
1145
  }
1182
- info(`Uploading folder ${localFilePath} (${files.length} files)...`);
1183
- const uploaded = [];
1184
- for (const file of files) {
1185
- const destination = pathModule.posix.join(uploadPath, file.relativePath);
1186
- const result = await uploadToAiDrive(file.absolutePath, destination, override, workspace);
1187
- if (result.status !== 'ok') {
1188
- output(result);
1189
- process.exit(1);
1190
- }
1191
- uploaded.push(destination);
1146
+ if (collected.sidecarOwner) {
1147
+ info(`Warning: ${dir} carries provenance for ${collected.sidecarOwner} — ` +
1148
+ `saving will fork this skill into your own catalog.`);
1192
1149
  }
1193
- output({
1194
- status: 'ok',
1195
- message: `Uploaded ${uploaded.length} files`,
1196
- data: { workspace, root: uploadPath, files: uploaded },
1197
- });
1150
+ // Collector-owned: `files` must be {path, content} read from the
1151
+ // local directory, so this assignment intentionally overrides any
1152
+ // --args-file value (the file-wins rule covers flag-shaped params
1153
+ // only, not CLI-populated ones).
1154
+ args.files = collected.files;
1198
1155
  }
1199
- else {
1200
- // Stream directly to AI Drivesingle request, no blob middleman
1201
- const result = await uploadToAiDrive(localFilePath, uploadPath, override, workspace);
1202
- output(result);
1156
+ // aidrive upload ergonomics: `gsk aidrive upload <path>` is the natural
1157
+ // invocation and the dominant fumblethe bare path is an excess
1158
+ // operand that Commander tolerates but never maps to an option, so the
1159
+ // request otherwise reaches the API with no content and fails with a
1160
+ // misleading "Missing file_content". When no explicit content source was
1161
+ // given, adopt a path-like operand as --local_file (cmd.args[0] is the
1162
+ // 'upload' action; later operands are candidate paths, directories
1163
+ // skipped) so the streaming upload below handles it; if there is still
1164
+ // no usable source, fail with an actionable message that names
1165
+ // --local_file rather than the opaque backend error.
1166
+ if (tool.name === 'aidrive' &&
1167
+ // args.action, not the positional: the action may arrive via
1168
+ // --args-file (sb-git's clone-url gate uses the same pattern).
1169
+ args.action === 'upload' &&
1170
+ !opts.local_file &&
1171
+ // args, not opts: file_content is a server param and may have
1172
+ // arrived via --args-file (merged into args above).
1173
+ !args.file_content) {
1174
+ // Skip the first operand only when it really is the action: with
1175
+ // the action arriving via --args-file, Commander binds a bare
1176
+ // path to the [action] positional slot, so cmd.args[0] is itself
1177
+ // a path candidate.
1178
+ const operandCandidates = primaryArgValue !== undefined && primaryArgValue === args.action
1179
+ ? invokedCmd.args.slice(1)
1180
+ : invokedCmd.args;
1181
+ const pathOperand = pickExistingUploadOperand(operandCandidates);
1182
+ if (pathOperand) {
1183
+ opts.local_file = pathOperand;
1184
+ }
1185
+ else {
1186
+ logError('upload needs a file source: use `--local_file <path>` for a ' +
1187
+ 'local file (any size, binaries OK) or `--file_content <text>` ' +
1188
+ 'for inline text. Example: gsk aidrive upload --local_file ' +
1189
+ './report.pdf --upload_path /report.pdf');
1190
+ process.exit(1);
1191
+ }
1203
1192
  }
1204
- return;
1205
- }
1206
- const designAttachmentArgs = collectDesignAttachmentArgs(process.argv);
1207
- const isDesignAttachmentCommand = designAttachmentArgs.length > 0 &&
1208
- ((tool.name === 'create_task' && args.task_type === 'design') ||
1209
- tool.name === 'task_continue');
1210
- if (isDesignAttachmentCommand) {
1211
- const uploaded = await uploadDesignAttachments(designAttachmentArgs, client);
1212
- const existing = Array.isArray(args.attachments)
1213
- ? args.attachments
1214
- : [];
1215
- args.attachments = [...existing, ...uploaded];
1216
- delete args.image;
1217
- delete args.file;
1218
- }
1219
- // Non-Design create_task --file keeps its CSV/Excel file_urls contract.
1220
- if (tool.name === 'create_task' &&
1221
- args.task_type !== 'design' &&
1222
- opts.file) {
1223
- const fileValues = Array.isArray(opts.file)
1224
- ? opts.file
1225
- : [opts.file];
1226
- const fileUrls = [];
1227
- for (const filePath of fileValues) {
1228
- if (isLocalFilePath(filePath)) {
1229
- // isLocalFilePath already verified existence via fs.existsSync
1230
- const uploaded = await uploadLocalFile(filePath, client);
1231
- const basename = pathModule.basename(filePath);
1232
- const sep = uploaded.includes('?') ? '&' : '?';
1233
- fileUrls.push(`${uploaded}${sep}original_name=${encodeURIComponent(basename)}`);
1193
+ // aidrive: handle --local_file for the upload action.
1194
+ // Stream the file directly to AI Drive via the dedicated tool_cli
1195
+ // endpoint (POST /api/tool_cli/aidrive/upload) — no blob middleman.
1196
+ if (tool.name === 'aidrive' &&
1197
+ args.action === 'upload' &&
1198
+ opts.local_file) {
1199
+ const localFilePath = opts.local_file;
1200
+ const override = !!opts.override;
1201
+ const workspace = args.workspace || 'personal';
1202
+ if (!fs.existsSync(localFilePath)) {
1203
+ logError(`File not found: ${localFilePath}`);
1204
+ process.exit(1);
1234
1205
  }
1235
- else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(filePath) ||
1236
- filePath.startsWith('/api/')) {
1237
- // Matches any URI scheme (http://, https://, aidrive://, etc.) or /api/ paths
1238
- fileUrls.push(filePath);
1206
+ // args.upload_path is safe here: args = { ...opts } at line above, and
1207
+ // only local_file/override were deleted from args — upload_path is intact.
1208
+ const rawUploadPath = args.upload_path ||
1209
+ `/${pathModule.basename(localFilePath)}`;
1210
+ const uploadPath = rawUploadPath.startsWith('/')
1211
+ ? rawUploadPath
1212
+ : `/${rawUploadPath}`;
1213
+ const localStat = fs.statSync(localFilePath);
1214
+ const files = collectUploadFiles(localFilePath);
1215
+ if (localStat.isDirectory()) {
1216
+ if (files.length === 0) {
1217
+ logError(`Folder has no files to upload: ${localFilePath}`);
1218
+ process.exit(1);
1219
+ }
1220
+ info(`Uploading folder ${localFilePath} (${files.length} files)...`);
1221
+ const uploaded = [];
1222
+ for (const file of files) {
1223
+ const destination = pathModule.posix.join(uploadPath, file.relativePath);
1224
+ const result = await uploadToAiDrive(file.absolutePath, destination, override, workspace);
1225
+ if (result.status !== 'ok') {
1226
+ output(result);
1227
+ process.exit(1);
1228
+ }
1229
+ uploaded.push(destination);
1230
+ }
1231
+ output({
1232
+ status: 'ok',
1233
+ message: `Uploaded ${uploaded.length} files`,
1234
+ data: { workspace, root: uploadPath, files: uploaded },
1235
+ });
1239
1236
  }
1240
1237
  else {
1241
- // Not a URL and not an existing local file likely a typo
1242
- logError(`File not found: ${filePath}`);
1243
- process.exit(1);
1238
+ // Stream directly to AI Drive single request, no blob middleman
1239
+ const result = await uploadToAiDrive(localFilePath, uploadPath, override, workspace);
1240
+ output(result);
1244
1241
  }
1242
+ return;
1245
1243
  }
1246
- // Merge with any --file_urls passed directly
1247
- const existing = Array.isArray(args.file_urls)
1248
- ? args.file_urls
1249
- : [];
1250
- args.file_urls = [...existing, ...fileUrls];
1251
- delete args.file; // CLI-only option, not a server parameter
1252
- }
1253
- // Resolve local file paths to uploaded URLs
1254
- const resolvedArgs = await resolveLocalFiles(args, client);
1255
- // The server owns the async-first default: create_task /
1256
- // task_continue return a submit stub ({status: "submitted",
1257
- // run_id, stream_url}) unless `--wait true` was passed. --attach
1258
- // upgrades the stub into a live SSE follow that exits with the
1259
- // run's terminal result the work still executes server-side.
1260
- let result = await client.executeTool(tool.name, resolvedArgs);
1261
- const stubData = result.data;
1262
- if (attachTask &&
1263
- result.status === 'ok' &&
1264
- stubData &&
1265
- typeof stubData.stream_url === 'string' &&
1266
- (stubData.status === 'submitted' ||
1267
- stubData.status === 'duplicate_submit_converged')) {
1268
- const meta = await client.attachGskTaskRun(stubData.stream_url);
1269
- const runStatus = String(meta.status || '');
1270
- const summary = meta.result_summary && typeof meta.result_summary === 'object'
1271
- ? meta.result_summary
1272
- : {};
1273
- result = {
1274
- status: runStatus === 'succeeded' ? 'ok' : 'error',
1275
- message: runStatus === 'succeeded'
1276
- ? 'success'
1277
- : `task ended with status ${runStatus || 'unknown'}`,
1278
- data: {
1279
- ...summary,
1280
- run_id: stubData.run_id,
1281
- run_status: runStatus,
1282
- },
1283
- };
1284
- }
1285
- const isSbGitCloneUrl = tool.name === 'sb-git' &&
1286
- resolvedArgs.action === 'clone-url';
1287
- if (isSbGitCloneUrl &&
1288
- result.status === 'ok' &&
1289
- result.data &&
1290
- typeof result.data === 'object') {
1291
- const data = result.data;
1292
- if (typeof data.url === 'string') {
1293
- const before = data.url;
1294
- const after = normalizeCloneUrlForGit(before);
1295
- data.url = after;
1296
- if (Array.isArray(data.commands)) {
1297
- data.commands = data.commands.map(cmd => typeof cmd === 'string' ? cmd.replace(before, after) : cmd);
1244
+ const designAttachmentArgs = collectDesignAttachmentArgs(process.argv);
1245
+ const isDesignAttachmentCommand = designAttachmentArgs.length > 0 &&
1246
+ ((tool.name === 'create_task' && args.task_type === 'design') ||
1247
+ tool.name === 'task_continue');
1248
+ if (isDesignAttachmentCommand) {
1249
+ const uploaded = await uploadDesignAttachments(designAttachmentArgs, client);
1250
+ const existing = Array.isArray(args.attachments)
1251
+ ? args.attachments
1252
+ : [];
1253
+ args.attachments = [...existing, ...uploaded];
1254
+ delete args.image;
1255
+ delete args.file;
1256
+ }
1257
+ // Non-Design create_task --file keeps its CSV/Excel file_urls contract.
1258
+ if (tool.name === 'create_task' &&
1259
+ args.task_type !== 'design' &&
1260
+ opts.file) {
1261
+ const fileValues = Array.isArray(opts.file)
1262
+ ? opts.file
1263
+ : [opts.file];
1264
+ const fileUrls = [];
1265
+ for (const filePath of fileValues) {
1266
+ if (isLocalFilePath(filePath)) {
1267
+ // isLocalFilePath already verified existence via fs.existsSync
1268
+ const uploaded = await uploadLocalFile(filePath, client);
1269
+ const basename = pathModule.basename(filePath);
1270
+ const sep = uploaded.includes('?') ? '&' : '?';
1271
+ fileUrls.push(`${uploaded}${sep}original_name=${encodeURIComponent(basename)}`);
1272
+ }
1273
+ else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(filePath) ||
1274
+ filePath.startsWith('/api/')) {
1275
+ // Matches any URI scheme (http://, https://, aidrive://, etc.) or /api/ paths
1276
+ fileUrls.push(filePath);
1277
+ }
1278
+ else {
1279
+ // Not a URL and not an existing local file — likely a typo
1280
+ logError(`File not found: ${filePath}`);
1281
+ process.exit(1);
1282
+ }
1298
1283
  }
1284
+ // Merge with any --file_urls passed directly
1285
+ const existing = Array.isArray(args.file_urls)
1286
+ ? args.file_urls
1287
+ : [];
1288
+ args.file_urls = [...existing, ...fileUrls];
1289
+ delete args.file; // CLI-only option, not a server parameter
1299
1290
  }
1300
- }
1301
- // sb-git clone-url --execute: substitute placeholders + spawn
1302
- // `git clone` locally. Backend response stays placeholder-only
1303
- // (no secrets in stdout / logs); substitution + subprocess live
1304
- // here because they need in-process auth + access to git.
1305
- if (isSbGitCloneUrl &&
1306
- sbGitExecuteRequested &&
1307
- result.status === 'ok' &&
1308
- result.data &&
1309
- typeof result.data === 'object') {
1310
- const globalOpts = getGlobalOptions();
1311
- const code = await executeCloneUrl({
1312
- data: result.data,
1313
- apiKey: globalOpts.apiKey || '',
1314
- baseUrl: globalOpts.baseUrl,
1315
- explicitDest: sbGitExecuteDest,
1316
- });
1317
- if (code !== 0)
1318
- process.exit(code);
1319
- return;
1320
- }
1321
- // skills pull: decode the returned file rows + write the skill tree to
1322
- // disk (default ./<slug>, or --out <dir>). Client-side because the
1323
- // backend returns JSON, not a filesystem. Pull always materialises;
1324
- // the base64 blobs are dropped from the echoed result below.
1325
- if (tool.name === 'skills_pull' &&
1326
- result.status === 'ok' &&
1327
- result.data &&
1328
- typeof result.data === 'object') {
1329
- const { writeSkillFiles } = await import('./commands/skills-pull.js');
1330
- const code = writeSkillFiles(result.data, skillsPullOut);
1331
- if (code !== 0)
1332
- process.exit(code);
1333
- // Drop the (large, base64) file blobs from the echoed result — the
1334
- // user asked to write to disk, not to dump bytes to stdout.
1335
- const data = result.data;
1336
- delete data.files;
1337
- output(result);
1338
- return;
1339
- }
1340
- // skills sync: `--check` reports a diff without writing. Otherwise
1341
- // materialize the response via applySyncResponse (Task 5), looping
1342
- // while the server signals a truncated bundle (`complete: false`) —
1343
- // only the local machine holds the digest state across calls and
1344
- // can write the resulting file tree / symlinks.
1345
- if (tool.name === 'skills_sync' &&
1346
- result.status === 'ok' &&
1347
- result.data &&
1348
- typeof result.data === 'object') {
1349
- if (skillsSyncCheck) {
1291
+ // Resolve local file paths to uploaded URLs
1292
+ const resolvedArgs = await resolveLocalFiles(args, client);
1293
+ // The server owns the async-first default: create_task /
1294
+ // task_continue return a submit stub ({status: "submitted",
1295
+ // run_id, stream_url}) unless `--wait true` was passed. --attach
1296
+ // upgrades the stub into a live SSE follow that exits with the
1297
+ // run's terminal result — the work still executes server-side.
1298
+ let result = await client.executeTool(tool.name, resolvedArgs);
1299
+ const stubData = result.data;
1300
+ if (attachTask &&
1301
+ result.status === 'ok' &&
1302
+ stubData &&
1303
+ typeof stubData.stream_url === 'string' &&
1304
+ (stubData.status === 'submitted' ||
1305
+ stubData.status === 'duplicate_submit_converged')) {
1306
+ const meta = await client.attachGskTaskRun(stubData.stream_url);
1307
+ const runStatus = String(meta.status || '');
1308
+ const summary = meta.result_summary && typeof meta.result_summary === 'object'
1309
+ ? meta.result_summary
1310
+ : {};
1311
+ result = {
1312
+ status: runStatus === 'succeeded' ? 'ok' : 'error',
1313
+ message: runStatus === 'succeeded'
1314
+ ? 'success'
1315
+ : `task ended with status ${runStatus || 'unknown'}`,
1316
+ data: {
1317
+ ...summary,
1318
+ run_id: stubData.run_id,
1319
+ run_status: runStatus,
1320
+ },
1321
+ };
1322
+ }
1323
+ const isSbGitCloneUrl = tool.name === 'sb-git' &&
1324
+ resolvedArgs.action === 'clone-url';
1325
+ if (isSbGitCloneUrl &&
1326
+ result.status === 'ok' &&
1327
+ result.data &&
1328
+ typeof result.data === 'object') {
1350
1329
  const data = result.data;
1351
- for (const entry of data.skills) {
1352
- const suffix = entry.code ? ` (${entry.code})` : '';
1353
- info(`${entry.slug}: ${entry.action}${suffix}`);
1330
+ if (typeof data.url === 'string') {
1331
+ const before = data.url;
1332
+ const after = normalizeCloneUrlForGit(before);
1333
+ data.url = after;
1334
+ if (Array.isArray(data.commands)) {
1335
+ data.commands = data.commands.map(cmd => typeof cmd === 'string' ? cmd.replace(before, after) : cmd);
1336
+ }
1354
1337
  }
1355
- const dirty = data.skills.some(entry => entry.action !== 'unchanged');
1356
- for (const entry of data.skills)
1357
- delete entry.files;
1338
+ }
1339
+ // sb-git clone-url --execute: substitute placeholders + spawn
1340
+ // `git clone` locally. Backend response stays placeholder-only
1341
+ // (no secrets in stdout / logs); substitution + subprocess live
1342
+ // here because they need in-process auth + access to git.
1343
+ if (isSbGitCloneUrl &&
1344
+ sbGitExecuteRequested &&
1345
+ result.status === 'ok' &&
1346
+ result.data &&
1347
+ typeof result.data === 'object') {
1348
+ const globalOpts = getGlobalOptions();
1349
+ const code = await executeCloneUrl({
1350
+ data: result.data,
1351
+ apiKey: globalOpts.apiKey || '',
1352
+ baseUrl: globalOpts.baseUrl,
1353
+ explicitDest: sbGitExecuteDest,
1354
+ });
1355
+ if (code !== 0)
1356
+ process.exit(code);
1357
+ return;
1358
+ }
1359
+ // skills pull: decode the returned file rows + write the skill tree to
1360
+ // disk (default ./<slug>, or --out <dir>). Client-side because the
1361
+ // backend returns JSON, not a filesystem. Pull always materialises;
1362
+ // the base64 blobs are dropped from the echoed result below.
1363
+ if (tool.name === 'skills_pull' &&
1364
+ result.status === 'ok' &&
1365
+ result.data &&
1366
+ typeof result.data === 'object') {
1367
+ const { writeSkillFiles } = await import('./commands/skills-pull.js');
1368
+ const code = writeSkillFiles(result.data, skillsPullOut);
1369
+ if (code !== 0)
1370
+ process.exit(code);
1371
+ // Drop the (large, base64) file blobs from the echoed result — the
1372
+ // user asked to write to disk, not to dump bytes to stdout.
1373
+ const data = result.data;
1374
+ delete data.files;
1358
1375
  output(result);
1359
- if (dirty)
1360
- process.exit(3);
1361
1376
  return;
1362
1377
  }
1363
- const { applySyncResponse, SyncAggregator } = await import('./commands/skills-sync.js');
1364
- const syncOpts = {
1365
- storeDir: skillsSyncStoreDir,
1366
- mountDir: skillsSyncMountDir,
1367
- };
1368
- let current = result;
1369
- // Fold every batch's outcome into deduped totals (see SyncAggregator):
1370
- // a server `error` row re-appears each batch, so concatenating would
1371
- // multi-count it and the summary + exit code would lie.
1372
- const agg = new SyncAggregator();
1373
- let iterations = 0;
1374
- for (;;) {
1375
- const data = current.data;
1376
- const applyResult = applySyncResponse(data, syncOpts);
1377
- agg.add(applyResult, current.data.skills);
1378
- if (data.complete !== false)
1379
- break;
1380
- iterations += 1;
1381
- if (iterations >= 20) {
1382
- logError('skills sync: exceeded 20 batches without completing — aborting');
1383
- process.exit(1);
1378
+ // skills sync: `--check` reports a diff without writing. Otherwise
1379
+ // materialize the response via applySyncResponse (Task 5), looping
1380
+ // while the server signals a truncated bundle (`complete: false`) —
1381
+ // only the local machine holds the digest state across calls and
1382
+ // can write the resulting file tree / symlinks.
1383
+ if (tool.name === 'skills_sync' &&
1384
+ result.status === 'ok' &&
1385
+ result.data &&
1386
+ typeof result.data === 'object') {
1387
+ if (skillsSyncCheck) {
1388
+ const data = result.data;
1389
+ for (const entry of data.skills) {
1390
+ const suffix = entry.code ? ` (${entry.code})` : '';
1391
+ info(`${entry.slug}: ${entry.action}${suffix}`);
1392
+ }
1393
+ const dirty = data.skills.some(entry => entry.action !== 'unchanged');
1394
+ for (const entry of data.skills)
1395
+ delete entry.files;
1396
+ output(result);
1397
+ if (dirty)
1398
+ process.exit(3);
1399
+ return;
1384
1400
  }
1385
- current = await client.executeTool('skills_sync', {
1386
- state: JSON.stringify(applyResult.state),
1387
- });
1388
- if (current.status !== 'ok' || !current.data) {
1389
- logError(`skills sync: batch ${iterations} failed — ${current.message}`);
1390
- process.exit(1);
1401
+ const { applySyncResponse, SyncAggregator } = await import('./commands/skills-sync.js');
1402
+ const syncOpts = {
1403
+ storeDir: skillsSyncStoreDir,
1404
+ mountDir: skillsSyncMountDir,
1405
+ };
1406
+ let current = result;
1407
+ // Fold every batch's outcome into deduped totals (see SyncAggregator):
1408
+ // a server `error` row re-appears each batch, so concatenating would
1409
+ // multi-count it and the summary + exit code would lie.
1410
+ const agg = new SyncAggregator();
1411
+ let iterations = 0;
1412
+ for (;;) {
1413
+ const data = current.data;
1414
+ const applyResult = applySyncResponse(data, syncOpts);
1415
+ agg.add(applyResult, current.data.skills);
1416
+ if (data.complete !== false)
1417
+ break;
1418
+ iterations += 1;
1419
+ if (iterations >= 20) {
1420
+ logError('skills sync: exceeded 20 batches without completing — aborting');
1421
+ process.exit(1);
1422
+ }
1423
+ current = await client.executeTool('skills_sync', {
1424
+ state: JSON.stringify(applyResult.state),
1425
+ });
1426
+ if (current.status !== 'ok' || !current.data) {
1427
+ logError(`skills sync: batch ${iterations} failed — ${current.message}`);
1428
+ process.exit(1);
1429
+ }
1391
1430
  }
1431
+ info(`synced ${agg.appliedCount} updated / ${agg.removedCount} removed / ${agg.failedCount} failed`);
1432
+ // Emit the aggregated (all-batch, deduped) skill rows rather than only
1433
+ // the final batch's, stripping the base64 file blobs first.
1434
+ const aggregatedSkills = agg.skills;
1435
+ for (const entry of aggregatedSkills)
1436
+ delete entry.files;
1437
+ current.data.skills = aggregatedSkills;
1438
+ output(current);
1439
+ // A boot script runs `sync` every startup and keys off the exit
1440
+ // code; a per-slug failure (mount refused, write error, server
1441
+ // `error` row) must not read as success. Exit 4 (distinct from 3 =
1442
+ // `--check` found a diff, and 1 = the sync loop itself aborted) so
1443
+ // the caller can tell "some skills didn't materialize" from a clean
1444
+ // run. Exit 0 stays reserved for fully-clean.
1445
+ if (agg.failedCount > 0)
1446
+ process.exit(4);
1447
+ return;
1392
1448
  }
1393
- info(`synced ${agg.appliedCount} updated / ${agg.removedCount} removed / ${agg.failedCount} failed`);
1394
- // Emit the aggregated (all-batch, deduped) skill rows rather than only
1395
- // the final batch's, stripping the base64 file blobs first.
1396
- const aggregatedSkills = agg.skills;
1397
- for (const entry of aggregatedSkills)
1398
- delete entry.files;
1399
- current.data.skills = aggregatedSkills;
1400
- output(current);
1401
- // A boot script runs `sync` every startup and keys off the exit
1402
- // code; a per-slug failure (mount refused, write error, server
1403
- // `error` row) must not read as success. Exit 4 (distinct from 3 =
1404
- // `--check` found a diff, and 1 = the sync loop itself aborted) so
1405
- // the caller can tell "some skills didn't materialize" from a clean
1406
- // run. Exit 0 stays reserved for fully-clean.
1407
- if (agg.failedCount > 0)
1408
- process.exit(4);
1409
- return;
1410
- }
1411
- // If -o was specified and tool succeeded, download the output file.
1412
- // Skip for create_task — it has its own export logic below.
1413
- if (tool.name !== 'create_task' &&
1414
- outputFilePath &&
1415
- outputFileKeys.length > 0 &&
1416
- result.status === 'ok' &&
1417
- result.data &&
1418
- typeof result.data === 'object') {
1419
- const fileUrl = extractFileUrl(result.data, outputFileKeys);
1420
- if (fileUrl) {
1421
- const localPath = await downloadToFile(fileUrl, outputFilePath, client);
1422
- result.data.local_path = localPath;
1423
- }
1424
- else {
1425
- info('Warning: Could not find a file URL in the result to download');
1449
+ // If -o was specified and tool succeeded, download the output file.
1450
+ // Skip for create_task it has its own export logic below.
1451
+ if (tool.name !== 'create_task' &&
1452
+ outputFilePath &&
1453
+ outputFileKeys.length > 0 &&
1454
+ result.status === 'ok' &&
1455
+ result.data &&
1456
+ typeof result.data === 'object') {
1457
+ const fileUrl = extractFileUrl(result.data, outputFileKeys);
1458
+ if (fileUrl) {
1459
+ const localPath = await downloadToFile(fileUrl, outputFilePath, client);
1460
+ result.data.local_path = localPath;
1461
+ }
1462
+ else {
1463
+ info('Warning: Could not find a file URL in the result to download');
1464
+ }
1426
1465
  }
1427
- }
1428
- // create_task -o: export artifact (pptx/docx/xlsx) after task completes
1429
- if (tool.name === 'create_task' &&
1430
- outputFilePath &&
1431
- result.status === 'ok' &&
1432
- result.data &&
1433
- typeof result.data === 'object') {
1434
- const data = result.data;
1435
- const projectId = data.project_id;
1436
- // args.type is the value actually sent to the API: the positional
1437
- // is copied into args and --args-file may override it (file wins),
1438
- // so the export must key on the merged view, never the positional.
1439
- const taskType = args.type;
1440
- if (projectId && taskType) {
1441
- info(`Exporting ${taskType} artifact...`);
1442
- try {
1443
- const exportResult = await client.exportArtifact(projectId, taskType);
1444
- if (exportResult.status === 'ok' && exportResult.data) {
1445
- const expData = exportResult.data;
1446
- const downloadUrl = expData.download_url;
1447
- if (downloadUrl) {
1448
- const localPath = await downloadToFile(downloadUrl, outputFilePath, client);
1449
- data.local_path = localPath;
1450
- data.export_format = expData.format;
1466
+ // create_task -o: export artifact (pptx/docx/xlsx) after task completes
1467
+ if (tool.name === 'create_task' &&
1468
+ outputFilePath &&
1469
+ result.status === 'ok' &&
1470
+ result.data &&
1471
+ typeof result.data === 'object') {
1472
+ const data = result.data;
1473
+ const projectId = data.project_id;
1474
+ // args.type is the value actually sent to the API: the positional
1475
+ // is copied into args and --args-file may override it (file wins),
1476
+ // so the export must key on the merged view, never the positional.
1477
+ const taskType = args.type;
1478
+ if (projectId && taskType) {
1479
+ info(`Exporting ${taskType} artifact...`);
1480
+ try {
1481
+ const exportResult = await client.exportArtifact(projectId, taskType);
1482
+ if (exportResult.status === 'ok' && exportResult.data) {
1483
+ const expData = exportResult.data;
1484
+ const downloadUrl = expData.download_url;
1485
+ if (downloadUrl) {
1486
+ const localPath = await downloadToFile(downloadUrl, outputFilePath, client);
1487
+ data.local_path = localPath;
1488
+ data.export_format = expData.format;
1489
+ }
1490
+ }
1491
+ else {
1492
+ info(`Warning: Export not available — ${exportResult.message || 'unknown error'}`);
1451
1493
  }
1452
1494
  }
1453
- else {
1454
- info(`Warning: Export not available — ${exportResult.message || 'unknown error'}`);
1495
+ catch (exportErr) {
1496
+ info(`Warning: Export failed — ${exportErr.message}`);
1455
1497
  }
1456
1498
  }
1457
- catch (exportErr) {
1458
- info(`Warning: Export failed — ${exportErr.message}`);
1459
- }
1460
1499
  }
1500
+ // Media generation payloads print compact + a stderr field summary
1501
+ // so agent-side `grep | head` filters can't lose the URLs/task_id
1502
+ // (the fabricated-URL incident behind PR #41690) — see
1503
+ // mediaSummary.ts for the full rationale.
1504
+ for (const line of mediaSummaryLines(tool.name, result)) {
1505
+ info(line);
1506
+ }
1507
+ // web_search payloads are large and agent-consumed — indent-2 here
1508
+ // is pure token overhead on every SAS/Claw search
1509
+ output(result, {
1510
+ compact: tool.name === 'web_search' || Boolean(mediaItemsKeyFor(tool.name)),
1511
+ });
1461
1512
  }
1462
- // Media generation payloads print compact + a stderr field summary
1463
- // so agent-side `grep | head` filters can't lose the URLs/task_id
1464
- // (the fabricated-URL incident behind PR #41690) — see
1465
- // mediaSummary.ts for the full rationale.
1466
- for (const line of mediaSummaryLines(tool.name, result)) {
1467
- info(line);
1513
+ catch (err) {
1514
+ logError(err.message);
1515
+ process.exit(1);
1468
1516
  }
1469
- // web_search payloads are large and agent-consumed — indent-2 here
1470
- // is pure token overhead on every SAS/Claw search
1471
- output(result, {
1472
- compact: tool.name === 'web_search' || Boolean(mediaItemsKeyFor(tool.name)),
1473
- });
1474
1517
  }
1475
- catch (err) {
1476
- logError(err.message);
1477
- process.exit(1);
1518
+ };
1519
+ cmd.action((primaryArgValue, opts) => runToolInvocation(primaryArgValue, opts));
1520
+ // Per-action subcommands (issue #51377) — see the actionViews comment at
1521
+ // the top of this function: a wrong-action flag now fails at parse time
1522
+ // with a did-you-mean, and `gsk teams send --help` shows only that
1523
+ // action's flags, while the flag-free parent still handles the bare
1524
+ // invocation (action listing), --args-file carrying the action, and
1525
+ // unknown action names (surfaced server-side).
1526
+ if (actionViews) {
1527
+ const allActionNames = actionViews.map(view => view.name);
1528
+ for (const view of actionViews) {
1529
+ const sub = cmd.command(view.name).description(view.description);
1530
+ for (const paramName of view.parameters) {
1531
+ addSchemaOption(sub, paramName, props[paramName], {
1532
+ // Same relaxation as the union path: with --args-file on the
1533
+ // command line, required params may live in the file, which
1534
+ // Commander cannot see at parse time.
1535
+ isRequired: argsFileInvoked
1536
+ ? false
1537
+ : view.required.includes(paramName),
1538
+ shortAlias: aliases[paramName],
1539
+ injectedDefault: tool.cli.group === 'vd' && paramName === 'vd_project_id'
1540
+ ? vdProjectIdDefault
1541
+ : undefined,
1542
+ description: actionScopedDescription(props[paramName].description || paramName, view.name, allActionNames),
1543
+ });
1544
+ }
1545
+ if (hasAttachmentsParam && view.parameters.includes('attachments')) {
1546
+ sub.option('--attach <values...>', 'Attach local files or URLs (alias for --attachments; repeatable)');
1547
+ sub.option('--attachment <values...>', 'Attach local files or URLs (alias for --attachments; repeatable)');
1548
+ }
1549
+ sub.option('--args-file <path>', 'Read arguments from a JSON object file ("-" = stdin). File keys ' +
1550
+ 'override flag values. Use for rich text (strings containing $, ' +
1551
+ 'quotes, newlines) instead of double-quoted flags. Server ' +
1552
+ 'parameters only — CLI-only options must still be passed as flags.');
1553
+ if (outputFileKeys.length > 0) {
1554
+ sub.option('-o, --output-file <path>', 'Download the generated file to a local path');
1555
+ }
1556
+ sub.action((opts) => runToolInvocation(view.name, opts, sub));
1478
1557
  }
1479
- });
1558
+ }
1480
1559
  }
1481
1560
  // ============================================
1482
1561
  // Built-in Commands