@genspark/cli 1.5.4 → 1.6.2

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