incur 0.4.8 → 0.4.10

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.
Files changed (88) hide show
  1. package/dist/Cli.d.ts +69 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +419 -73
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +44 -13
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Parser.d.ts +14 -0
  21. package/dist/Parser.d.ts.map +1 -1
  22. package/dist/Parser.js +127 -1
  23. package/dist/Parser.js.map +1 -1
  24. package/dist/Skill.d.ts.map +1 -1
  25. package/dist/Skill.js +5 -2
  26. package/dist/Skill.js.map +1 -1
  27. package/dist/Skillgen.d.ts.map +1 -1
  28. package/dist/Skillgen.js +4 -38
  29. package/dist/Skillgen.js.map +1 -1
  30. package/dist/SyncMcp.d.ts.map +1 -1
  31. package/dist/SyncMcp.js +75 -8
  32. package/dist/SyncMcp.js.map +1 -1
  33. package/dist/SyncSkills.d.ts +9 -0
  34. package/dist/SyncSkills.d.ts.map +1 -1
  35. package/dist/SyncSkills.js +13 -74
  36. package/dist/SyncSkills.js.map +1 -1
  37. package/dist/bin.d.ts +1 -1
  38. package/dist/bin.d.ts.map +1 -1
  39. package/dist/internal/command.d.ts +31 -19
  40. package/dist/internal/command.d.ts.map +1 -1
  41. package/dist/internal/command.js +10 -5
  42. package/dist/internal/command.js.map +1 -1
  43. package/dist/internal/cta.d.ts +22 -0
  44. package/dist/internal/cta.d.ts.map +1 -0
  45. package/dist/internal/cta.js +25 -0
  46. package/dist/internal/cta.js.map +1 -0
  47. package/dist/internal/json.d.ts +5 -0
  48. package/dist/internal/json.d.ts.map +1 -0
  49. package/dist/internal/json.js +13 -0
  50. package/dist/internal/json.js.map +1 -0
  51. package/dist/internal/yaml.d.ts +12 -0
  52. package/dist/internal/yaml.d.ts.map +1 -0
  53. package/dist/internal/yaml.js +20 -0
  54. package/dist/internal/yaml.js.map +1 -0
  55. package/dist/middleware.d.ts +8 -4
  56. package/dist/middleware.d.ts.map +1 -1
  57. package/dist/middleware.js +1 -1
  58. package/dist/middleware.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/Cli.test-d.ts +73 -0
  61. package/src/Cli.test.ts +1536 -48
  62. package/src/Cli.ts +606 -108
  63. package/src/Completions.test.ts +85 -0
  64. package/src/Completions.ts +48 -9
  65. package/src/Formatter.test.ts +17 -0
  66. package/src/Formatter.ts +7 -5
  67. package/src/Help.test.ts +32 -1
  68. package/src/Help.ts +42 -4
  69. package/src/Mcp.test.ts +289 -0
  70. package/src/Mcp.ts +68 -13
  71. package/src/Parser.test.ts +173 -0
  72. package/src/Parser.ts +132 -1
  73. package/src/Skill.test.ts +6 -0
  74. package/src/Skill.ts +8 -5
  75. package/src/Skillgen.test.ts +55 -6
  76. package/src/Skillgen.ts +9 -35
  77. package/src/SyncMcp.test.ts +89 -0
  78. package/src/SyncMcp.ts +72 -8
  79. package/src/SyncSkills.test.ts +78 -1
  80. package/src/SyncSkills.ts +20 -78
  81. package/src/bun-compile.test.ts +5 -0
  82. package/src/e2e.test.ts +166 -12
  83. package/src/internal/command.ts +12 -4
  84. package/src/internal/cta.ts +58 -0
  85. package/src/internal/json.ts +16 -0
  86. package/src/internal/yaml.ts +23 -0
  87. package/src/lazy-imports.test.ts +94 -0
  88. package/src/middleware.ts +12 -3
package/dist/Cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
+ import { PassThrough } from 'node:stream';
4
5
  import { estimateTokenCount, sliceByTokens } from 'tokenx';
5
- import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
6
6
  import { z } from 'zod';
7
7
  import * as Completions from './Completions.js';
8
8
  import { IncurError, ParseError, ValidationError } from './Errors.js';
@@ -12,8 +12,11 @@ import * as Formatter from './Formatter.js';
12
12
  import * as Help from './Help.js';
13
13
  import { builtinCommands, findBuiltin, findBuiltinSubcommand, shells, } from './internal/command.js';
14
14
  import * as Command from './internal/command.js';
15
+ import { formatCtaBlock } from './internal/cta.js';
15
16
  import { isRecord, suggest, toKebab } from './internal/helpers.js';
17
+ import * as Json from './internal/json.js';
16
18
  import { detectRunner } from './internal/pm.js';
19
+ import * as Yaml from './internal/yaml.js';
17
20
  import * as Mcp from './Mcp.js';
18
21
  import * as Openapi from './Openapi.js';
19
22
  import * as Parser from './Parser.js';
@@ -21,6 +24,7 @@ import * as Schema from './Schema.js';
21
24
  import * as Skill from './Skill.js';
22
25
  import * as SyncMcp from './SyncMcp.js';
23
26
  import * as SyncSkills from './SyncSkills.js';
27
+ const destructiveCommandHint = 'Confirm with the user before executing this destructive command.';
24
28
  export function create(nameOrDefinition, definition) {
25
29
  const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name;
26
30
  const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition;
@@ -31,7 +35,9 @@ export function create(nameOrDefinition, definition) {
31
35
  const commands = new Map();
32
36
  const middlewares = [];
33
37
  const pending = [];
34
- const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0');
38
+ const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0', {
39
+ stateless: def.mcp?.stateless,
40
+ });
35
41
  if (def.openapi && rootFetch) {
36
42
  pending.push((async () => {
37
43
  const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl });
@@ -61,12 +67,14 @@ export function create(nameOrDefinition, definition) {
61
67
  basePath: def.basePath,
62
68
  config: def.openapiConfig,
63
69
  });
64
- commands.set(nameOrCli, {
70
+ const entry = {
65
71
  _group: true,
66
72
  description: def.description,
67
73
  commands: generated,
68
74
  ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
69
- });
75
+ };
76
+ assertNoGlobalOptionConflicts(nameOrCli, entry, toGlobals.get(cli));
77
+ commands.set(nameOrCli, entry);
70
78
  })());
71
79
  return cli;
72
80
  }
@@ -79,6 +87,7 @@ export function create(nameOrDefinition, definition) {
79
87
  });
80
88
  return cli;
81
89
  }
90
+ assertNoGlobalOptionConflicts(nameOrCli, def, toGlobals.get(cli));
82
91
  commands.set(nameOrCli, def);
83
92
  if (def.aliases)
84
93
  for (const a of def.aliases)
@@ -87,6 +96,7 @@ export function create(nameOrDefinition, definition) {
87
96
  }
88
97
  const mountedRootDef = toRootDefinition.get(nameOrCli);
89
98
  if (mountedRootDef) {
99
+ assertNoGlobalOptionConflicts(nameOrCli.name, mountedRootDef, toGlobals.get(cli));
90
100
  commands.set(nameOrCli.name, mountedRootDef);
91
101
  const rootAliases = toRootAliases.get(nameOrCli);
92
102
  if (rootAliases)
@@ -98,21 +108,25 @@ export function create(nameOrDefinition, definition) {
98
108
  const subCommands = toCommands.get(sub);
99
109
  const subOutputPolicy = toOutputPolicy.get(sub);
100
110
  const subMiddlewares = toMiddlewares.get(sub);
101
- commands.set(sub.name, {
111
+ const entry = {
102
112
  _group: true,
103
113
  description: sub.description,
104
114
  commands: subCommands,
105
115
  ...(subOutputPolicy ? { outputPolicy: subOutputPolicy } : undefined),
106
116
  ...(subMiddlewares?.length ? { middlewares: subMiddlewares } : undefined),
107
- });
117
+ };
118
+ assertNoGlobalOptionConflicts(sub.name, entry, toGlobals.get(cli));
119
+ commands.set(sub.name, entry);
108
120
  return cli;
109
121
  },
110
122
  async fetch(req) {
111
123
  if (pending.length > 0)
112
124
  await Promise.all(pending);
125
+ const globalsDesc = toGlobals.get(cli);
113
126
  return fetchImpl(name, commands, req, {
114
127
  description: def.description,
115
128
  envSchema: def.env,
129
+ globals: globalsDesc,
116
130
  mcpHandler,
117
131
  middlewares,
118
132
  name,
@@ -124,13 +138,16 @@ export function create(nameOrDefinition, definition) {
124
138
  async serve(argv = process.argv.slice(2), serveOptions = {}) {
125
139
  if (pending.length > 0)
126
140
  await Promise.all(pending);
141
+ const globalsDesc = toGlobals.get(cli);
127
142
  return serveImpl(name, commands, argv, {
128
143
  ...serveOptions,
129
144
  aliases: def.aliases,
145
+ banner: def.banner,
130
146
  config: def.config,
131
147
  description: def.description,
132
148
  envSchema: def.env,
133
149
  format: def.format,
150
+ globals: globalsDesc,
134
151
  mcp: def.mcp,
135
152
  middlewares,
136
153
  outputPolicy: def.outputPolicy,
@@ -156,6 +173,40 @@ export function create(nameOrDefinition, definition) {
156
173
  toConfigEnabled.set(cli, true);
157
174
  if (def.outputPolicy)
158
175
  toOutputPolicy.set(cli, def.outputPolicy);
176
+ if (def.globals) {
177
+ toGlobals.set(cli, { schema: def.globals, alias: def.globalAlias });
178
+ const builtinNames = [
179
+ 'verbose',
180
+ 'format',
181
+ 'json',
182
+ 'llms',
183
+ 'llmsFull',
184
+ 'mcp',
185
+ 'help',
186
+ 'version',
187
+ 'schema',
188
+ 'filterOutput',
189
+ 'tokenLimit',
190
+ 'tokenOffset',
191
+ 'tokenCount',
192
+ ...(def.config?.flag
193
+ ? [def.config.flag, `no${def.config.flag[0].toUpperCase()}${def.config.flag.slice(1)}`]
194
+ : []),
195
+ ];
196
+ const globalKeys = Object.keys(def.globals.shape);
197
+ for (const key of globalKeys) {
198
+ if (builtinNames.includes(key))
199
+ throw new Error(`Global option '${key}' conflicts with a built-in flag. Choose a different name.`);
200
+ }
201
+ // Check globalAlias values against reserved short aliases
202
+ const reservedShorts = new Set(['h']);
203
+ if (def.globalAlias) {
204
+ for (const [name, short] of Object.entries(def.globalAlias)) {
205
+ if (reservedShorts.has(short))
206
+ throw new Error(`Global alias '-${short}' for '${name}' conflicts with a built-in short flag. Choose a different alias.`);
207
+ }
208
+ }
209
+ }
159
210
  toMiddlewares.set(cli, middlewares);
160
211
  toCommands.set(cli, commands);
161
212
  return cli;
@@ -172,6 +223,22 @@ async function serveImpl(name, commands, argv, options = {}) {
172
223
  function writeln(s) {
173
224
  stdout(s.endsWith('\n') ? s : `${s}\n`);
174
225
  }
226
+ async function writeBanner() {
227
+ if (!options.banner || help)
228
+ return;
229
+ const banner = typeof options.banner === 'function'
230
+ ? { render: options.banner, mode: 'all' }
231
+ : options.banner;
232
+ const mode = banner.mode ?? 'all';
233
+ if (mode !== 'all' && mode !== (human ? 'human' : 'agent'))
234
+ return;
235
+ try {
236
+ const text = await banner.render();
237
+ if (text)
238
+ writeln(text);
239
+ }
240
+ catch { }
241
+ }
175
242
  let builtinFlags;
176
243
  try {
177
244
  builtinFlags = extractBuiltinFlags(argv, { configFlag });
@@ -185,7 +252,36 @@ async function serveImpl(name, commands, argv, options = {}) {
185
252
  exit(1);
186
253
  return;
187
254
  }
188
- const { fullOutput, format: formatFlag, formatExplicit, filterOutput, tokenLimit, tokenOffset, tokenCount, llms, llmsFull, mcp: mcpFlag, help, version, schema, configPath, configDisabled, rest: filtered, } = builtinFlags;
255
+ const { fullOutput, format: formatFlag, formatExplicit, filterOutput, tokenLimit, tokenOffset, tokenCount, llms, llmsFull, mcp: mcpFlag, help, version, schema, configPath, configDisabled, rest, } = builtinFlags;
256
+ let globals = {};
257
+ let filtered = rest;
258
+ function parseGlobalOptions(validate) {
259
+ if (!options.globals)
260
+ return true;
261
+ try {
262
+ const result = Parser.parseGlobals(rest, options.globals.schema, options.globals.alias, {
263
+ validate,
264
+ });
265
+ if (validate)
266
+ globals = result.parsed;
267
+ filtered = result.rest;
268
+ return true;
269
+ }
270
+ catch (error) {
271
+ const message = error instanceof Error ? error.message : String(error);
272
+ if (human)
273
+ writeln(formatHumanError({ code: 'UNKNOWN', message }));
274
+ else
275
+ writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'));
276
+ exit(1);
277
+ return false;
278
+ }
279
+ }
280
+ if (!parseGlobalOptions(false))
281
+ return;
282
+ // Pre-load yaml for the sync formatting paths below (yaml is loaded lazily -- see internal/yaml.ts).
283
+ if (formatFlag === 'yaml')
284
+ await Yaml.load();
189
285
  // --mcp: start as MCP stdio server
190
286
  if (mcpFlag) {
191
287
  await Mcp.serve(name, options.version ?? '0.0.0', commands, {
@@ -193,6 +289,7 @@ async function serveImpl(name, commands, argv, options = {}) {
193
289
  env: options.envSchema,
194
290
  vars: options.vars,
195
291
  version: options.version,
292
+ ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined),
196
293
  });
197
294
  return;
198
295
  }
@@ -209,7 +306,9 @@ async function serveImpl(name, commands, argv, options = {}) {
209
306
  }
210
307
  else {
211
308
  const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1);
212
- const candidates = Completions.complete(commands, options.rootCommand, words, index);
309
+ const candidates = Completions.complete(commands, options.rootCommand, words, index, options.globals
310
+ ? { schema: options.globals.schema, alias: options.globals.alias }
311
+ : undefined);
213
312
  // Add built-in commands (completions, mcp, skills) to completions
214
313
  const current = words[index] ?? '';
215
314
  const nonFlags = words.slice(0, index).filter((w) => !w.startsWith('-'));
@@ -282,25 +381,29 @@ async function serveImpl(name, commands, argv, options = {}) {
282
381
  }
283
382
  }
284
383
  const scopedRoot = prefix.length === 0 ? options.rootCommand : undefined;
384
+ // Markdown skill output renders scopedName separately. Passing prefix again
385
+ // to those collect helpers would double the group segment in command names
386
+ // (e.g. "cli auth auth login" instead of "cli auth login").
387
+ const collectPrefix = prefix.length > 0 ? [] : prefix;
285
388
  if (llmsFull) {
286
389
  if (!formatExplicit || formatFlag === 'md') {
287
390
  const groups = new Map();
288
- const cmds = collectSkillCommands(scopedCommands, prefix, groups, scopedRoot);
391
+ const cmds = collectSkillCommands(scopedCommands, collectPrefix, groups, scopedRoot);
289
392
  const scopedName = prefix.length > 0 ? `${name} ${prefix.join(' ')}` : name;
290
393
  writeln(Skill.generate(scopedName, cmds, groups));
291
394
  return;
292
395
  }
293
- writeln(Formatter.format(buildManifest(scopedCommands, prefix), formatFlag));
396
+ writeln(Formatter.format(buildManifest(scopedCommands, prefix, options.globals?.schema), formatFlag));
294
397
  return;
295
398
  }
296
399
  if (!formatExplicit || formatFlag === 'md') {
297
400
  const groups = new Map();
298
- const cmds = collectSkillCommands(scopedCommands, prefix, groups, scopedRoot);
401
+ const cmds = collectSkillCommands(scopedCommands, collectPrefix, groups, scopedRoot);
299
402
  const scopedName = prefix.length > 0 ? `${name} ${prefix.join(' ')}` : name;
300
403
  writeln(Skill.index(scopedName, cmds, scopedDescription));
301
404
  return;
302
405
  }
303
- writeln(Formatter.format(buildIndexManifest(scopedCommands, prefix), formatFlag));
406
+ writeln(Formatter.format(buildIndexManifest(scopedCommands, prefix, options.globals?.schema), formatFlag));
304
407
  return;
305
408
  }
306
409
  // completions <shell>: print shell hook script to stdout
@@ -465,12 +568,15 @@ async function serveImpl(name, commands, argv, options = {}) {
465
568
  }
466
569
  return;
467
570
  }
468
- // mcp add: register CLI as MCP server via `npx add-mcp`
571
+ // mcp add/doctor: register or smoke-test CLI MCP server integration.
469
572
  const mcpIdx = builtinIdx(filtered, name, 'mcp');
470
573
  if (mcpIdx !== -1) {
574
+ const builtin = findBuiltin('mcp');
471
575
  const mcpSub = filtered[mcpIdx + 1];
472
- if (mcpSub && mcpSub !== 'add') {
473
- const suggestion = suggest(mcpSub, ['add']);
576
+ const sub = mcpSub ? findBuiltinSubcommand(builtin, mcpSub) : undefined;
577
+ if (mcpSub && !sub) {
578
+ const candidates = builtin.subcommands?.flatMap((sub) => [sub.name, ...(sub.aliases ?? [])]) ?? [];
579
+ const suggestion = suggest(mcpSub, candidates);
474
580
  const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
475
581
  const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`;
476
582
  const ctaCommands = [];
@@ -493,13 +599,18 @@ async function serveImpl(name, commands, argv, options = {}) {
493
599
  return;
494
600
  }
495
601
  if (!mcpSub) {
496
- const b = findBuiltin('mcp');
497
- writeln(formatBuiltinHelp(name, b));
602
+ writeln(formatBuiltinHelp(name, builtin));
498
603
  return;
499
604
  }
500
605
  if (help) {
501
- const b = findBuiltin('mcp');
502
- writeln(formatBuiltinSubcommandHelp(name, b, 'add'));
606
+ writeln(formatBuiltinSubcommandHelp(name, builtin, sub.name));
607
+ return;
608
+ }
609
+ if (sub.name === 'doctor') {
610
+ const result = await runMcpDoctor(name, commands, options);
611
+ writeln(Formatter.format(result, formatExplicit ? formatFlag : 'toon'));
612
+ if (!result.ok)
613
+ exit(1);
503
614
  return;
504
615
  }
505
616
  const rest = filtered.slice(mcpIdx + 2);
@@ -556,11 +667,13 @@ async function serveImpl(name, commands, argv, options = {}) {
556
667
  hasRequiredArgs(options.rootCommand.args)) {
557
668
  // Root command with args but none provided (human mode) — show help
558
669
  const cmd = options.rootCommand;
670
+ await writeBanner();
559
671
  writeln(Help.formatCommand(name, {
560
672
  alias: cmd.alias,
561
673
  aliases: options.aliases,
562
674
  configFlag,
563
675
  description: cmd.description ?? options.description,
676
+ globals: options.globals,
564
677
  version: options.version,
565
678
  args: cmd.args,
566
679
  env: cmd.env,
@@ -578,10 +691,12 @@ async function serveImpl(name, commands, argv, options = {}) {
578
691
  // Root command/fetch with no args — treat as root invocation
579
692
  }
580
693
  else {
694
+ await writeBanner();
581
695
  writeln(Help.formatRoot(name, {
582
696
  aliases: options.aliases,
583
697
  configFlag,
584
698
  description: options.description,
699
+ globals: options.globals,
585
700
  version: options.version,
586
701
  commands: collectHelpCommands(commands),
587
702
  root: true,
@@ -635,6 +750,7 @@ async function serveImpl(name, commands, argv, options = {}) {
635
750
  aliases: options.aliases,
636
751
  configFlag,
637
752
  description: cmd.description ?? options.description,
753
+ globals: options.globals,
638
754
  version: options.version,
639
755
  args: cmd.args,
640
756
  env: cmd.env,
@@ -652,6 +768,7 @@ async function serveImpl(name, commands, argv, options = {}) {
652
768
  aliases: isRoot ? options.aliases : undefined,
653
769
  configFlag,
654
770
  description: helpDesc,
771
+ globals: options.globals,
655
772
  version: isRoot ? options.version : undefined,
656
773
  commands: collectHelpCommands(helpCmds),
657
774
  root: isRoot,
@@ -670,6 +787,7 @@ async function serveImpl(name, commands, argv, options = {}) {
670
787
  aliases: isRootCmd ? options.aliases : cmd.aliases,
671
788
  configFlag,
672
789
  description: cmd.description,
790
+ globals: options.globals,
673
791
  version: isRootCmd ? options.version : undefined,
674
792
  args: cmd.args,
675
793
  env: cmd.env,
@@ -690,6 +808,7 @@ async function serveImpl(name, commands, argv, options = {}) {
690
808
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
691
809
  configFlag,
692
810
  description: resolved.description,
811
+ globals: options.globals,
693
812
  commands: collectHelpCommands(resolved.commands),
694
813
  }));
695
814
  return;
@@ -718,6 +837,8 @@ async function serveImpl(name, commands, argv, options = {}) {
718
837
  result.options = Schema.toJsonSchema(cmd.options);
719
838
  if (cmd.output)
720
839
  result.output = Schema.toJsonSchema(cmd.output);
840
+ if (options.globals?.schema)
841
+ result.globals = Schema.toJsonSchema(options.globals.schema);
721
842
  writeln(Formatter.format(result, format));
722
843
  return;
723
844
  }
@@ -725,6 +846,7 @@ async function serveImpl(name, commands, argv, options = {}) {
725
846
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
726
847
  configFlag,
727
848
  description: resolved.description,
849
+ globals: options.globals,
728
850
  commands: collectHelpCommands(resolved.commands),
729
851
  }));
730
852
  return;
@@ -733,6 +855,8 @@ async function serveImpl(name, commands, argv, options = {}) {
733
855
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
734
856
  const resolvedFormat = 'command' in resolved && resolved.command.format;
735
857
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon';
858
+ if (format === 'yaml')
859
+ await Yaml.load();
736
860
  // Fall back to root fetch/command when no subcommand matches,
737
861
  // but only if the token doesn't look like a typo of a known command.
738
862
  const rootFallbackBlocked = 'error' in resolved &&
@@ -888,6 +1012,8 @@ async function serveImpl(name, commands, argv, options = {}) {
888
1012
  }
889
1013
  // Fetch gateway execution path
890
1014
  if ('fetchGateway' in effective) {
1015
+ if (!parseGlobalOptions(true))
1016
+ return;
891
1017
  const { fetchGateway, path, rest: fetchRest } = effective;
892
1018
  const fetchMiddleware = [
893
1019
  ...(options.middlewares ?? []),
@@ -963,6 +1089,7 @@ async function serveImpl(name, commands, argv, options = {}) {
963
1089
  error: errorFn,
964
1090
  format,
965
1091
  formatExplicit,
1092
+ globals,
966
1093
  name,
967
1094
  set(key, value) {
968
1095
  varsMap[key] = value;
@@ -1012,7 +1139,9 @@ async function serveImpl(name, commands, argv, options = {}) {
1012
1139
  }
1013
1140
  return;
1014
1141
  }
1015
- const { command, path, rest } = effective;
1142
+ const { command, path, rest: commandRest } = effective;
1143
+ if (!parseGlobalOptions(true))
1144
+ return;
1016
1145
  // Collect middleware: root CLI + groups traversed + per-command
1017
1146
  const allMiddleware = [
1018
1147
  ...(options.middlewares ?? []),
@@ -1022,7 +1151,7 @@ async function serveImpl(name, commands, argv, options = {}) {
1022
1151
  ...(command.middleware ?? []),
1023
1152
  ];
1024
1153
  if (human)
1025
- emitDeprecationWarnings(rest, command.options, command.alias);
1154
+ emitDeprecationWarnings(commandRest, command.options, command.alias);
1026
1155
  let defaults;
1027
1156
  if (configEnabled) {
1028
1157
  try {
@@ -1048,13 +1177,14 @@ async function serveImpl(name, commands, argv, options = {}) {
1048
1177
  }
1049
1178
  const result = await Command.execute(command, {
1050
1179
  agent: !human,
1051
- argv: rest,
1180
+ argv: commandRest,
1052
1181
  defaults,
1053
1182
  displayName,
1054
1183
  env: options.envSchema,
1055
1184
  envSource: options.env,
1056
1185
  format,
1057
1186
  formatExplicit,
1187
+ globals,
1058
1188
  inputOptions: {},
1059
1189
  middlewares: allMiddleware,
1060
1190
  name,
@@ -1123,11 +1253,14 @@ async function serveImpl(name, commands, argv, options = {}) {
1123
1253
  }
1124
1254
  }
1125
1255
  /** @internal Creates a lazy MCP HTTP handler scoped to a CLI instance. */
1126
- function createMcpHttpHandler(name, version) {
1256
+ function createMcpHttpHandler(name, version, options = {}) {
1127
1257
  let transport;
1128
1258
  return async (req, commands, mcpOptions) => {
1259
+ const stateless = options.stateless ?? true;
1260
+ if (stateless && req.method !== 'POST')
1261
+ return new Response(null, { status: 405, headers: { Allow: 'POST' } });
1129
1262
  if (!transport) {
1130
- const { McpServer, WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/server');
1263
+ const { fromJsonSchema, McpServer, WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/server');
1131
1264
  const server = new McpServer({ name, version });
1132
1265
  for (const tool of Mcp.collectTools(commands, [])) {
1133
1266
  const mergedShape = {
@@ -1138,6 +1271,9 @@ function createMcpHttpHandler(name, version) {
1138
1271
  server.registerTool(tool.name, {
1139
1272
  ...(tool.description ? { description: tool.description } : undefined),
1140
1273
  ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
1274
+ ...(tool.outputSchema
1275
+ ? { outputSchema: fromJsonSchema(tool.outputSchema) }
1276
+ : undefined),
1141
1277
  }, async (...callArgs) => {
1142
1278
  const params = hasInput ? callArgs[0] : {};
1143
1279
  return Mcp.callTool(tool, params, {
@@ -1149,10 +1285,13 @@ function createMcpHttpHandler(name, version) {
1149
1285
  });
1150
1286
  });
1151
1287
  }
1152
- transport = new WebStandardStreamableHTTPServerTransport({
1153
- sessionIdGenerator: () => crypto.randomUUID(),
1154
- enableJsonResponse: true,
1155
- });
1288
+ const transportOptions = stateless
1289
+ ? { enableJsonResponse: true }
1290
+ : {
1291
+ sessionIdGenerator: () => crypto.randomUUID(),
1292
+ enableJsonResponse: true,
1293
+ };
1294
+ transport = new WebStandardStreamableHTTPServerTransport(transportOptions);
1156
1295
  await server.connect(transport);
1157
1296
  }
1158
1297
  return transport.handleRequest(req);
@@ -1185,7 +1324,7 @@ async function fetchImpl(name, commands, req, options = {}) {
1185
1324
  if (req.method === 'GET' && isOpenapiRoute(segments)) {
1186
1325
  const spec = generatedOpenapi(name, commands, options);
1187
1326
  const yaml = segments[0] === 'openapi.yml' || segments[0] === 'openapi.yaml';
1188
- return new Response(yaml ? yamlStringify(spec) : JSON.stringify(spec), {
1327
+ return new Response(yaml ? (await Yaml.load()).stringify(spec) : JSON.stringify(spec), {
1189
1328
  status: 200,
1190
1329
  headers: {
1191
1330
  'content-type': yaml ? 'application/yaml' : 'application/json',
@@ -1205,6 +1344,8 @@ async function fetchImpl(name, commands, req, options = {}) {
1205
1344
  segments[1] === 'skills' &&
1206
1345
  segments.length >= 3 &&
1207
1346
  req.method === 'GET') {
1347
+ // Pre-load yaml for the sync call paths below (`Skill.split`, frontmatter parsing).
1348
+ await Yaml.load();
1208
1349
  const groups = new Map();
1209
1350
  const cmds = collectSkillCommands(commands, [], groups, options.rootCommand);
1210
1351
  // GET /.well-known/skills/index.json
@@ -1212,7 +1353,7 @@ async function fetchImpl(name, commands, req, options = {}) {
1212
1353
  const files = Skill.split(name, cmds, 1, groups);
1213
1354
  const skills = files.map((f) => {
1214
1355
  const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/);
1215
- const meta = fmMatch ? yamlParse(fmMatch[1]) : {};
1356
+ const meta = fmMatch ? Yaml.loadSync().parse(fmMatch[1]) : {};
1216
1357
  return {
1217
1358
  name: f.dir || name,
1218
1359
  description: meta.description ?? '',
@@ -1252,7 +1393,7 @@ async function fetchImpl(name, commands, req, options = {}) {
1252
1393
  catch { }
1253
1394
  }
1254
1395
  function jsonResponse(body, status) {
1255
- return new Response(JSON.stringify(body), {
1396
+ return new Response(Json.stringify(body), {
1256
1397
  status,
1257
1398
  headers: { 'content-type': 'application/json' },
1258
1399
  });
@@ -1303,7 +1444,7 @@ async function fetchImpl(name, commands, req, options = {}) {
1303
1444
  /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
1304
1445
  async function executeCommand(path, command, rest, inputOptions, start, options) {
1305
1446
  function jsonResponse(body, status) {
1306
- return new Response(JSON.stringify(body), {
1447
+ return new Response(Json.stringify(body), {
1307
1448
  status,
1308
1449
  headers: { 'content-type': 'application/json' },
1309
1450
  });
@@ -1313,13 +1454,39 @@ async function executeCommand(path, command, rest, inputOptions, start, options)
1313
1454
  ...(options.groupMiddlewares ?? []),
1314
1455
  ...(command.middleware ?? []),
1315
1456
  ];
1457
+ let globals = {};
1458
+ let commandInputOptions = inputOptions;
1459
+ if (options.globals) {
1460
+ const globalKeys = new Set(Object.keys(options.globals.schema.shape));
1461
+ const rawGlobals = {};
1462
+ commandInputOptions = {};
1463
+ for (const [key, value] of Object.entries(inputOptions)) {
1464
+ if (globalKeys.has(key))
1465
+ rawGlobals[key] = value;
1466
+ else
1467
+ commandInputOptions[key] = value;
1468
+ }
1469
+ try {
1470
+ globals = options.globals.schema.parse(rawGlobals);
1471
+ }
1472
+ catch (error) {
1473
+ const issues = error?.issues ?? error?.error?.issues ?? [];
1474
+ const message = issues.map((i) => i.message).join('; ') || 'Validation failed';
1475
+ return jsonResponse({
1476
+ ok: false,
1477
+ error: { code: 'VALIDATION_ERROR', message },
1478
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1479
+ }, 400);
1480
+ }
1481
+ }
1316
1482
  const result = await Command.execute(command, {
1317
1483
  agent: true,
1318
1484
  argv: rest,
1319
1485
  env: options.envSchema,
1320
1486
  format: 'json',
1321
1487
  formatExplicit: true,
1322
- inputOptions,
1488
+ globals,
1489
+ inputOptions: commandInputOptions,
1323
1490
  middlewares: allMiddleware,
1324
1491
  name: options.name ?? path,
1325
1492
  parseMode: 'split',
@@ -1330,30 +1497,68 @@ async function executeCommand(path, command, rest, inputOptions, start, options)
1330
1497
  const duration = `${Math.round(performance.now() - start)}ms`;
1331
1498
  // Streaming path — async generator → NDJSON response
1332
1499
  if ('stream' in result) {
1500
+ const iterator = result.stream;
1501
+ const encoder = new TextEncoder();
1502
+ const meta = (cta) => ({
1503
+ command: path,
1504
+ duration: `${Math.round(performance.now() - start)}ms`,
1505
+ ...(cta ? { cta } : undefined),
1506
+ });
1507
+ const errorRecord = (err) => ({
1508
+ type: 'error',
1509
+ ok: false,
1510
+ error: {
1511
+ code: err.code,
1512
+ message: err.message,
1513
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1514
+ },
1515
+ meta: meta(formatCtaBlock(options.name ?? path, err.cta)),
1516
+ });
1333
1517
  const stream = new ReadableStream({
1334
- async start(controller) {
1335
- const encoder = new TextEncoder();
1518
+ async cancel() {
1519
+ await iterator.return(undefined);
1520
+ },
1521
+ async pull(controller) {
1336
1522
  try {
1337
- for await (const value of result.stream) {
1338
- controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'));
1523
+ const { value, done } = await iterator.next();
1524
+ if (done) {
1525
+ if (isSentinel(value) && value[sentinel] === 'error') {
1526
+ controller.enqueue(encoder.encode(Json.stringify(errorRecord(value)) + '\n'));
1527
+ controller.close();
1528
+ return;
1529
+ }
1530
+ const cta = isSentinel(value) && value[sentinel] === 'ok'
1531
+ ? formatCtaBlock(options.name ?? path, value.cta)
1532
+ : undefined;
1533
+ controller.enqueue(encoder.encode(Json.stringify({
1534
+ type: 'done',
1535
+ ok: true,
1536
+ meta: meta(cta),
1537
+ }) + '\n'));
1538
+ controller.close();
1539
+ return;
1339
1540
  }
1340
- controller.enqueue(encoder.encode(JSON.stringify({
1341
- type: 'done',
1342
- ok: true,
1343
- meta: { command: path },
1344
- }) + '\n'));
1541
+ if (isSentinel(value) && value[sentinel] === 'error') {
1542
+ controller.enqueue(encoder.encode(Json.stringify(errorRecord(value)) + '\n'));
1543
+ await iterator.return(undefined);
1544
+ controller.close();
1545
+ return;
1546
+ }
1547
+ controller.enqueue(encoder.encode(Json.stringify({ type: 'chunk', data: value }) + '\n'));
1345
1548
  }
1346
1549
  catch (error) {
1347
- controller.enqueue(encoder.encode(JSON.stringify({
1550
+ controller.enqueue(encoder.encode(Json.stringify({
1348
1551
  type: 'error',
1349
1552
  ok: false,
1350
1553
  error: {
1351
- code: 'UNKNOWN',
1554
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1352
1555
  message: error instanceof Error ? error.message : String(error),
1556
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
1353
1557
  },
1558
+ meta: meta(),
1354
1559
  }) + '\n'));
1560
+ controller.close();
1355
1561
  }
1356
- controller.close();
1357
1562
  },
1358
1563
  });
1359
1564
  return new Response(stream, {
@@ -1371,6 +1576,7 @@ async function executeCommand(path, command, rest, inputOptions, start, options)
1371
1576
  ...(result.error.retryable !== undefined
1372
1577
  ? { retryable: result.error.retryable }
1373
1578
  : undefined),
1579
+ ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined),
1374
1580
  },
1375
1581
  meta: {
1376
1582
  command: path,
@@ -1765,6 +1971,115 @@ function formatBuiltinSubcommandHelp(cli, builtin, subName) {
1765
1971
  options: sub?.options,
1766
1972
  });
1767
1973
  }
1974
+ async function runMcpDoctor(name, commands, options) {
1975
+ const warnings = [];
1976
+ const errors = [];
1977
+ const input = new PassThrough();
1978
+ const output = new PassThrough();
1979
+ const chunks = [];
1980
+ output.on('data', (chunk) => chunks.push(chunk.toString()));
1981
+ let serveError;
1982
+ const done = Mcp.serve(name, options.version ?? '0.0.0', commands, {
1983
+ input,
1984
+ output,
1985
+ middlewares: options.middlewares,
1986
+ env: options.envSchema,
1987
+ vars: options.vars,
1988
+ version: options.version,
1989
+ ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined),
1990
+ }).catch((error) => {
1991
+ serveError = error;
1992
+ });
1993
+ input.write(`${Json.stringify({
1994
+ jsonrpc: '2.0',
1995
+ id: 1,
1996
+ method: 'initialize',
1997
+ params: {
1998
+ protocolVersion: '2024-11-05',
1999
+ capabilities: {},
2000
+ clientInfo: { name: 'incur-doctor', version: '1.0.0' },
2001
+ },
2002
+ })}\n`);
2003
+ input.write(`${Json.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
2004
+ await new Promise((resolve) => setTimeout(resolve, 20));
2005
+ input.end();
2006
+ await done;
2007
+ if (serveError)
2008
+ return {
2009
+ ok: false,
2010
+ toolCount: 0,
2011
+ tools: [],
2012
+ warnings,
2013
+ errors: [{ code: 'MCP_SERVER_FAILED', message: errorMessage(serveError) }],
2014
+ };
2015
+ let responses;
2016
+ try {
2017
+ responses = parseMcpDoctorResponses(chunks);
2018
+ }
2019
+ catch (error) {
2020
+ return {
2021
+ ok: false,
2022
+ toolCount: 0,
2023
+ tools: [],
2024
+ warnings,
2025
+ errors: [{ code: 'MCP_RESPONSE_PARSE_FAILED', message: errorMessage(error) }],
2026
+ };
2027
+ }
2028
+ const initialize = responses.find((response) => response.id === 1);
2029
+ if (!initialize)
2030
+ errors.push({ code: 'MCP_INITIALIZE_MISSING', message: 'Missing initialize response.' });
2031
+ else if (initialize.error)
2032
+ errors.push({ code: 'MCP_INITIALIZE_FAILED', message: mcpErrorMessage(initialize.error) });
2033
+ const toolsList = responses.find((response) => response.id === 2);
2034
+ let tools = [];
2035
+ if (!toolsList)
2036
+ errors.push({ code: 'MCP_TOOLS_LIST_MISSING', message: 'Missing tools/list response.' });
2037
+ else if (toolsList.error)
2038
+ errors.push({ code: 'MCP_TOOLS_LIST_FAILED', message: mcpErrorMessage(toolsList.error) });
2039
+ else if (!isRecord(toolsList.result) || !Array.isArray(toolsList.result.tools))
2040
+ errors.push({
2041
+ code: 'MCP_TOOLS_LIST_INVALID',
2042
+ message: 'tools/list did not return a tools array.',
2043
+ });
2044
+ else
2045
+ tools = toolsList.result.tools
2046
+ .filter(isRecord)
2047
+ .map((tool) => ({
2048
+ name: typeof tool.name === 'string' ? tool.name : '',
2049
+ ...(typeof tool.description === 'string' ? { description: tool.description } : undefined),
2050
+ }))
2051
+ .filter((tool) => tool.name);
2052
+ if (errors.length === 0 && tools.length === 0)
2053
+ warnings.push('No MCP tools exposed.');
2054
+ return {
2055
+ ok: errors.length === 0,
2056
+ toolCount: tools.length,
2057
+ tools,
2058
+ warnings,
2059
+ errors,
2060
+ };
2061
+ }
2062
+ function parseMcpDoctorResponses(chunks) {
2063
+ const responses = [];
2064
+ for (const line of chunks.join('').split(/\r?\n/)) {
2065
+ const trimmed = line.trim();
2066
+ if (!trimmed)
2067
+ continue;
2068
+ const parsed = JSON.parse(trimmed);
2069
+ if (!isRecord(parsed))
2070
+ throw new Error('Expected JSON-RPC response object.');
2071
+ responses.push(parsed);
2072
+ }
2073
+ return responses;
2074
+ }
2075
+ function errorMessage(error) {
2076
+ return error instanceof Error ? error.message : String(error);
2077
+ }
2078
+ function mcpErrorMessage(error) {
2079
+ if (isRecord(error) && typeof error.message === 'string')
2080
+ return error.message;
2081
+ return Json.stringify(error);
2082
+ }
1768
2083
  /** @internal Formats help text for a fetch gateway command. */
1769
2084
  function formatFetchHelp(name, description) {
1770
2085
  const lines = [];
@@ -1819,6 +2134,31 @@ function resolveAlias(commands, entry) {
1819
2134
  return commands.get(entry.target);
1820
2135
  return entry;
1821
2136
  }
2137
+ /** @internal Validates command options against CLI-level global options. */
2138
+ function assertNoGlobalOptionConflicts(path, entry, globals) {
2139
+ if (!globals || isFetchGateway(entry) || isAlias(entry))
2140
+ return;
2141
+ if (isGroup(entry)) {
2142
+ for (const [name, child] of entry.commands)
2143
+ assertNoGlobalOptionConflicts(`${path} ${name}`, child, globals);
2144
+ return;
2145
+ }
2146
+ if (entry.options) {
2147
+ const globalKeys = Object.keys(globals.schema.shape);
2148
+ const optionKeys = Object.keys(entry.options.shape);
2149
+ for (const key of optionKeys) {
2150
+ if (globalKeys.includes(key))
2151
+ throw new Error(`Command '${path}' option '${key}' conflicts with a global option. Choose a different name.`);
2152
+ }
2153
+ }
2154
+ if (globals.alias && entry.alias) {
2155
+ const globalAliasValues = new Set(Object.values(globals.alias));
2156
+ for (const [name, short] of Object.entries(entry.alias)) {
2157
+ if (short && globalAliasValues.has(short))
2158
+ throw new Error(`Command '${path}' alias '-${short}' for '${name}' conflicts with a global alias. Choose a different alias.`);
2159
+ }
2160
+ }
2161
+ }
1822
2162
  /** @internal Maps CLI instances to their command maps. */
1823
2163
  export const toCommands = new WeakMap();
1824
2164
  /** @internal Maps CLI instances to their middleware arrays. */
@@ -1831,6 +2171,8 @@ export const toRootOptions = new WeakMap();
1831
2171
  export const toConfigEnabled = new WeakMap();
1832
2172
  /** @internal Maps CLI instances to their output policy. */
1833
2173
  const toOutputPolicy = new WeakMap();
2174
+ /** @internal Maps CLI instances to their globals schema and alias map. */
2175
+ const toGlobals = new WeakMap();
1834
2176
  /** @internal Maps root CLI instances to their command aliases. */
1835
2177
  const toRootAliases = new WeakMap();
1836
2178
  /** @internal Sentinel symbol for `ok()` and `error()` return values. */
@@ -1901,7 +2243,7 @@ async function handleStreaming(generator, ctx) {
1901
2243
  }
1902
2244
  }
1903
2245
  if (useJsonl)
1904
- ctx.writeln(JSON.stringify({ type: 'chunk', data: value }));
2246
+ ctx.writeln(Json.stringify({ type: 'chunk', data: value }));
1905
2247
  else if (ctx.renderOutput)
1906
2248
  ctx.writeln(ctx.truncate(Formatter.format(value, ctx.format)).text);
1907
2249
  }
@@ -1947,6 +2289,7 @@ async function handleStreaming(generator, ctx) {
1947
2289
  error: {
1948
2290
  code: error instanceof IncurError ? error.code : 'UNKNOWN',
1949
2291
  message: error instanceof Error ? error.message : String(error),
2292
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
1950
2293
  },
1951
2294
  }));
1952
2295
  else
@@ -2025,6 +2368,7 @@ async function handleStreaming(generator, ctx) {
2025
2368
  error: {
2026
2369
  code: error instanceof IncurError ? error.code : 'UNKNOWN',
2027
2370
  message: error instanceof Error ? error.message : String(error),
2371
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
2028
2372
  },
2029
2373
  meta: {
2030
2374
  command: ctx.path,
@@ -2035,35 +2379,12 @@ async function handleStreaming(generator, ctx) {
2035
2379
  }
2036
2380
  }
2037
2381
  }
2038
- /** @internal Formats a CTA block into the output envelope shape. */
2039
- function formatCtaBlock(name, block) {
2040
- if (!block || block.commands.length === 0)
2041
- return undefined;
2042
- return {
2043
- description: block.description ??
2044
- (block.commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'),
2045
- commands: block.commands.map((c) => formatCta(name, c)),
2046
- };
2047
- }
2048
- /** @internal Formats a CTA by prefixing the CLI name. Handles string and object forms. */
2049
- function formatCta(name, cta) {
2050
- if (typeof cta === 'string')
2051
- return { command: `${name} ${cta}` };
2052
- const prefix = cta.command === name || cta.command.startsWith(`${name} `) ? '' : `${name} `;
2053
- let cmd = `${prefix}${cta.command}`;
2054
- if (cta.args)
2055
- for (const [key, value] of Object.entries(cta.args))
2056
- cmd += value === true ? ` <${key}>` : ` ${value}`;
2057
- if (cta.options)
2058
- for (const [key, value] of Object.entries(cta.options))
2059
- cmd += value === true ? ` --${key} <${key}>` : ` --${key} ${value}`;
2060
- return { command: cmd, ...(cta.description ? { description: cta.description } : undefined) };
2061
- }
2062
2382
  /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */
2063
- function buildIndexManifest(commands, prefix = []) {
2383
+ function buildIndexManifest(commands, prefix = [], globalsSchema) {
2064
2384
  return {
2065
2385
  version: 'incur.v1',
2066
2386
  commands: collectIndexCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)),
2387
+ ...(globalsSchema ? { globals: Schema.toJsonSchema(globalsSchema) } : undefined),
2067
2388
  };
2068
2389
  }
2069
2390
  /** @internal Recursively collects leaf commands with name + description only. */
@@ -2090,10 +2411,11 @@ function collectIndexCommands(commands, prefix) {
2090
2411
  return result;
2091
2412
  }
2092
2413
  /** @internal Builds the `--llms` manifest from the command tree. */
2093
- function buildManifest(commands, prefix = []) {
2414
+ function buildManifest(commands, prefix = [], globalsSchema) {
2094
2415
  return {
2095
2416
  version: 'incur.v1',
2096
2417
  commands: collectCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)),
2418
+ ...(globalsSchema ? { globals: Schema.toJsonSchema(globalsSchema) } : undefined),
2097
2419
  };
2098
2420
  }
2099
2421
  /** @internal Recursively collects leaf commands with their full paths. */
@@ -2143,7 +2465,7 @@ function collectCommands(commands, prefix) {
2143
2465
  return result;
2144
2466
  }
2145
2467
  /** @internal Recursively collects leaf commands as `Skill.CommandInfo` for `--llms --format md`. */
2146
- function collectSkillCommands(commands, prefix, groups, rootCommand) {
2468
+ export function collectSkillCommands(commands, prefix, groups, rootCommand) {
2147
2469
  const result = [];
2148
2470
  if (rootCommand) {
2149
2471
  const cmd = {};
@@ -2155,6 +2477,8 @@ function collectSkillCommands(commands, prefix, groups, rootCommand) {
2155
2477
  cmd.env = rootCommand.env;
2156
2478
  if (rootCommand.hint)
2157
2479
  cmd.hint = rootCommand.hint;
2480
+ if (isDestructive(rootCommand))
2481
+ cmd.hint = appendDestructiveHint(cmd.hint);
2158
2482
  if (rootCommand.options)
2159
2483
  cmd.options = rootCommand.options;
2160
2484
  if (rootCommand.output)
@@ -2190,6 +2514,8 @@ function collectSkillCommands(commands, prefix, groups, rootCommand) {
2190
2514
  cmd.env = entry.env;
2191
2515
  if (entry.hint)
2192
2516
  cmd.hint = entry.hint;
2517
+ if (isDestructive(entry))
2518
+ cmd.hint = appendDestructiveHint(cmd.hint);
2193
2519
  if (entry.options)
2194
2520
  cmd.options = entry.options;
2195
2521
  if (entry.output)
@@ -2207,6 +2533,16 @@ function collectSkillCommands(commands, prefix, groups, rootCommand) {
2207
2533
  }
2208
2534
  return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
2209
2535
  }
2536
+ function isDestructive(command) {
2537
+ return command.destructive === true || command.mcp?.annotations?.destructiveHint === true;
2538
+ }
2539
+ function appendDestructiveHint(hint) {
2540
+ if (!hint)
2541
+ return destructiveCommandHint;
2542
+ if (hint.includes(destructiveCommandHint))
2543
+ return hint;
2544
+ return `${hint} ${destructiveCommandHint}`;
2545
+ }
2210
2546
  /** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */
2211
2547
  export function formatExamples(examples) {
2212
2548
  if (!examples || examples.length === 0)
@@ -2225,6 +2561,16 @@ export function formatExamples(examples) {
2225
2561
  return result;
2226
2562
  });
2227
2563
  }
2564
+ /** @internal Parses YAML frontmatter from generated skill Markdown. */
2565
+ export function parseSkillFrontmatter(content) {
2566
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2567
+ if (!match)
2568
+ return {};
2569
+ const meta = Yaml.loadSync().parse(match[1]);
2570
+ if (!meta || typeof meta !== 'object')
2571
+ return {};
2572
+ return meta;
2573
+ }
2228
2574
  /** @internal Builds separate args, env, and options JSON Schemas. */
2229
2575
  function buildInputSchema(args, env, options) {
2230
2576
  if (!args && !env && !options)