incur 0.3.4 → 0.3.6

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 (68) hide show
  1. package/README.md +62 -1
  2. package/dist/Cli.d.ts +17 -7
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +435 -365
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Completions.d.ts +1 -2
  7. package/dist/Completions.d.ts.map +1 -1
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Filter.js +0 -18
  10. package/dist/Filter.js.map +1 -1
  11. package/dist/Help.d.ts +6 -0
  12. package/dist/Help.d.ts.map +1 -1
  13. package/dist/Help.js +35 -22
  14. package/dist/Help.js.map +1 -1
  15. package/dist/Mcp.d.ts +25 -5
  16. package/dist/Mcp.d.ts.map +1 -1
  17. package/dist/Mcp.js +61 -69
  18. package/dist/Mcp.js.map +1 -1
  19. package/dist/Parser.d.ts +2 -0
  20. package/dist/Parser.d.ts.map +1 -1
  21. package/dist/Parser.js +69 -37
  22. package/dist/Parser.js.map +1 -1
  23. package/dist/Skill.d.ts.map +1 -1
  24. package/dist/Skill.js +5 -1
  25. package/dist/Skill.js.map +1 -1
  26. package/dist/SyncSkills.d.ts.map +1 -1
  27. package/dist/SyncSkills.js +10 -1
  28. package/dist/SyncSkills.js.map +1 -1
  29. package/dist/bin.d.ts +1 -0
  30. package/dist/bin.d.ts.map +1 -1
  31. package/dist/bin.js +17 -2
  32. package/dist/bin.js.map +1 -1
  33. package/dist/internal/command.d.ts +118 -0
  34. package/dist/internal/command.d.ts.map +1 -0
  35. package/dist/internal/command.js +276 -0
  36. package/dist/internal/command.js.map +1 -0
  37. package/dist/internal/configSchema.d.ts +8 -0
  38. package/dist/internal/configSchema.d.ts.map +1 -0
  39. package/dist/internal/configSchema.js +57 -0
  40. package/dist/internal/configSchema.js.map +1 -0
  41. package/dist/internal/helpers.d.ts +5 -0
  42. package/dist/internal/helpers.d.ts.map +1 -0
  43. package/dist/internal/helpers.js +9 -0
  44. package/dist/internal/helpers.js.map +1 -0
  45. package/examples/npm/.npmrc.json +21 -0
  46. package/examples/npm/config.schema.json +137 -0
  47. package/package.json +1 -1
  48. package/src/Cli.test-d.ts +39 -0
  49. package/src/Cli.test.ts +704 -6
  50. package/src/Cli.ts +551 -448
  51. package/src/Completions.test.ts +35 -9
  52. package/src/Completions.ts +1 -2
  53. package/src/Filter.ts +0 -17
  54. package/src/Help.test.ts +77 -0
  55. package/src/Help.ts +39 -21
  56. package/src/Mcp.test.ts +143 -0
  57. package/src/Mcp.ts +92 -84
  58. package/src/Parser.test-d.ts +22 -0
  59. package/src/Parser.test.ts +89 -0
  60. package/src/Parser.ts +86 -35
  61. package/src/Skill.ts +5 -1
  62. package/src/SyncSkills.ts +11 -1
  63. package/src/bin.ts +21 -2
  64. package/src/e2e.test.ts +30 -17
  65. package/src/internal/command.ts +428 -0
  66. package/src/internal/configSchema.test.ts +193 -0
  67. package/src/internal/configSchema.ts +66 -0
  68. package/src/internal/helpers.ts +9 -0
package/dist/Cli.js CHANGED
@@ -1,10 +1,16 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
1
4
  import { estimateTokenCount, sliceByTokens } from 'tokenx';
2
5
  import * as Completions from './Completions.js';
3
- import { IncurError, ValidationError } from './Errors.js';
6
+ import { IncurError, ParseError, ValidationError } from './Errors.js';
4
7
  import * as Fetch from './Fetch.js';
5
8
  import * as Filter from './Filter.js';
6
9
  import * as Formatter from './Formatter.js';
7
10
  import * as Help from './Help.js';
11
+ import { builtinCommands, shells } from './internal/command.js';
12
+ import * as Command from './internal/command.js';
13
+ import { isRecord } from './internal/helpers.js';
8
14
  import { detectRunner } from './internal/pm.js';
9
15
  import * as Mcp from './Mcp.js';
10
16
  import * as Openapi from './Openapi.js';
@@ -76,10 +82,13 @@ export function create(nameOrDefinition, definition) {
76
82
  if (pending.length > 0)
77
83
  await Promise.all(pending);
78
84
  return fetchImpl(name, commands, req, {
85
+ envSchema: def.env,
79
86
  mcpHandler,
80
87
  middlewares,
88
+ name,
81
89
  rootCommand: rootDef,
82
90
  vars: def.vars,
91
+ version: def.version,
83
92
  });
84
93
  },
85
94
  async serve(argv = process.argv.slice(2), serveOptions = {}) {
@@ -88,6 +97,7 @@ export function create(nameOrDefinition, definition) {
88
97
  return serveImpl(name, commands, argv, {
89
98
  ...serveOptions,
90
99
  aliases: def.aliases,
100
+ config: def.config,
91
101
  description: def.description,
92
102
  envSchema: def.env,
93
103
  format: def.format,
@@ -108,6 +118,10 @@ export function create(nameOrDefinition, definition) {
108
118
  };
109
119
  if (rootDef)
110
120
  toRootDefinition.set(cli, rootDef);
121
+ if (def.options)
122
+ toRootOptions.set(cli, def.options);
123
+ if (def.config !== undefined)
124
+ toConfigEnabled.set(cli, true);
111
125
  if (def.outputPolicy)
112
126
  toOutputPolicy.set(cli, def.outputPolicy);
113
127
  toMiddlewares.set(cli, middlewares);
@@ -119,10 +133,34 @@ export function create(nameOrDefinition, definition) {
119
133
  async function serveImpl(name, commands, argv, options = {}) {
120
134
  const stdout = options.stdout ?? ((s) => process.stdout.write(s));
121
135
  const exit = options.exit ?? ((code) => process.exit(code));
122
- const { verbose, format: formatFlag, formatExplicit, filterOutput, tokenLimit, tokenOffset, tokenCount, llms, llmsFull, mcp: mcpFlag, help, version, schema, rest: filtered, } = extractBuiltinFlags(argv);
136
+ const human = process.stdout.isTTY === true;
137
+ const configEnabled = options.config !== undefined;
138
+ const configFlag = options.config?.flag;
139
+ function writeln(s) {
140
+ stdout(s.endsWith('\n') ? s : `${s}\n`);
141
+ }
142
+ let builtinFlags;
143
+ try {
144
+ builtinFlags = extractBuiltinFlags(argv, { configFlag });
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ if (human)
149
+ writeln(formatHumanError({ code: 'UNKNOWN', message }));
150
+ else
151
+ writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'));
152
+ exit(1);
153
+ return;
154
+ }
155
+ const { verbose, format: formatFlag, formatExplicit, filterOutput, tokenLimit, tokenOffset, tokenCount, llms, llmsFull, mcp: mcpFlag, help, version, schema, configPath, configDisabled, rest: filtered, } = builtinFlags;
123
156
  // --mcp: start as MCP stdio server
124
157
  if (mcpFlag) {
125
- await Mcp.serve(name, options.version ?? '0.0.0', commands);
158
+ await Mcp.serve(name, options.version ?? '0.0.0', commands, {
159
+ middlewares: options.middlewares,
160
+ env: options.envSchema,
161
+ vars: options.vars,
162
+ version: options.version,
163
+ });
126
164
  return;
127
165
  }
128
166
  // COMPLETE: dynamic shell completions (called by shell hook at tab-press)
@@ -139,17 +177,33 @@ async function serveImpl(name, commands, argv, options = {}) {
139
177
  else {
140
178
  const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1);
141
179
  const candidates = Completions.complete(commands, options.rootCommand, words, index);
180
+ // Add built-in commands (completions, mcp, skills) to completions
181
+ const current = words[index] ?? '';
182
+ const nonFlags = words.slice(0, index).filter((w) => !w.startsWith('-'));
183
+ if (nonFlags.length <= 1) {
184
+ for (const b of builtinCommands) {
185
+ if (b.name.startsWith(current) && !candidates.some((c) => c.value === b.name))
186
+ candidates.push({
187
+ value: b.name,
188
+ description: b.description,
189
+ ...(b.subcommands ? { noSpace: true } : undefined),
190
+ });
191
+ }
192
+ }
193
+ else if (nonFlags.length === 2) {
194
+ const parent = nonFlags[nonFlags.length - 1];
195
+ const builtin = builtinCommands.find((b) => b.name === parent && b.subcommands);
196
+ if (builtin?.subcommands)
197
+ for (const sub of builtin.subcommands)
198
+ if (sub.name.startsWith(current))
199
+ candidates.push({ value: sub.name, description: sub.description });
200
+ }
142
201
  const out = Completions.format(completeShell, candidates);
143
202
  if (out)
144
203
  stdout(out);
145
204
  }
146
205
  return;
147
206
  }
148
- // Human mode: stdout is a TTY.
149
- const human = process.stdout.isTTY === true;
150
- function writeln(s) {
151
- stdout(s.endsWith('\n') ? s : `${s}\n`);
152
- }
153
207
  // Skills staleness check (skip for built-in commands)
154
208
  if (!llms && !llmsFull && !schema && !help && !version) {
155
209
  const isSkillsAdd = filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills');
@@ -219,41 +273,23 @@ async function serveImpl(name, commands, argv, options = {}) {
219
273
  // not a completions invocation
220
274
  return -1;
221
275
  })();
276
+ // TODO: refactor built-in command handlers (completions, skills, mcp) into a generic dispatch loop on `builtinCommands`
222
277
  if (completionsIdx !== -1 && filtered[completionsIdx] === 'completions') {
223
- if (help) {
224
- writeln([
225
- `${name} completions Generate shell completion script`,
226
- '',
227
- `Usage: ${name} completions <shell>`,
228
- '',
229
- 'Shells:',
230
- ' bash',
231
- ' fish',
232
- ' nushell',
233
- ' zsh',
234
- '',
235
- 'Setup:',
236
- ...(() => {
237
- const rows = [
238
- ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
239
- ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
240
- ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
241
- ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
242
- ];
243
- const shellW = Math.max(...rows.map((r) => r[0].length));
244
- const cmdW = Math.max(...rows.map((r) => r[1].length));
245
- return rows.map(([shell, cmd, comment]) => ` ${shell.padEnd(shellW)} ${cmd.padEnd(cmdW)} ${comment}`);
246
- })(),
247
- ].join('\n'));
278
+ const shell = filtered[completionsIdx + 1];
279
+ if (help || !shell) {
280
+ const b = builtinCommands.find((c) => c.name === 'completions');
281
+ writeln(Help.formatCommand(`${name} completions`, {
282
+ args: b.args,
283
+ description: b.description,
284
+ hideGlobalOptions: true,
285
+ hint: b.hint?.(name),
286
+ }));
248
287
  return;
249
288
  }
250
- const shell = filtered[completionsIdx + 1];
251
- if (!shell || !['bash', 'fish', 'nushell', 'zsh'].includes(shell)) {
289
+ if (!shells.includes(shell)) {
252
290
  writeln(formatHumanError({
253
291
  code: 'INVALID_SHELL',
254
- message: shell
255
- ? `Unknown shell '${shell}'. Supported: bash, fish, nushell, zsh`
256
- : `Missing shell argument. Usage: ${name} completions <bash|fish|nushell|zsh>`,
292
+ message: `Unknown shell '${shell}'. Supported: ${shells.join(', ')}`,
257
293
  }));
258
294
  exit(1);
259
295
  return;
@@ -264,17 +300,15 @@ async function serveImpl(name, commands, argv, options = {}) {
264
300
  }
265
301
  // skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
266
302
  const skillsIdx = filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1;
267
- if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills' && filtered[skillsIdx + 1] === 'add') {
303
+ if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
304
+ if (filtered[skillsIdx + 1] !== 'add') {
305
+ const b = builtinCommands.find((c) => c.name === 'skills');
306
+ writeln(formatBuiltinHelp(name, b));
307
+ return;
308
+ }
268
309
  if (help) {
269
- writeln([
270
- `${name} skills add — Sync skill files to agents`,
271
- '',
272
- `Usage: ${name} skills add [options]`,
273
- '',
274
- 'Options:',
275
- ' --depth <number> Grouping depth for skill files (default: 1)',
276
- ' --no-global Install to project instead of globally',
277
- ].join('\n'));
310
+ const b = builtinCommands.find((c) => c.name === 'skills');
311
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'));
278
312
  return;
279
313
  }
280
314
  const rest = filtered.slice(skillsIdx + 2);
@@ -333,18 +367,15 @@ async function serveImpl(name, commands, argv, options = {}) {
333
367
  }
334
368
  // mcp add: register CLI as MCP server via `npx add-mcp`
335
369
  const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1;
336
- if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp' && filtered[mcpIdx + 1] === 'add') {
370
+ if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
371
+ if (filtered[mcpIdx + 1] !== 'add') {
372
+ const b = builtinCommands.find((c) => c.name === 'mcp');
373
+ writeln(formatBuiltinHelp(name, b));
374
+ return;
375
+ }
337
376
  if (help) {
338
- writeln([
339
- `${name} mcp add — Register as MCP server for your agent`,
340
- '',
341
- `Usage: ${name} mcp add [options]`,
342
- '',
343
- 'Options:',
344
- ' -c, --command <cmd> Override the command agents will run (e.g. "pnpm my-cli --mcp")',
345
- ' --no-global Install to project instead of globally',
346
- ' --agent <agent> Target a specific agent (e.g. claude-code, cursor)',
347
- ].join('\n'));
377
+ const b = builtinCommands.find((c) => c.name === 'mcp');
378
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'));
348
379
  return;
349
380
  }
350
381
  const rest = filtered.slice(mcpIdx + 2);
@@ -404,6 +435,7 @@ async function serveImpl(name, commands, argv, options = {}) {
404
435
  writeln(Help.formatCommand(name, {
405
436
  alias: cmd.alias,
406
437
  aliases: options.aliases,
438
+ configFlag,
407
439
  description: cmd.description ?? options.description,
408
440
  version: options.version,
409
441
  args: cmd.args,
@@ -424,6 +456,7 @@ async function serveImpl(name, commands, argv, options = {}) {
424
456
  else {
425
457
  writeln(Help.formatRoot(name, {
426
458
  aliases: options.aliases,
459
+ configFlag,
427
460
  description: options.description,
428
461
  version: options.version,
429
462
  commands: collectHelpCommands(commands),
@@ -466,6 +499,7 @@ async function serveImpl(name, commands, argv, options = {}) {
466
499
  writeln(Help.formatCommand(name, {
467
500
  alias: cmd.alias,
468
501
  aliases: options.aliases,
502
+ configFlag,
469
503
  description: cmd.description ?? options.description,
470
504
  version: options.version,
471
505
  args: cmd.args,
@@ -482,6 +516,7 @@ async function serveImpl(name, commands, argv, options = {}) {
482
516
  else {
483
517
  writeln(Help.formatRoot(helpName, {
484
518
  aliases: isRoot ? options.aliases : undefined,
519
+ configFlag,
485
520
  description: helpDesc,
486
521
  version: isRoot ? options.version : undefined,
487
522
  commands: collectHelpCommands(helpCmds),
@@ -499,6 +534,7 @@ async function serveImpl(name, commands, argv, options = {}) {
499
534
  writeln(Help.formatCommand(commandName, {
500
535
  alias: cmd.alias,
501
536
  aliases: isRootCmd ? options.aliases : undefined,
537
+ configFlag,
502
538
  description: cmd.description,
503
539
  version: isRootCmd ? options.version : undefined,
504
540
  args: cmd.args,
@@ -518,6 +554,7 @@ async function serveImpl(name, commands, argv, options = {}) {
518
554
  if (schema) {
519
555
  if ('help' in resolved) {
520
556
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
557
+ configFlag,
521
558
  description: resolved.description,
522
559
  commands: collectHelpCommands(resolved.commands),
523
560
  }));
@@ -550,6 +587,7 @@ async function serveImpl(name, commands, argv, options = {}) {
550
587
  }
551
588
  if ('help' in resolved) {
552
589
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
590
+ configFlag,
553
591
  description: resolved.description,
554
592
  commands: collectHelpCommands(resolved.commands),
555
593
  }));
@@ -807,178 +845,110 @@ async function serveImpl(name, commands, argv, options = {}) {
807
845
  : []),
808
846
  ...(command.middleware ?? []),
809
847
  ];
810
- // Initialize vars from schema defaults
811
- const varsMap = options.vars ? options.vars.parse({}) : {};
812
- const envSource = options.env ?? process.env;
813
- const runCommand = async () => {
814
- const { args, options: parsedOptions } = Parser.parse(rest, {
815
- alias: command.alias,
816
- args: command.args,
817
- options: command.options,
818
- });
819
- if (human)
820
- emitDeprecationWarnings(rest, command.options, command.alias);
821
- const env = command.env ? Parser.parseEnv(command.env, envSource) : {};
822
- const okFn = (data, meta = {}) => {
823
- return { [sentinel]: 'ok', data, cta: meta.cta };
824
- };
825
- const errorFn = (opts) => {
826
- return { [sentinel]: 'error', ...opts };
827
- };
828
- const result = command.run({
829
- agent: !human,
830
- args,
831
- env,
832
- error: errorFn,
833
- format,
834
- formatExplicit,
835
- name,
836
- ok: okFn,
837
- options: parsedOptions,
838
- var: varsMap,
839
- version: options.version,
840
- });
841
- // Streaming path — async generator
842
- if (isAsyncGenerator(result)) {
843
- await handleStreaming(result, {
844
- name,
845
- path,
846
- start,
847
- format,
848
- formatExplicit,
849
- human,
850
- renderOutput,
851
- verbose,
852
- truncate,
853
- write,
854
- writeln,
855
- exit,
848
+ if (human)
849
+ emitDeprecationWarnings(rest, command.options, command.alias);
850
+ let defaults;
851
+ if (configEnabled) {
852
+ try {
853
+ defaults = await loadCommandOptionDefaults(name, path, {
854
+ configDisabled,
855
+ configPath,
856
+ files: options.config?.files,
857
+ loader: options.config?.loader,
856
858
  });
857
- return;
858
- }
859
- const awaited = await result;
860
- if (isSentinel(awaited)) {
861
- const cta = formatCtaBlock(name, awaited.cta);
862
- if (awaited[sentinel] === 'ok') {
863
- write({
864
- ok: true,
865
- data: awaited.data,
866
- meta: {
867
- command: path,
868
- duration: `${Math.round(performance.now() - start)}ms`,
869
- ...(cta ? { cta } : undefined),
870
- },
871
- });
872
- }
873
- else {
874
- const err = awaited;
875
- write({
876
- ok: false,
877
- error: {
878
- code: err.code,
879
- message: err.message,
880
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
881
- },
882
- meta: {
883
- command: path,
884
- duration: `${Math.round(performance.now() - start)}ms`,
885
- ...(cta ? { cta } : undefined),
886
- },
887
- });
888
- exit(err.exitCode ?? 1);
889
- }
890
859
  }
891
- else {
860
+ catch (error) {
892
861
  write({
893
- ok: true,
894
- data: awaited,
895
- meta: {
896
- command: path,
897
- duration: `${Math.round(performance.now() - start)}ms`,
862
+ ok: false,
863
+ error: {
864
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
865
+ message: error instanceof Error ? error.message : String(error),
898
866
  },
867
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
899
868
  });
869
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
870
+ return;
900
871
  }
901
- };
902
- try {
903
- const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, envSource) : {};
904
- if (allMiddleware.length > 0) {
905
- const errorFn = (opts) => {
906
- return { [sentinel]: 'error', ...opts };
907
- };
908
- const mwCtx = {
909
- agent: !human,
872
+ }
873
+ const result = await Command.execute(command, {
874
+ agent: !human,
875
+ argv: rest,
876
+ defaults,
877
+ env: options.envSchema,
878
+ envSource: options.env,
879
+ format,
880
+ formatExplicit,
881
+ inputOptions: {},
882
+ middlewares: allMiddleware,
883
+ name,
884
+ path,
885
+ vars: options.vars,
886
+ version: options.version,
887
+ });
888
+ const duration = `${Math.round(performance.now() - start)}ms`;
889
+ // Streaming path — async generator
890
+ if ('stream' in result) {
891
+ await handleStreaming(result.stream, {
892
+ name,
893
+ path,
894
+ start,
895
+ format,
896
+ formatExplicit,
897
+ human,
898
+ renderOutput,
899
+ verbose,
900
+ truncate,
901
+ write,
902
+ writeln,
903
+ exit,
904
+ });
905
+ return;
906
+ }
907
+ if (result.ok) {
908
+ const cta = formatCtaBlock(name, result.cta);
909
+ write({
910
+ ok: true,
911
+ data: result.data,
912
+ meta: {
910
913
  command: path,
911
- env: cliEnv,
912
- error: errorFn,
913
- format,
914
- formatExplicit,
915
- name,
916
- set(key, value) {
917
- varsMap[key] = value;
918
- },
919
- var: varsMap,
920
- version: options.version,
921
- };
922
- const handleMwSentinel = (result) => {
923
- if (!isSentinel(result) || result[sentinel] !== 'error')
924
- return;
925
- const err = result;
926
- const cta = formatCtaBlock(name, err.cta);
927
- write({
928
- ok: false,
929
- error: {
930
- code: err.code,
931
- message: err.message,
932
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
933
- },
934
- meta: {
935
- command: path,
936
- duration: `${Math.round(performance.now() - start)}ms`,
937
- ...(cta ? { cta } : undefined),
938
- },
939
- });
940
- exit(err.exitCode ?? 1);
941
- };
942
- const composed = allMiddleware.reduceRight((next, mw) => async () => {
943
- handleMwSentinel(await mw(mwCtx, next));
944
- }, runCommand);
945
- await composed();
946
- }
947
- else {
948
- await runCommand();
949
- }
914
+ duration,
915
+ ...(cta ? { cta } : undefined),
916
+ },
917
+ });
950
918
  }
951
- catch (error) {
952
- const errorOutput = {
919
+ else {
920
+ const cta = formatCtaBlock(name, result.cta);
921
+ if (human && !formatExplicit && result.error.fieldErrors) {
922
+ writeln(formatHumanValidationError(name, path, command, new ValidationError({
923
+ message: result.error.message,
924
+ fieldErrors: result.error.fieldErrors,
925
+ }), options.env, configFlag));
926
+ exit(1);
927
+ return;
928
+ }
929
+ write({
953
930
  ok: false,
954
931
  error: {
955
- code: error instanceof IncurError
956
- ? error.code
957
- : error instanceof ValidationError
958
- ? 'VALIDATION_ERROR'
959
- : 'UNKNOWN',
960
- message: error instanceof Error ? error.message : String(error),
961
- ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
962
- ...(error instanceof ValidationError ? { fieldErrors: error.fieldErrors } : undefined),
932
+ code: result.error.code,
933
+ message: result.error.message,
934
+ ...(result.error.retryable !== undefined
935
+ ? { retryable: result.error.retryable }
936
+ : undefined),
937
+ ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined),
963
938
  },
964
939
  meta: {
965
940
  command: path,
966
- duration: `${Math.round(performance.now() - start)}ms`,
941
+ duration,
942
+ ...(cta ? { cta } : undefined),
967
943
  },
968
- };
969
- if (human && !formatExplicit && error instanceof ValidationError) {
970
- writeln(formatHumanValidationError(name, path, command, error, options.env));
971
- exit(1);
972
- return;
973
- }
974
- write(errorOutput);
975
- exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
944
+ });
945
+ exit(result.exitCode ?? 1);
976
946
  }
977
947
  }
978
948
  /** @internal Creates a lazy MCP HTTP handler scoped to a CLI instance. */
979
949
  function createMcpHttpHandler(name, version) {
980
950
  let transport;
981
- return async (req, commands) => {
951
+ return async (req, commands, mcpOptions) => {
982
952
  if (!transport) {
983
953
  const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
984
954
  const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js');
@@ -994,7 +964,13 @@ function createMcpHttpHandler(name, version) {
994
964
  ...(hasInput ? { inputSchema: mergedShape } : undefined),
995
965
  }, async (...callArgs) => {
996
966
  const params = hasInput ? callArgs[0] : {};
997
- return Mcp.callTool(tool, params);
967
+ return Mcp.callTool(tool, params, {
968
+ name,
969
+ version,
970
+ middlewares: mcpOptions?.middlewares,
971
+ env: mcpOptions?.env,
972
+ vars: mcpOptions?.vars,
973
+ });
998
974
  });
999
975
  }
1000
976
  transport = new WebStandardStreamableHTTPServerTransport({
@@ -1013,7 +989,11 @@ async function fetchImpl(name, commands, req, options = {}) {
1013
989
  const segments = url.pathname.split('/').filter(Boolean);
1014
990
  // MCP over HTTP: route /mcp to the MCP transport
1015
991
  if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler)
1016
- return options.mcpHandler(req, commands);
992
+ return options.mcpHandler(req, commands, {
993
+ middlewares: options.middlewares,
994
+ env: options.envSchema,
995
+ vars: options.vars,
996
+ });
1017
997
  // .well-known/skills/ — Agent Skills Discovery (RFC)
1018
998
  if (segments[0] === '.well-known' &&
1019
999
  segments[1] === 'skills' &&
@@ -1103,7 +1083,11 @@ async function fetchImpl(name, commands, req, options = {}) {
1103
1083
  if ('fetchGateway' in resolved)
1104
1084
  return resolved.fetchGateway.fetch(req);
1105
1085
  const { command, path, rest } = resolved;
1106
- return executeCommand(path, command, rest, inputOptions, start, options);
1086
+ const groupMiddlewares = 'middlewares' in resolved ? resolved.middlewares : [];
1087
+ return executeCommand(path, command, rest, inputOptions, start, {
1088
+ ...options,
1089
+ groupMiddlewares,
1090
+ });
1107
1091
  }
1108
1092
  /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
1109
1093
  async function executeCommand(path, command, rest, inputOptions, start, options) {
@@ -1113,156 +1097,90 @@ async function executeCommand(path, command, rest, inputOptions, start, options)
1113
1097
  headers: { 'content-type': 'application/json' },
1114
1098
  });
1115
1099
  }
1116
- const sentinel_ = Symbol.for('incur.sentinel');
1117
- const varsMap = options.vars ? options.vars.parse({}) : {};
1118
- let response;
1119
- const runCommand = async () => {
1120
- const { args } = Parser.parse(rest, { args: command.args });
1121
- const parsedOptions = command.options ? command.options.parse(inputOptions) : {};
1122
- const okFn = (data) => ({ [sentinel_]: 'ok', data });
1123
- const errorFn = (opts) => ({ [sentinel_]: 'error', ...opts });
1124
- const result = command.run({
1125
- agent: true,
1126
- args,
1127
- env: {},
1128
- error: errorFn,
1129
- format: 'json',
1130
- formatExplicit: true,
1131
- name: path,
1132
- ok: okFn,
1133
- options: parsedOptions,
1134
- var: varsMap,
1135
- version: undefined,
1136
- });
1137
- // Streaming path async generator → NDJSON response
1138
- if (isAsyncGenerator(result)) {
1139
- const stream = new ReadableStream({
1140
- async start(controller) {
1141
- const encoder = new TextEncoder();
1142
- try {
1143
- let returnValue;
1144
- while (true) {
1145
- const { value, done } = await result.next();
1146
- if (done) {
1147
- returnValue = value;
1148
- break;
1149
- }
1150
- if (isSentinel(value) && value[sentinel] === 'error') {
1151
- const tagged = value;
1152
- controller.enqueue(encoder.encode(JSON.stringify({
1153
- type: 'error',
1154
- ok: false,
1155
- error: { code: tagged.code, message: tagged.message },
1156
- }) + '\n'));
1157
- controller.close();
1158
- return;
1159
- }
1160
- controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'));
1161
- }
1162
- const meta = { command: path };
1163
- if (isSentinel(returnValue) && returnValue[sentinel] === 'error') {
1164
- const tagged = returnValue;
1165
- controller.enqueue(encoder.encode(JSON.stringify({
1166
- type: 'error',
1167
- ok: false,
1168
- error: { code: tagged.code, message: tagged.message },
1169
- }) + '\n'));
1170
- }
1171
- else {
1172
- controller.enqueue(encoder.encode(JSON.stringify({ type: 'done', ok: true, meta }) + '\n'));
1173
- }
1174
- }
1175
- catch (error) {
1176
- controller.enqueue(encoder.encode(JSON.stringify({
1177
- type: 'error',
1178
- ok: false,
1179
- error: {
1180
- code: 'UNKNOWN',
1181
- message: error instanceof Error ? error.message : String(error),
1182
- },
1183
- }) + '\n'));
1100
+ const allMiddleware = [
1101
+ ...(options.middlewares ?? []),
1102
+ ...(options.groupMiddlewares ?? []),
1103
+ ...(command.middleware ?? []),
1104
+ ];
1105
+ const result = await Command.execute(command, {
1106
+ agent: true,
1107
+ argv: rest,
1108
+ env: options.envSchema,
1109
+ format: 'json',
1110
+ formatExplicit: true,
1111
+ inputOptions,
1112
+ middlewares: allMiddleware,
1113
+ name: options.name ?? path,
1114
+ parseMode: 'split',
1115
+ path,
1116
+ vars: options.vars,
1117
+ version: options.version,
1118
+ });
1119
+ const duration = `${Math.round(performance.now() - start)}ms`;
1120
+ // Streaming path — async generator → NDJSON response
1121
+ if ('stream' in result) {
1122
+ const stream = new ReadableStream({
1123
+ async start(controller) {
1124
+ const encoder = new TextEncoder();
1125
+ try {
1126
+ for await (const value of result.stream) {
1127
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'));
1184
1128
  }
1185
- controller.close();
1186
- },
1187
- });
1188
- response = new Response(stream, {
1189
- status: 200,
1190
- headers: { 'content-type': 'application/x-ndjson' },
1191
- });
1192
- return;
1193
- }
1194
- const awaited = await result;
1195
- const duration = `${Math.round(performance.now() - start)}ms`;
1196
- if (typeof awaited === 'object' && awaited !== null && sentinel_ in awaited) {
1197
- const tagged = awaited;
1198
- if (tagged[sentinel_] === 'error')
1199
- response = jsonResponse({
1200
- ok: false,
1201
- error: { code: tagged.code, message: tagged.message },
1202
- meta: { command: path, duration },
1203
- }, 500);
1204
- else
1205
- response = jsonResponse({ ok: true, data: tagged.data, meta: { command: path, duration } }, 200);
1206
- return;
1207
- }
1208
- response = jsonResponse({ ok: true, data: awaited, meta: { command: path, duration } }, 200);
1209
- };
1210
- try {
1211
- const allMiddleware = options.middlewares ?? [];
1212
- if (allMiddleware.length > 0) {
1213
- const errorFn = (opts) => {
1214
- const duration = `${Math.round(performance.now() - start)}ms`;
1215
- response = jsonResponse({
1216
- ok: false,
1217
- error: { code: opts.code, message: opts.message },
1218
- meta: { command: path, duration },
1219
- }, 500);
1220
- return undefined;
1221
- };
1222
- const mwCtx = {
1223
- agent: true,
1224
- command: path,
1225
- env: {},
1226
- error: errorFn,
1227
- format: 'json',
1228
- formatExplicit: true,
1229
- name: path,
1230
- set(key, value) {
1231
- varsMap[key] = value;
1232
- },
1233
- var: varsMap,
1234
- version: undefined,
1235
- };
1236
- const composed = allMiddleware.reduceRight((next, mw) => async () => {
1237
- await mw(mwCtx, next);
1238
- }, runCommand);
1239
- await composed();
1240
- }
1241
- else {
1242
- await runCommand();
1243
- }
1129
+ controller.enqueue(encoder.encode(JSON.stringify({
1130
+ type: 'done',
1131
+ ok: true,
1132
+ meta: { command: path },
1133
+ }) + '\n'));
1134
+ }
1135
+ catch (error) {
1136
+ controller.enqueue(encoder.encode(JSON.stringify({
1137
+ type: 'error',
1138
+ ok: false,
1139
+ error: {
1140
+ code: 'UNKNOWN',
1141
+ message: error instanceof Error ? error.message : String(error),
1142
+ },
1143
+ }) + '\n'));
1144
+ }
1145
+ controller.close();
1146
+ },
1147
+ });
1148
+ return new Response(stream, {
1149
+ status: 200,
1150
+ headers: { 'content-type': 'application/x-ndjson' },
1151
+ });
1244
1152
  }
1245
- catch (error) {
1246
- const duration = `${Math.round(performance.now() - start)}ms`;
1247
- if (error instanceof ValidationError)
1248
- return jsonResponse({
1249
- ok: false,
1250
- error: { code: 'VALIDATION_ERROR', message: error.message },
1251
- meta: { command: path, duration },
1252
- }, 400);
1153
+ if (!result.ok) {
1154
+ const cta = formatCtaBlock(options.name ?? path, result.cta);
1253
1155
  return jsonResponse({
1254
1156
  ok: false,
1255
1157
  error: {
1256
- code: error instanceof IncurError ? error.code : 'UNKNOWN',
1257
- message: error instanceof Error ? error.message : String(error),
1158
+ code: result.error.code,
1159
+ message: result.error.message,
1160
+ ...(result.error.retryable !== undefined
1161
+ ? { retryable: result.error.retryable }
1162
+ : undefined),
1163
+ },
1164
+ meta: {
1165
+ command: path,
1166
+ duration,
1167
+ ...(cta ? { cta } : undefined),
1258
1168
  },
1259
- meta: { command: path, duration },
1260
- }, 500);
1169
+ }, result.error.code === 'VALIDATION_ERROR' ? 400 : 500);
1261
1170
  }
1262
- return response;
1171
+ const cta = formatCtaBlock(options.name ?? path, result.cta);
1172
+ return jsonResponse({
1173
+ ok: true,
1174
+ data: result.data,
1175
+ meta: {
1176
+ command: path,
1177
+ duration,
1178
+ ...(cta ? { cta } : undefined),
1179
+ },
1180
+ }, 200);
1263
1181
  }
1264
1182
  /** @internal Formats a validation error for TTY with usage hint. */
1265
- function formatHumanValidationError(cli, path, command, error, envSource) {
1183
+ function formatHumanValidationError(cli, path, command, error, envSource, configFlag) {
1266
1184
  const lines = [];
1267
1185
  for (const fe of error.fieldErrors)
1268
1186
  lines.push(`Error: missing required argument <${fe.path}>`);
@@ -1270,6 +1188,7 @@ function formatHumanValidationError(cli, path, command, error, envSource) {
1270
1188
  lines.push('');
1271
1189
  lines.push(Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
1272
1190
  alias: command.alias,
1191
+ configFlag,
1273
1192
  description: command.description,
1274
1193
  args: command.args,
1275
1194
  env: command.env,
@@ -1343,7 +1262,7 @@ function resolveCommand(commands, tokens) {
1343
1262
  };
1344
1263
  }
1345
1264
  /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
1346
- function extractBuiltinFlags(argv) {
1265
+ function extractBuiltinFlags(argv, options = {}) {
1347
1266
  let verbose = false;
1348
1267
  let llms = false;
1349
1268
  let llmsFull = false;
@@ -1353,11 +1272,16 @@ function extractBuiltinFlags(argv) {
1353
1272
  let schema = false;
1354
1273
  let format = 'toon';
1355
1274
  let formatExplicit = false;
1275
+ let configPath;
1276
+ let configDisabled = false;
1356
1277
  let filterOutput;
1357
1278
  let tokenLimit;
1358
1279
  let tokenOffset;
1359
1280
  let tokenCount = false;
1360
1281
  const rest = [];
1282
+ const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined;
1283
+ const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined;
1284
+ const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined;
1361
1285
  for (let i = 0; i < argv.length; i++) {
1362
1286
  const token = argv[i];
1363
1287
  if (token === '--verbose')
@@ -1383,6 +1307,25 @@ function extractBuiltinFlags(argv) {
1383
1307
  formatExplicit = true;
1384
1308
  i++;
1385
1309
  }
1310
+ else if (cfgFlag && token === cfgFlag) {
1311
+ const value = argv[i + 1];
1312
+ if (value === undefined)
1313
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` });
1314
+ configPath = value;
1315
+ configDisabled = false;
1316
+ i++;
1317
+ }
1318
+ else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
1319
+ const value = token.slice(cfgFlagEq.length);
1320
+ if (value.length === 0)
1321
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` });
1322
+ configPath = value;
1323
+ configDisabled = false;
1324
+ }
1325
+ else if (noCfgFlag && token === noCfgFlag) {
1326
+ configPath = undefined;
1327
+ configDisabled = true;
1328
+ }
1386
1329
  else if (token === '--filter-output' && argv[i + 1]) {
1387
1330
  filterOutput = argv[i + 1];
1388
1331
  i++;
@@ -1404,6 +1347,8 @@ function extractBuiltinFlags(argv) {
1404
1347
  verbose,
1405
1348
  format,
1406
1349
  formatExplicit,
1350
+ configPath,
1351
+ configDisabled,
1407
1352
  filterOutput,
1408
1353
  tokenLimit,
1409
1354
  tokenOffset,
@@ -1417,6 +1362,117 @@ function extractBuiltinFlags(argv) {
1417
1362
  rest,
1418
1363
  };
1419
1364
  }
1365
+ /** @internal Loads config-backed option defaults for the active command. */
1366
+ async function loadCommandOptionDefaults(cli, path, options = {}) {
1367
+ if (options.configDisabled)
1368
+ return undefined;
1369
+ const { loader } = options;
1370
+ // Resolve the target file path
1371
+ let targetPath;
1372
+ if (options.configPath) {
1373
+ targetPath = resolveConfigPath(options.configPath);
1374
+ }
1375
+ else {
1376
+ const searchPaths = options.files ?? [`${cli}.json`];
1377
+ targetPath = await findFirstExisting(searchPaths);
1378
+ }
1379
+ // Load and parse the config
1380
+ let parsed;
1381
+ if (loader) {
1382
+ const result = await loader(targetPath);
1383
+ if (result === undefined)
1384
+ return undefined;
1385
+ if (!isRecord(result))
1386
+ throw new ParseError({ message: 'Config loader must return a plain object or undefined' });
1387
+ parsed = result;
1388
+ }
1389
+ else {
1390
+ if (!targetPath)
1391
+ return undefined;
1392
+ const result = await readJsonConfig(targetPath, !!options.configPath);
1393
+ if (!result)
1394
+ return undefined;
1395
+ parsed = result;
1396
+ }
1397
+ // Extract the command section from the config tree
1398
+ return extractCommandSection(parsed, cli, path);
1399
+ }
1400
+ /** @internal Resolves a config file path, expanding `~` to home dir. */
1401
+ function resolveConfigPath(filePath) {
1402
+ if (filePath.startsWith('~/') || filePath === '~') {
1403
+ return path.join(os.homedir(), filePath.slice(1));
1404
+ }
1405
+ return path.resolve(process.cwd(), filePath);
1406
+ }
1407
+ /** @internal Returns the first readable file from a list of paths, or `undefined`. */
1408
+ async function findFirstExisting(paths) {
1409
+ for (const p of paths) {
1410
+ const resolved = resolveConfigPath(p);
1411
+ try {
1412
+ await fs.access(resolved, fs.constants.R_OK);
1413
+ return resolved;
1414
+ }
1415
+ catch { }
1416
+ }
1417
+ return undefined;
1418
+ }
1419
+ /** @internal Reads and parses a JSON config file. */
1420
+ async function readJsonConfig(targetPath, explicit) {
1421
+ let raw;
1422
+ try {
1423
+ raw = await fs.readFile(targetPath, 'utf8');
1424
+ }
1425
+ catch (error) {
1426
+ if (error.code === 'ENOENT') {
1427
+ if (explicit)
1428
+ throw new ParseError({ message: `Config file not found: ${targetPath}` });
1429
+ return undefined;
1430
+ }
1431
+ throw error;
1432
+ }
1433
+ let parsed;
1434
+ try {
1435
+ parsed = JSON.parse(raw);
1436
+ }
1437
+ catch (error) {
1438
+ throw new ParseError({
1439
+ message: `Invalid JSON config file: ${targetPath}`,
1440
+ cause: error instanceof Error ? error : undefined,
1441
+ });
1442
+ }
1443
+ if (!isRecord(parsed))
1444
+ throw new ParseError({
1445
+ message: `Invalid config file: expected a top-level object in ${targetPath}`,
1446
+ });
1447
+ return parsed;
1448
+ }
1449
+ /** @internal Walks the nested config tree to extract option defaults for a command path. */
1450
+ function extractCommandSection(parsed, cli, path) {
1451
+ const segments = path === cli ? [] : path.split(' ');
1452
+ let node = parsed;
1453
+ for (const seg of segments) {
1454
+ if (!isRecord(node))
1455
+ return undefined;
1456
+ const commands = node.commands;
1457
+ if (!isRecord(commands))
1458
+ return undefined;
1459
+ node = commands[seg];
1460
+ if (node === undefined)
1461
+ return undefined;
1462
+ }
1463
+ if (!isRecord(node))
1464
+ throw new ParseError({
1465
+ message: `Invalid config section for '${path}': expected an object`,
1466
+ });
1467
+ const options = node.options;
1468
+ if (options === undefined)
1469
+ return undefined;
1470
+ if (!isRecord(options))
1471
+ throw new ParseError({
1472
+ message: `Invalid config 'options' for '${path}': expected an object`,
1473
+ });
1474
+ return Object.keys(options).length > 0 ? options : undefined;
1475
+ }
1420
1476
  /** @internal Collects immediate child commands/groups for help output. */
1421
1477
  function collectHelpCommands(commands) {
1422
1478
  const result = [];
@@ -1425,6 +1481,23 @@ function collectHelpCommands(commands) {
1425
1481
  }
1426
1482
  return result.sort((a, b) => a.name.localeCompare(b.name));
1427
1483
  }
1484
+ /** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */
1485
+ function formatBuiltinHelp(cli, builtin) {
1486
+ return Help.formatRoot(`${cli} ${builtin.name}`, {
1487
+ description: builtin.description,
1488
+ commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })),
1489
+ });
1490
+ }
1491
+ /** @internal Formats subcommand-level help for a built-in command (e.g. `cli skills add --help`). */
1492
+ function formatBuiltinSubcommandHelp(cli, builtin, subName) {
1493
+ const sub = builtin.subcommands?.find((s) => s.name === subName);
1494
+ return Help.formatCommand(`${cli} ${builtin.name} ${subName}`, {
1495
+ alias: sub?.alias,
1496
+ description: sub?.description,
1497
+ hideGlobalOptions: true,
1498
+ options: sub?.options,
1499
+ });
1500
+ }
1428
1501
  /** @internal Formats help text for a fetch gateway command. */
1429
1502
  function formatFetchHelp(name, description) {
1430
1503
  const lines = [];
@@ -1458,7 +1531,11 @@ export const toCommands = new WeakMap();
1458
1531
  /** @internal Maps CLI instances to their middleware arrays. */
1459
1532
  const toMiddlewares = new WeakMap();
1460
1533
  /** @internal Maps root CLI instances to their command definitions. */
1461
- const toRootDefinition = new WeakMap();
1534
+ export const toRootDefinition = new WeakMap();
1535
+ /** @internal Maps CLI instances to their root options schema. */
1536
+ export const toRootOptions = new WeakMap();
1537
+ /** @internal Maps CLI instances to whether config file loading is enabled. */
1538
+ export const toConfigEnabled = new WeakMap();
1462
1539
  /** @internal Maps CLI instances to their output policy. */
1463
1540
  const toOutputPolicy = new WeakMap();
1464
1541
  /** @internal Sentinel symbol for `ok()` and `error()` return values. */
@@ -1491,13 +1568,6 @@ function hasRequiredArgs(args) {
1491
1568
  function isSentinel(value) {
1492
1569
  return typeof value === 'object' && value !== null && sentinel in value;
1493
1570
  }
1494
- /** @internal Type guard for async generators returned by streaming `run` handlers. */
1495
- function isAsyncGenerator(value) {
1496
- return (typeof value === 'object' &&
1497
- value !== null &&
1498
- Symbol.asyncIterator in value &&
1499
- typeof value.next === 'function');
1500
- }
1501
1571
  /** @internal Handles streaming output from an async generator `run` handler. */
1502
1572
  async function handleStreaming(generator, ctx) {
1503
1573
  // Incremental: no explicit format (default toon), or explicit jsonl