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/src/Cli.ts CHANGED
@@ -1,13 +1,19 @@
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 type { z } from 'zod'
3
6
 
4
7
  import * as Completions from './Completions.js'
5
8
  import type { FieldError } from './Errors.js'
6
- import { IncurError, ValidationError } from './Errors.js'
9
+ import { IncurError, ParseError, ValidationError } from './Errors.js'
7
10
  import * as Fetch from './Fetch.js'
8
11
  import * as Filter from './Filter.js'
9
12
  import * as Formatter from './Formatter.js'
10
13
  import * as Help from './Help.js'
14
+ import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js'
15
+ import * as Command from './internal/command.js'
16
+ import { isRecord } from './internal/helpers.js'
11
17
  import { detectRunner } from './internal/pm.js'
12
18
  import type { OneOf } from './internal/types.js'
13
19
  import * as Mcp from './Mcp.js'
@@ -257,10 +263,13 @@ export function create(
257
263
  async fetch(req: Request) {
258
264
  if (pending.length > 0) await Promise.all(pending)
259
265
  return fetchImpl(name, commands, req, {
266
+ envSchema: def.env,
260
267
  mcpHandler,
261
268
  middlewares,
269
+ name,
262
270
  rootCommand: rootDef,
263
271
  vars: def.vars,
272
+ version: def.version,
264
273
  })
265
274
  },
266
275
 
@@ -269,6 +278,7 @@ export function create(
269
278
  return serveImpl(name, commands, argv, {
270
279
  ...serveOptions,
271
280
  aliases: def.aliases,
281
+ config: def.config,
272
282
  description: def.description,
273
283
  envSchema: def.env,
274
284
  format: def.format,
@@ -290,6 +300,8 @@ export function create(
290
300
  }
291
301
 
292
302
  if (rootDef) toRootDefinition.set(cli as unknown as Root, rootDef)
303
+ if (def.options) toRootOptions.set(cli, def.options)
304
+ if (def.config !== undefined) toConfigEnabled.set(cli, true)
293
305
  if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy)
294
306
  toMiddlewares.set(cli, middlewares)
295
307
  toCommands.set(cli, commands)
@@ -313,6 +325,24 @@ export declare namespace create {
313
325
  aliases?: string[] | undefined
314
326
  /** Zod schema for positional arguments. */
315
327
  args?: args | undefined
328
+ /** Enable config-file defaults for command options. */
329
+ config?:
330
+ | {
331
+ /** Global flag name for specifying a config file path (e.g. `'config'` → `--config <path>`). Omit to auto-load only, with no CLI flag. */
332
+ flag?: string | undefined
333
+ /** Ordered list of file paths to search. First existing file wins. Supports `~` for home dir. Defaults to `['<cli>.json']` relative to cwd. */
334
+ files?: string[] | undefined
335
+ /** Custom config loader. Receives the resolved file path (or `undefined` if no file was found). Returns the parsed config tree, or `undefined` for no defaults. When omitted, the framework reads and parses JSON. */
336
+ loader?:
337
+ | ((
338
+ path: string | undefined,
339
+ ) =>
340
+ | Record<string, unknown>
341
+ | undefined
342
+ | Promise<Record<string, unknown> | undefined>)
343
+ | undefined
344
+ }
345
+ | undefined
316
346
  /** A short description of what the CLI does. */
317
347
  description?: string | undefined
318
348
  /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
@@ -422,6 +452,24 @@ async function serveImpl(
422
452
  ) {
423
453
  const stdout = options.stdout ?? ((s: string) => process.stdout.write(s))
424
454
  const exit = options.exit ?? ((code: number) => process.exit(code))
455
+ const human = process.stdout.isTTY === true
456
+ const configEnabled = options.config !== undefined
457
+ const configFlag = options.config?.flag
458
+
459
+ function writeln(s: string) {
460
+ stdout(s.endsWith('\n') ? s : `${s}\n`)
461
+ }
462
+
463
+ let builtinFlags: ReturnType<typeof extractBuiltinFlags>
464
+ try {
465
+ builtinFlags = extractBuiltinFlags(argv, { configFlag })
466
+ } catch (error) {
467
+ const message = error instanceof Error ? error.message : String(error)
468
+ if (human) writeln(formatHumanError({ code: 'UNKNOWN', message }))
469
+ else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'))
470
+ exit(1)
471
+ return
472
+ }
425
473
 
426
474
  const {
427
475
  verbose,
@@ -437,17 +485,24 @@ async function serveImpl(
437
485
  help,
438
486
  version,
439
487
  schema,
488
+ configPath,
489
+ configDisabled,
440
490
  rest: filtered,
441
- } = extractBuiltinFlags(argv)
491
+ } = builtinFlags
442
492
 
443
493
  // --mcp: start as MCP stdio server
444
494
  if (mcpFlag) {
445
- await Mcp.serve(name, options.version ?? '0.0.0', commands)
495
+ await Mcp.serve(name, options.version ?? '0.0.0', commands, {
496
+ middlewares: options.middlewares,
497
+ env: options.envSchema,
498
+ vars: options.vars,
499
+ version: options.version,
500
+ })
446
501
  return
447
502
  }
448
503
 
449
504
  // COMPLETE: dynamic shell completions (called by shell hook at tab-press)
450
- const completeShell = process.env.COMPLETE as Completions.Shell | undefined
505
+ const completeShell = process.env.COMPLETE as Shell | undefined
451
506
  if (completeShell) {
452
507
  // Remove separator `--` from argv
453
508
  const sepIdx = argv.indexOf('--')
@@ -459,19 +514,32 @@ async function serveImpl(
459
514
  } else {
460
515
  const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1)
461
516
  const candidates = Completions.complete(commands, options.rootCommand, words, index)
517
+ // Add built-in commands (completions, mcp, skills) to completions
518
+ const current = words[index] ?? ''
519
+ const nonFlags = words.slice(0, index).filter((w) => !w.startsWith('-'))
520
+ if (nonFlags.length <= 1) {
521
+ for (const b of builtinCommands) {
522
+ if (b.name.startsWith(current) && !candidates.some((c) => c.value === b.name))
523
+ candidates.push({
524
+ value: b.name,
525
+ description: b.description,
526
+ ...(b.subcommands ? { noSpace: true } : undefined),
527
+ })
528
+ }
529
+ } else if (nonFlags.length === 2) {
530
+ const parent = nonFlags[nonFlags.length - 1]
531
+ const builtin = builtinCommands.find((b) => b.name === parent && b.subcommands)
532
+ if (builtin?.subcommands)
533
+ for (const sub of builtin.subcommands)
534
+ if (sub.name.startsWith(current))
535
+ candidates.push({ value: sub.name, description: sub.description })
536
+ }
462
537
  const out = Completions.format(completeShell, candidates)
463
538
  if (out) stdout(out)
464
539
  }
465
540
  return
466
541
  }
467
542
 
468
- // Human mode: stdout is a TTY.
469
- const human = process.stdout.isTTY === true
470
-
471
- function writeln(s: string) {
472
- stdout(s.endsWith('\n') ? s : `${s}\n`)
473
- }
474
-
475
543
  // Skills staleness check (skip for built-in commands)
476
544
  if (!llms && !llmsFull && !schema && !help && !version) {
477
545
  const isSkillsAdd =
@@ -544,73 +612,48 @@ async function serveImpl(
544
612
  // not a completions invocation
545
613
  return -1
546
614
  })()
615
+ // TODO: refactor built-in command handlers (completions, skills, mcp) into a generic dispatch loop on `builtinCommands`
547
616
  if (completionsIdx !== -1 && filtered[completionsIdx] === 'completions') {
548
- if (help) {
617
+ const shell = filtered[completionsIdx + 1]
618
+ if (help || !shell) {
619
+ const b = builtinCommands.find((c) => c.name === 'completions')!
549
620
  writeln(
550
- [
551
- `${name} completions — Generate shell completion script`,
552
- '',
553
- `Usage: ${name} completions <shell>`,
554
- '',
555
- 'Shells:',
556
- ' bash',
557
- ' fish',
558
- ' nushell',
559
- ' zsh',
560
- '',
561
- 'Setup:',
562
- ...(() => {
563
- const rows = [
564
- ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
565
- ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
566
- ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
567
- ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
568
- ]
569
- const shellW = Math.max(...rows.map((r) => r[0]!.length))
570
- const cmdW = Math.max(...rows.map((r) => r[1]!.length))
571
- return rows.map(
572
- ([shell, cmd, comment]) =>
573
- ` ${shell!.padEnd(shellW)} ${cmd!.padEnd(cmdW)} ${comment}`,
574
- )
575
- })(),
576
- ].join('\n'),
621
+ Help.formatCommand(`${name} completions`, {
622
+ args: b.args,
623
+ description: b.description,
624
+ hideGlobalOptions: true,
625
+ hint: b.hint?.(name),
626
+ }),
577
627
  )
578
628
  return
579
629
  }
580
- const shell = filtered[completionsIdx + 1]
581
- if (!shell || !['bash', 'fish', 'nushell', 'zsh'].includes(shell)) {
630
+ if (!shells.includes(shell as any)) {
582
631
  writeln(
583
632
  formatHumanError({
584
633
  code: 'INVALID_SHELL',
585
- message: shell
586
- ? `Unknown shell '${shell}'. Supported: bash, fish, nushell, zsh`
587
- : `Missing shell argument. Usage: ${name} completions <bash|fish|nushell|zsh>`,
634
+ message: `Unknown shell '${shell}'. Supported: ${shells.join(', ')}`,
588
635
  }),
589
636
  )
590
637
  exit(1)
591
638
  return
592
639
  }
593
640
  const names = [name, ...(options.aliases ?? [])]
594
- writeln(names.map((n) => Completions.register(shell as Completions.Shell, n)).join('\n'))
641
+ writeln(names.map((n) => Completions.register(shell as Shell, n)).join('\n'))
595
642
  return
596
643
  }
597
644
 
598
645
  // skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
599
646
  const skillsIdx =
600
647
  filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
601
- if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills' && filtered[skillsIdx + 1] === 'add') {
648
+ if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
649
+ if (filtered[skillsIdx + 1] !== 'add') {
650
+ const b = builtinCommands.find((c) => c.name === 'skills')!
651
+ writeln(formatBuiltinHelp(name, b))
652
+ return
653
+ }
602
654
  if (help) {
603
- writeln(
604
- [
605
- `${name} skills add — Sync skill files to agents`,
606
- '',
607
- `Usage: ${name} skills add [options]`,
608
- '',
609
- 'Options:',
610
- ' --depth <number> Grouping depth for skill files (default: 1)',
611
- ' --no-global Install to project instead of globally',
612
- ].join('\n'),
613
- )
655
+ const b = builtinCommands.find((c) => c.name === 'skills')!
656
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
614
657
  return
615
658
  }
616
659
  const rest = filtered.slice(skillsIdx + 2)
@@ -674,20 +717,15 @@ async function serveImpl(
674
717
 
675
718
  // mcp add: register CLI as MCP server via `npx add-mcp`
676
719
  const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
677
- if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp' && filtered[mcpIdx + 1] === 'add') {
720
+ if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
721
+ if (filtered[mcpIdx + 1] !== 'add') {
722
+ const b = builtinCommands.find((c) => c.name === 'mcp')!
723
+ writeln(formatBuiltinHelp(name, b))
724
+ return
725
+ }
678
726
  if (help) {
679
- writeln(
680
- [
681
- `${name} mcp add — Register as MCP server for your agent`,
682
- '',
683
- `Usage: ${name} mcp add [options]`,
684
- '',
685
- 'Options:',
686
- ' -c, --command <cmd> Override the command agents will run (e.g. "pnpm my-cli --mcp")',
687
- ' --no-global Install to project instead of globally',
688
- ' --agent <agent> Target a specific agent (e.g. claude-code, cursor)',
689
- ].join('\n'),
690
- )
727
+ const b = builtinCommands.find((c) => c.name === 'mcp')!
728
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
691
729
  return
692
730
  }
693
731
  const rest = filtered.slice(mcpIdx + 2)
@@ -759,6 +797,7 @@ async function serveImpl(
759
797
  Help.formatCommand(name, {
760
798
  alias: cmd.alias as Record<string, string> | undefined,
761
799
  aliases: options.aliases,
800
+ configFlag,
762
801
  description: cmd.description ?? options.description,
763
802
  version: options.version,
764
803
  args: cmd.args,
@@ -780,6 +819,7 @@ async function serveImpl(
780
819
  writeln(
781
820
  Help.formatRoot(name, {
782
821
  aliases: options.aliases,
822
+ configFlag,
783
823
  description: options.description,
784
824
  version: options.version,
785
825
  commands: collectHelpCommands(commands),
@@ -828,6 +868,7 @@ async function serveImpl(
828
868
  Help.formatCommand(name, {
829
869
  alias: cmd.alias as Record<string, string> | undefined,
830
870
  aliases: options.aliases,
871
+ configFlag,
831
872
  description: cmd.description ?? options.description,
832
873
  version: options.version,
833
874
  args: cmd.args,
@@ -845,6 +886,7 @@ async function serveImpl(
845
886
  writeln(
846
887
  Help.formatRoot(helpName, {
847
888
  aliases: isRoot ? options.aliases : undefined,
889
+ configFlag,
848
890
  description: helpDesc,
849
891
  version: isRoot ? options.version : undefined,
850
892
  commands: collectHelpCommands(helpCmds),
@@ -864,6 +906,7 @@ async function serveImpl(
864
906
  Help.formatCommand(commandName, {
865
907
  alias: cmd.alias as Record<string, string> | undefined,
866
908
  aliases: isRootCmd ? options.aliases : undefined,
909
+ configFlag,
867
910
  description: cmd.description,
868
911
  version: isRootCmd ? options.version : undefined,
869
912
  args: cmd.args,
@@ -886,6 +929,7 @@ async function serveImpl(
886
929
  if ('help' in resolved) {
887
930
  writeln(
888
931
  Help.formatRoot(`${name} ${resolved.path}`, {
932
+ configFlag,
889
933
  description: resolved.description,
890
934
  commands: collectHelpCommands(resolved.commands),
891
935
  }),
@@ -917,6 +961,7 @@ async function serveImpl(
917
961
  if ('help' in resolved) {
918
962
  writeln(
919
963
  Help.formatRoot(`${name} ${resolved.path}`, {
964
+ configFlag,
920
965
  description: resolved.description,
921
966
  commands: collectHelpCommands(resolved.commands),
922
967
  }),
@@ -1198,211 +1243,150 @@ async function serveImpl(
1198
1243
  ...((command.middleware as MiddlewareHandler[] | undefined) ?? []),
1199
1244
  ]
1200
1245
 
1201
- // Initialize vars from schema defaults
1202
- const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1203
- const envSource = options.env ?? process.env
1204
-
1205
- const runCommand = async () => {
1206
- const { args, options: parsedOptions } = Parser.parse(rest, {
1207
- alias: command.alias as Record<string, string> | undefined,
1208
- args: command.args,
1209
- options: command.options,
1210
- })
1211
-
1212
- if (human)
1213
- emitDeprecationWarnings(
1214
- rest,
1215
- command.options,
1216
- command.alias as Record<string, string> | undefined,
1217
- )
1218
-
1219
- const env = command.env ? Parser.parseEnv(command.env, envSource) : {}
1220
-
1221
- const okFn = (data: unknown, meta: { cta?: CtaBlock | undefined } = {}): never => {
1222
- return { [sentinel]: 'ok', data, cta: meta.cta } as never
1223
- }
1224
- const errorFn = (opts: {
1225
- code: string
1226
- exitCode?: number | undefined
1227
- message: string
1228
- retryable?: boolean | undefined
1229
- cta?: CtaBlock | undefined
1230
- }): never => {
1231
- return { [sentinel]: 'error', ...opts } as never
1232
- }
1233
-
1234
- const result = command.run({
1235
- agent: !human,
1236
- args,
1237
- env,
1238
- error: errorFn,
1239
- format,
1240
- formatExplicit,
1241
- name,
1242
- ok: okFn,
1243
- options: parsedOptions,
1244
- var: varsMap,
1245
- version: options.version,
1246
- })
1246
+ if (human)
1247
+ emitDeprecationWarnings(
1248
+ rest,
1249
+ command.options,
1250
+ command.alias as Record<string, string> | undefined,
1251
+ )
1247
1252
 
1248
- // Streaming path async generator
1249
- if (isAsyncGenerator(result)) {
1250
- await handleStreaming(result, {
1251
- name,
1252
- path,
1253
- start,
1254
- format,
1255
- formatExplicit,
1256
- human,
1257
- renderOutput,
1258
- verbose,
1259
- truncate,
1260
- write,
1261
- writeln,
1262
- exit,
1253
+ let defaults: Record<string, unknown> | undefined
1254
+ if (configEnabled) {
1255
+ try {
1256
+ defaults = await loadCommandOptionDefaults(name, path, {
1257
+ configDisabled,
1258
+ configPath,
1259
+ files: options.config?.files,
1260
+ loader: options.config?.loader,
1263
1261
  })
1264
- return
1265
- }
1266
-
1267
- const awaited = await result
1268
-
1269
- if (isSentinel(awaited)) {
1270
- const cta = formatCtaBlock(name, awaited.cta)
1271
- if (awaited[sentinel] === 'ok') {
1272
- write({
1273
- ok: true,
1274
- data: awaited.data,
1275
- meta: {
1276
- command: path,
1277
- duration: `${Math.round(performance.now() - start)}ms`,
1278
- ...(cta ? { cta } : undefined),
1279
- },
1280
- })
1281
- } else {
1282
- const err = awaited as ErrorResult
1283
- write({
1284
- ok: false,
1285
- error: {
1286
- code: err.code,
1287
- message: err.message,
1288
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1289
- },
1290
- meta: {
1291
- command: path,
1292
- duration: `${Math.round(performance.now() - start)}ms`,
1293
- ...(cta ? { cta } : undefined),
1294
- },
1295
- })
1296
- exit(err.exitCode ?? 1)
1297
- }
1298
- } else {
1262
+ } catch (error) {
1299
1263
  write({
1300
- ok: true,
1301
- data: awaited,
1302
- meta: {
1303
- command: path,
1304
- duration: `${Math.round(performance.now() - start)}ms`,
1264
+ ok: false,
1265
+ error: {
1266
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1267
+ message: error instanceof Error ? error.message : String(error),
1305
1268
  },
1269
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1306
1270
  })
1271
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1272
+ return
1307
1273
  }
1308
1274
  }
1309
1275
 
1310
- try {
1311
- const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, envSource) : {}
1276
+ const result = await Command.execute(command, {
1277
+ agent: !human,
1278
+ argv: rest,
1279
+ defaults,
1280
+ env: options.envSchema,
1281
+ envSource: options.env,
1282
+ format,
1283
+ formatExplicit,
1284
+ inputOptions: {},
1285
+ middlewares: allMiddleware,
1286
+ name,
1287
+ path,
1288
+ vars: options.vars,
1289
+ version: options.version,
1290
+ })
1312
1291
 
1313
- if (allMiddleware.length > 0) {
1314
- const errorFn = (opts: {
1315
- code: string
1316
- exitCode?: number | undefined
1317
- message: string
1318
- retryable?: boolean | undefined
1319
- cta?: CtaBlock | undefined
1320
- }): never => {
1321
- return { [sentinel]: 'error', ...opts } as never
1322
- }
1323
- const mwCtx: MiddlewareContext = {
1324
- agent: !human,
1292
+ const duration = `${Math.round(performance.now() - start)}ms`
1293
+
1294
+ // Streaming path — async generator
1295
+ if ('stream' in result) {
1296
+ await handleStreaming(result.stream, {
1297
+ name,
1298
+ path,
1299
+ start,
1300
+ format,
1301
+ formatExplicit,
1302
+ human,
1303
+ renderOutput,
1304
+ verbose,
1305
+ truncate,
1306
+ write,
1307
+ writeln,
1308
+ exit,
1309
+ })
1310
+ return
1311
+ }
1312
+
1313
+ if (result.ok) {
1314
+ const cta = formatCtaBlock(name, result.cta as CtaBlock | undefined)
1315
+ write({
1316
+ ok: true,
1317
+ data: result.data,
1318
+ meta: {
1325
1319
  command: path,
1326
- env: cliEnv,
1327
- error: errorFn,
1328
- format,
1329
- formatExplicit,
1330
- name,
1331
- set(key: string, value: unknown) {
1332
- varsMap[key] = value
1333
- },
1334
- var: varsMap,
1335
- version: options.version,
1336
- }
1337
- const handleMwSentinel = (result: unknown) => {
1338
- if (!isSentinel(result) || result[sentinel] !== 'error') return
1339
- const err = result as ErrorResult
1340
- const cta = formatCtaBlock(name, err.cta)
1341
- write({
1342
- ok: false,
1343
- error: {
1344
- code: err.code,
1345
- message: err.message,
1346
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1347
- },
1348
- meta: {
1349
- command: path,
1350
- duration: `${Math.round(performance.now() - start)}ms`,
1351
- ...(cta ? { cta } : undefined),
1352
- },
1353
- })
1354
- exit(err.exitCode ?? 1)
1355
- }
1356
- const composed = allMiddleware.reduceRight(
1357
- (next: () => Promise<void>, mw) => async () => {
1358
- handleMwSentinel(await mw(mwCtx, next))
1359
- },
1360
- runCommand,
1320
+ duration,
1321
+ ...(cta ? { cta } : undefined),
1322
+ },
1323
+ })
1324
+ } else {
1325
+ const cta = formatCtaBlock(name, result.cta as CtaBlock | undefined)
1326
+
1327
+ if (human && !formatExplicit && result.error.fieldErrors) {
1328
+ writeln(
1329
+ formatHumanValidationError(
1330
+ name,
1331
+ path,
1332
+ command,
1333
+ new ValidationError({
1334
+ message: result.error.message,
1335
+ fieldErrors: result.error.fieldErrors,
1336
+ }),
1337
+ options.env,
1338
+ configFlag,
1339
+ ),
1361
1340
  )
1362
- await composed()
1363
- } else {
1364
- await runCommand()
1341
+ exit(1)
1342
+ return
1365
1343
  }
1366
- } catch (error) {
1367
- const errorOutput: Output = {
1344
+
1345
+ write({
1368
1346
  ok: false,
1369
1347
  error: {
1370
- code:
1371
- error instanceof IncurError
1372
- ? error.code
1373
- : error instanceof ValidationError
1374
- ? 'VALIDATION_ERROR'
1375
- : 'UNKNOWN',
1376
- message: error instanceof Error ? error.message : String(error),
1377
- ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
1378
- ...(error instanceof ValidationError ? { fieldErrors: error.fieldErrors } : undefined),
1348
+ code: result.error.code,
1349
+ message: result.error.message,
1350
+ ...(result.error.retryable !== undefined
1351
+ ? { retryable: result.error.retryable }
1352
+ : undefined),
1353
+ ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined),
1379
1354
  },
1380
1355
  meta: {
1381
1356
  command: path,
1382
- duration: `${Math.round(performance.now() - start)}ms`,
1357
+ duration,
1358
+ ...(cta ? { cta } : undefined),
1383
1359
  },
1384
- }
1385
-
1386
- if (human && !formatExplicit && error instanceof ValidationError) {
1387
- writeln(formatHumanValidationError(name, path, command, error, options.env))
1388
- exit(1)
1389
- return
1390
- }
1391
-
1392
- write(errorOutput)
1393
- exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1360
+ })
1361
+ exit(result.exitCode ?? 1)
1394
1362
  }
1395
1363
  }
1396
1364
 
1397
1365
  /** @internal Options for fetchImpl. */
1398
1366
  declare namespace fetchImpl {
1399
1367
  type Options = {
1368
+ /** CLI-level env schema. */
1369
+ envSchema?: z.ZodObject<any> | undefined
1370
+ /** Group-level middleware collected during command resolution. */
1371
+ groupMiddlewares?: MiddlewareHandler[] | undefined
1400
1372
  mcpHandler?:
1401
- | ((req: Request, commands: Map<string, CommandEntry>) => Promise<Response>)
1373
+ | ((
1374
+ req: Request,
1375
+ commands: Map<string, CommandEntry>,
1376
+ mcpOptions?: {
1377
+ middlewares?: MiddlewareHandler[] | undefined
1378
+ env?: z.ZodObject<any> | undefined
1379
+ vars?: z.ZodObject<any> | undefined
1380
+ },
1381
+ ) => Promise<Response>)
1402
1382
  | undefined
1403
1383
  middlewares?: MiddlewareHandler[] | undefined
1384
+ /** CLI name. */
1385
+ name?: string | undefined
1404
1386
  rootCommand?: CommandDefinition<any, any, any> | undefined
1405
1387
  vars?: z.ZodObject<any> | undefined
1388
+ /** CLI version string. */
1389
+ version?: string | undefined
1406
1390
  }
1407
1391
  }
1408
1392
 
@@ -1410,7 +1394,15 @@ declare namespace fetchImpl {
1410
1394
  function createMcpHttpHandler(name: string, version: string) {
1411
1395
  let transport: any
1412
1396
 
1413
- return async (req: Request, commands: Map<string, CommandEntry>): Promise<Response> => {
1397
+ return async (
1398
+ req: Request,
1399
+ commands: Map<string, CommandEntry>,
1400
+ mcpOptions?: {
1401
+ middlewares?: MiddlewareHandler[] | undefined
1402
+ env?: z.ZodObject<any> | undefined
1403
+ vars?: z.ZodObject<any> | undefined
1404
+ },
1405
+ ): Promise<Response> => {
1414
1406
  if (!transport) {
1415
1407
  const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
1416
1408
  const { WebStandardStreamableHTTPServerTransport } =
@@ -1433,7 +1425,13 @@ function createMcpHttpHandler(name: string, version: string) {
1433
1425
  },
1434
1426
  async (...callArgs: any[]) => {
1435
1427
  const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
1436
- return Mcp.callTool(tool, params)
1428
+ return Mcp.callTool(tool, params, {
1429
+ name,
1430
+ version,
1431
+ middlewares: mcpOptions?.middlewares,
1432
+ env: mcpOptions?.env,
1433
+ vars: mcpOptions?.vars,
1434
+ })
1437
1435
  },
1438
1436
  )
1439
1437
  }
@@ -1462,7 +1460,11 @@ async function fetchImpl(
1462
1460
 
1463
1461
  // MCP over HTTP: route /mcp to the MCP transport
1464
1462
  if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler)
1465
- return options.mcpHandler(req, commands)
1463
+ return options.mcpHandler(req, commands, {
1464
+ middlewares: options.middlewares,
1465
+ env: options.envSchema,
1466
+ vars: options.vars,
1467
+ })
1466
1468
 
1467
1469
  // .well-known/skills/ — Agent Skills Discovery (RFC)
1468
1470
  if (
@@ -1571,7 +1573,11 @@ async function fetchImpl(
1571
1573
  if ('fetchGateway' in resolved) return resolved.fetchGateway.fetch(req)
1572
1574
 
1573
1575
  const { command, path, rest } = resolved
1574
- return executeCommand(path, command, rest, inputOptions, start, options)
1576
+ const groupMiddlewares = 'middlewares' in resolved ? resolved.middlewares : []
1577
+ return executeCommand(path, command, rest, inputOptions, start, {
1578
+ ...options,
1579
+ groupMiddlewares,
1580
+ })
1575
1581
  }
1576
1582
 
1577
1583
  /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
@@ -1590,200 +1596,107 @@ async function executeCommand(
1590
1596
  })
1591
1597
  }
1592
1598
 
1593
- const sentinel_ = Symbol.for('incur.sentinel')
1594
- const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1595
- let response: Response | undefined
1599
+ const allMiddleware = [
1600
+ ...(options.middlewares ?? []),
1601
+ ...((options.groupMiddlewares as MiddlewareHandler[] | undefined) ?? []),
1602
+ ...((command.middleware as MiddlewareHandler[] | undefined) ?? []),
1603
+ ]
1596
1604
 
1597
- const runCommand = async () => {
1598
- const { args } = Parser.parse(rest, { args: command.args })
1599
- const parsedOptions = command.options ? command.options.parse(inputOptions) : {}
1605
+ const result = await Command.execute(command, {
1606
+ agent: true,
1607
+ argv: rest,
1608
+ env: options.envSchema,
1609
+ format: 'json',
1610
+ formatExplicit: true,
1611
+ inputOptions,
1612
+ middlewares: allMiddleware,
1613
+ name: options.name ?? path,
1614
+ parseMode: 'split',
1615
+ path,
1616
+ vars: options.vars,
1617
+ version: options.version,
1618
+ })
1600
1619
 
1601
- const okFn = (data: unknown): never => ({ [sentinel_]: 'ok', data }) as never
1602
- const errorFn = (opts: {
1603
- code: string
1604
- message: string
1605
- exitCode?: number | undefined
1606
- }): never => ({ [sentinel_]: 'error', ...opts }) as never
1607
-
1608
- const result = command.run({
1609
- agent: true,
1610
- args,
1611
- env: {},
1612
- error: errorFn,
1613
- format: 'json',
1614
- formatExplicit: true,
1615
- name: path,
1616
- ok: okFn,
1617
- options: parsedOptions,
1618
- var: varsMap,
1619
- version: undefined,
1620
- })
1620
+ const duration = `${Math.round(performance.now() - start)}ms`
1621
1621
 
1622
- // Streaming path — async generator → NDJSON response
1623
- if (isAsyncGenerator(result)) {
1624
- const stream = new ReadableStream({
1625
- async start(controller) {
1626
- const encoder = new TextEncoder()
1627
- try {
1628
- let returnValue: unknown
1629
- while (true) {
1630
- const { value, done } = await result.next()
1631
- if (done) {
1632
- returnValue = value
1633
- break
1634
- }
1635
- if (isSentinel(value) && (value as any)[sentinel] === 'error') {
1636
- const tagged = value as any
1637
- controller.enqueue(
1638
- encoder.encode(
1639
- JSON.stringify({
1640
- type: 'error',
1641
- ok: false,
1642
- error: { code: tagged.code, message: tagged.message },
1643
- }) + '\n',
1644
- ),
1645
- )
1646
- controller.close()
1647
- return
1648
- }
1649
- controller.enqueue(
1650
- encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'),
1651
- )
1652
- }
1653
- const meta: Record<string, unknown> = { command: path }
1654
- if (isSentinel(returnValue) && (returnValue as any)[sentinel] === 'error') {
1655
- const tagged = returnValue as any
1656
- controller.enqueue(
1657
- encoder.encode(
1658
- JSON.stringify({
1659
- type: 'error',
1660
- ok: false,
1661
- error: { code: tagged.code, message: tagged.message },
1662
- }) + '\n',
1663
- ),
1664
- )
1665
- } else {
1666
- controller.enqueue(
1667
- encoder.encode(JSON.stringify({ type: 'done', ok: true, meta }) + '\n'),
1668
- )
1669
- }
1670
- } catch (error) {
1622
+ // Streaming path — async generator → NDJSON response
1623
+ if ('stream' in result) {
1624
+ const stream = new ReadableStream({
1625
+ async start(controller) {
1626
+ const encoder = new TextEncoder()
1627
+ try {
1628
+ for await (const value of result.stream) {
1671
1629
  controller.enqueue(
1672
- encoder.encode(
1673
- JSON.stringify({
1674
- type: 'error',
1675
- ok: false,
1676
- error: {
1677
- code: 'UNKNOWN',
1678
- message: error instanceof Error ? error.message : String(error),
1679
- },
1680
- }) + '\n',
1681
- ),
1630
+ encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'),
1682
1631
  )
1683
1632
  }
1684
- controller.close()
1685
- },
1686
- })
1687
- response = new Response(stream, {
1688
- status: 200,
1689
- headers: { 'content-type': 'application/x-ndjson' },
1690
- })
1691
- return
1692
- }
1693
-
1694
- const awaited = await result
1695
- const duration = `${Math.round(performance.now() - start)}ms`
1696
-
1697
- if (typeof awaited === 'object' && awaited !== null && sentinel_ in awaited) {
1698
- const tagged = awaited as any
1699
- if (tagged[sentinel_] === 'error')
1700
- response = jsonResponse(
1701
- {
1702
- ok: false,
1703
- error: { code: tagged.code, message: tagged.message },
1704
- meta: { command: path, duration },
1705
- },
1706
- 500,
1707
- )
1708
- else
1709
- response = jsonResponse(
1710
- { ok: true, data: tagged.data, meta: { command: path, duration } },
1711
- 200,
1712
- )
1713
- return
1714
- }
1715
-
1716
- response = jsonResponse({ ok: true, data: awaited, meta: { command: path, duration } }, 200)
1633
+ controller.enqueue(
1634
+ encoder.encode(
1635
+ JSON.stringify({
1636
+ type: 'done',
1637
+ ok: true,
1638
+ meta: { command: path },
1639
+ }) + '\n',
1640
+ ),
1641
+ )
1642
+ } catch (error) {
1643
+ controller.enqueue(
1644
+ encoder.encode(
1645
+ JSON.stringify({
1646
+ type: 'error',
1647
+ ok: false,
1648
+ error: {
1649
+ code: 'UNKNOWN',
1650
+ message: error instanceof Error ? error.message : String(error),
1651
+ },
1652
+ }) + '\n',
1653
+ ),
1654
+ )
1655
+ }
1656
+ controller.close()
1657
+ },
1658
+ })
1659
+ return new Response(stream, {
1660
+ status: 200,
1661
+ headers: { 'content-type': 'application/x-ndjson' },
1662
+ })
1717
1663
  }
1718
1664
 
1719
- try {
1720
- const allMiddleware = options.middlewares ?? []
1721
- if (allMiddleware.length > 0) {
1722
- const errorFn = (opts: {
1723
- code: string
1724
- message: string
1725
- exitCode?: number | undefined
1726
- }): never => {
1727
- const duration = `${Math.round(performance.now() - start)}ms`
1728
- response = jsonResponse(
1729
- {
1730
- ok: false,
1731
- error: { code: opts.code, message: opts.message },
1732
- meta: { command: path, duration },
1733
- },
1734
- 500,
1735
- )
1736
- return undefined as never
1737
- }
1738
- const mwCtx: MiddlewareContext = {
1739
- agent: true,
1740
- command: path,
1741
- env: {},
1742
- error: errorFn,
1743
- format: 'json',
1744
- formatExplicit: true,
1745
- name: path,
1746
- set(key: string, value: unknown) {
1747
- varsMap[key] = value
1748
- },
1749
- var: varsMap,
1750
- version: undefined,
1751
- }
1752
- const composed = allMiddleware.reduceRight(
1753
- (next: () => Promise<void>, mw) => async () => {
1754
- await mw(mwCtx, next)
1755
- },
1756
- runCommand,
1757
- )
1758
- await composed()
1759
- } else {
1760
- await runCommand()
1761
- }
1762
- } catch (error) {
1763
- const duration = `${Math.round(performance.now() - start)}ms`
1764
- if (error instanceof ValidationError)
1765
- return jsonResponse(
1766
- {
1767
- ok: false,
1768
- error: { code: 'VALIDATION_ERROR', message: error.message },
1769
- meta: { command: path, duration },
1770
- },
1771
- 400,
1772
- )
1665
+ if (!result.ok) {
1666
+ const cta = formatCtaBlock(options.name ?? path, result.cta as CtaBlock | undefined)
1773
1667
  return jsonResponse(
1774
1668
  {
1775
1669
  ok: false,
1776
1670
  error: {
1777
- code: error instanceof IncurError ? error.code : 'UNKNOWN',
1778
- message: error instanceof Error ? error.message : String(error),
1671
+ code: result.error.code,
1672
+ message: result.error.message,
1673
+ ...(result.error.retryable !== undefined
1674
+ ? { retryable: result.error.retryable }
1675
+ : undefined),
1676
+ },
1677
+ meta: {
1678
+ command: path,
1679
+ duration,
1680
+ ...(cta ? { cta } : undefined),
1779
1681
  },
1780
- meta: { command: path, duration },
1781
1682
  },
1782
- 500,
1683
+ result.error.code === 'VALIDATION_ERROR' ? 400 : 500,
1783
1684
  )
1784
1685
  }
1785
1686
 
1786
- return response!
1687
+ const cta = formatCtaBlock(options.name ?? path, result.cta as CtaBlock | undefined)
1688
+ return jsonResponse(
1689
+ {
1690
+ ok: true,
1691
+ data: result.data,
1692
+ meta: {
1693
+ command: path,
1694
+ duration,
1695
+ ...(cta ? { cta } : undefined),
1696
+ },
1697
+ },
1698
+ 200,
1699
+ )
1787
1700
  }
1788
1701
 
1789
1702
  /** @internal Formats a validation error for TTY with usage hint. */
@@ -1793,6 +1706,7 @@ function formatHumanValidationError(
1793
1706
  command: CommandDefinition<any, any, any>,
1794
1707
  error: ValidationError,
1795
1708
  envSource?: Record<string, string | undefined>,
1709
+ configFlag?: string,
1796
1710
  ): string {
1797
1711
  const lines: string[] = []
1798
1712
  for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`)
@@ -1801,6 +1715,7 @@ function formatHumanValidationError(
1801
1715
  lines.push(
1802
1716
  Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
1803
1717
  alias: command.alias as Record<string, string> | undefined,
1718
+ configFlag,
1804
1719
  description: command.description,
1805
1720
  args: command.args,
1806
1721
  env: command.env,
@@ -1910,6 +1825,20 @@ declare namespace serveImpl {
1910
1825
  type Options = serve.Options & {
1911
1826
  /** Alternative binary names for this CLI. */
1912
1827
  aliases?: string[] | undefined
1828
+ config?:
1829
+ | {
1830
+ flag?: string | undefined
1831
+ files?: string[] | undefined
1832
+ loader?:
1833
+ | ((
1834
+ path: string | undefined,
1835
+ ) =>
1836
+ | Record<string, unknown>
1837
+ | undefined
1838
+ | Promise<Record<string, unknown> | undefined>)
1839
+ | undefined
1840
+ }
1841
+ | undefined
1913
1842
  description?: string | undefined
1914
1843
  /** CLI-level env schema. Parsed before middleware runs. */
1915
1844
  envSchema?: z.ZodObject<any> | undefined
@@ -1944,7 +1873,7 @@ declare namespace serveImpl {
1944
1873
  }
1945
1874
 
1946
1875
  /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
1947
- function extractBuiltinFlags(argv: string[]) {
1876
+ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Options = {}) {
1948
1877
  let verbose = false
1949
1878
  let llms = false
1950
1879
  let llmsFull = false
@@ -1954,12 +1883,18 @@ function extractBuiltinFlags(argv: string[]) {
1954
1883
  let schema = false
1955
1884
  let format: Formatter.Format = 'toon'
1956
1885
  let formatExplicit = false
1886
+ let configPath: string | undefined
1887
+ let configDisabled = false
1957
1888
  let filterOutput: string | undefined
1958
1889
  let tokenLimit: number | undefined
1959
1890
  let tokenOffset: number | undefined
1960
1891
  let tokenCount = false
1961
1892
  const rest: string[] = []
1962
1893
 
1894
+ const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined
1895
+ const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined
1896
+ const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined
1897
+
1963
1898
  for (let i = 0; i < argv.length; i++) {
1964
1899
  const token = argv[i]!
1965
1900
  if (token === '--verbose') verbose = true
@@ -1976,6 +1911,22 @@ function extractBuiltinFlags(argv: string[]) {
1976
1911
  format = argv[i + 1] as Formatter.Format
1977
1912
  formatExplicit = true
1978
1913
  i++
1914
+ } else if (cfgFlag && token === cfgFlag) {
1915
+ const value = argv[i + 1]
1916
+ if (value === undefined)
1917
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
1918
+ configPath = value
1919
+ configDisabled = false
1920
+ i++
1921
+ } else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
1922
+ const value = token.slice(cfgFlagEq.length)
1923
+ if (value.length === 0)
1924
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
1925
+ configPath = value
1926
+ configDisabled = false
1927
+ } else if (noCfgFlag && token === noCfgFlag) {
1928
+ configPath = undefined
1929
+ configDisabled = true
1979
1930
  } else if (token === '--filter-output' && argv[i + 1]) {
1980
1931
  filterOutput = argv[i + 1]!
1981
1932
  i++
@@ -1993,6 +1944,8 @@ function extractBuiltinFlags(argv: string[]) {
1993
1944
  verbose,
1994
1945
  format,
1995
1946
  formatExplicit,
1947
+ configPath,
1948
+ configDisabled,
1996
1949
  filterOutput,
1997
1950
  tokenLimit,
1998
1951
  tokenOffset,
@@ -2007,6 +1960,145 @@ function extractBuiltinFlags(argv: string[]) {
2007
1960
  }
2008
1961
  }
2009
1962
 
1963
+ declare namespace extractBuiltinFlags {
1964
+ type Options = {
1965
+ configFlag?: string | undefined
1966
+ }
1967
+ }
1968
+
1969
+ /** @internal Loads config-backed option defaults for the active command. */
1970
+ async function loadCommandOptionDefaults(
1971
+ cli: string,
1972
+ path: string,
1973
+ options: loadCommandOptionDefaults.Options = {},
1974
+ ): Promise<Record<string, unknown> | undefined> {
1975
+ if (options.configDisabled) return undefined
1976
+
1977
+ const { loader } = options
1978
+
1979
+ // Resolve the target file path
1980
+ let targetPath: string | undefined
1981
+ if (options.configPath) {
1982
+ targetPath = resolveConfigPath(options.configPath)
1983
+ } else {
1984
+ const searchPaths = options.files ?? [`${cli}.json`]
1985
+ targetPath = await findFirstExisting(searchPaths)
1986
+ }
1987
+
1988
+ // Load and parse the config
1989
+ let parsed: Record<string, unknown>
1990
+ if (loader) {
1991
+ const result = await loader(targetPath)
1992
+ if (result === undefined) return undefined
1993
+ if (!isRecord(result))
1994
+ throw new ParseError({ message: 'Config loader must return a plain object or undefined' })
1995
+ parsed = result
1996
+ } else {
1997
+ if (!targetPath) return undefined
1998
+ const result = await readJsonConfig(targetPath, !!options.configPath)
1999
+ if (!result) return undefined
2000
+ parsed = result
2001
+ }
2002
+
2003
+ // Extract the command section from the config tree
2004
+ return extractCommandSection(parsed, cli, path)
2005
+ }
2006
+
2007
+ declare namespace loadCommandOptionDefaults {
2008
+ type Options = {
2009
+ configDisabled?: boolean | undefined
2010
+ configPath?: string | undefined
2011
+ files?: string[] | undefined
2012
+ loader?:
2013
+ | ((
2014
+ path: string | undefined,
2015
+ ) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>)
2016
+ | undefined
2017
+ }
2018
+ }
2019
+
2020
+ /** @internal Resolves a config file path, expanding `~` to home dir. */
2021
+ function resolveConfigPath(filePath: string): string {
2022
+ if (filePath.startsWith('~/') || filePath === '~') {
2023
+ return path.join(os.homedir(), filePath.slice(1))
2024
+ }
2025
+ return path.resolve(process.cwd(), filePath)
2026
+ }
2027
+
2028
+ /** @internal Returns the first readable file from a list of paths, or `undefined`. */
2029
+ async function findFirstExisting(paths: string[]): Promise<string | undefined> {
2030
+ for (const p of paths) {
2031
+ const resolved = resolveConfigPath(p)
2032
+ try {
2033
+ await fs.access(resolved, fs.constants.R_OK)
2034
+ return resolved
2035
+ } catch {}
2036
+ }
2037
+ return undefined
2038
+ }
2039
+
2040
+ /** @internal Reads and parses a JSON config file. */
2041
+ async function readJsonConfig(
2042
+ targetPath: string,
2043
+ explicit: boolean,
2044
+ ): Promise<Record<string, unknown> | undefined> {
2045
+ let raw: string
2046
+ try {
2047
+ raw = await fs.readFile(targetPath, 'utf8')
2048
+ } catch (error) {
2049
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
2050
+ if (explicit) throw new ParseError({ message: `Config file not found: ${targetPath}` })
2051
+ return undefined
2052
+ }
2053
+ throw error
2054
+ }
2055
+
2056
+ let parsed: unknown
2057
+ try {
2058
+ parsed = JSON.parse(raw)
2059
+ } catch (error) {
2060
+ throw new ParseError({
2061
+ message: `Invalid JSON config file: ${targetPath}`,
2062
+ cause: error instanceof Error ? error : undefined,
2063
+ })
2064
+ }
2065
+
2066
+ if (!isRecord(parsed))
2067
+ throw new ParseError({
2068
+ message: `Invalid config file: expected a top-level object in ${targetPath}`,
2069
+ })
2070
+ return parsed
2071
+ }
2072
+
2073
+ /** @internal Walks the nested config tree to extract option defaults for a command path. */
2074
+ function extractCommandSection(
2075
+ parsed: Record<string, unknown>,
2076
+ cli: string,
2077
+ path: string,
2078
+ ): Record<string, unknown> | undefined {
2079
+ const segments = path === cli ? [] : path.split(' ')
2080
+ let node: unknown = parsed
2081
+ for (const seg of segments) {
2082
+ if (!isRecord(node)) return undefined
2083
+ const commands = node.commands
2084
+ if (!isRecord(commands)) return undefined
2085
+ node = commands[seg]
2086
+ if (node === undefined) return undefined
2087
+ }
2088
+ if (!isRecord(node))
2089
+ throw new ParseError({
2090
+ message: `Invalid config section for '${path}': expected an object`,
2091
+ })
2092
+
2093
+ const options = node.options
2094
+ if (options === undefined) return undefined
2095
+ if (!isRecord(options))
2096
+ throw new ParseError({
2097
+ message: `Invalid config 'options' for '${path}': expected an object`,
2098
+ })
2099
+ return Object.keys(options).length > 0 ? options : undefined
2100
+ }
2101
+
2010
2102
  /** @internal Collects immediate child commands/groups for help output. */
2011
2103
  function collectHelpCommands(
2012
2104
  commands: Map<string, CommandEntry>,
@@ -2018,6 +2110,29 @@ function collectHelpCommands(
2018
2110
  return result.sort((a, b) => a.name.localeCompare(b.name))
2019
2111
  }
2020
2112
 
2113
+ /** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */
2114
+ function formatBuiltinHelp(cli: string, builtin: (typeof builtinCommands)[number]): string {
2115
+ return Help.formatRoot(`${cli} ${builtin.name}`, {
2116
+ description: builtin.description,
2117
+ commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })),
2118
+ })
2119
+ }
2120
+
2121
+ /** @internal Formats subcommand-level help for a built-in command (e.g. `cli skills add --help`). */
2122
+ function formatBuiltinSubcommandHelp(
2123
+ cli: string,
2124
+ builtin: (typeof builtinCommands)[number],
2125
+ subName: string,
2126
+ ): string {
2127
+ const sub = builtin.subcommands?.find((s) => s.name === subName)
2128
+ return Help.formatCommand(`${cli} ${builtin.name} ${subName}`, {
2129
+ alias: sub?.alias,
2130
+ description: sub?.description,
2131
+ hideGlobalOptions: true,
2132
+ options: sub?.options,
2133
+ })
2134
+ }
2135
+
2021
2136
  /** @internal Formats help text for a fetch gateway command. */
2022
2137
  function formatFetchHelp(name: string, description?: string): string {
2023
2138
  const lines: string[] = []
@@ -2087,7 +2202,13 @@ export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
2087
2202
  const toMiddlewares = new WeakMap<Cli, MiddlewareHandler[]>()
2088
2203
 
2089
2204
  /** @internal Maps root CLI instances to their command definitions. */
2090
- const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2205
+ export const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2206
+
2207
+ /** @internal Maps CLI instances to their root options schema. */
2208
+ export const toRootOptions = new WeakMap<Cli, z.ZodObject<any>>()
2209
+
2210
+ /** @internal Maps CLI instances to whether config file loading is enabled. */
2211
+ export const toConfigEnabled = new WeakMap<Cli, boolean>()
2091
2212
 
2092
2213
  /** @internal Maps CLI instances to their output policy. */
2093
2214
  const toOutputPolicy = new WeakMap<Cli, OutputPolicy>()
@@ -2155,16 +2276,6 @@ function isSentinel(value: unknown): value is OkResult | ErrorResult {
2155
2276
  return typeof value === 'object' && value !== null && sentinel in value
2156
2277
  }
2157
2278
 
2158
- /** @internal Type guard for async generators returned by streaming `run` handlers. */
2159
- function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
2160
- return (
2161
- typeof value === 'object' &&
2162
- value !== null &&
2163
- Symbol.asyncIterator in value &&
2164
- typeof (value as any).next === 'function'
2165
- )
2166
- }
2167
-
2168
2279
  /** @internal Handles streaming output from an async generator `run` handler. */
2169
2280
  async function handleStreaming(
2170
2281
  generator: AsyncGenerator<unknown, unknown, unknown>,
@@ -2649,15 +2760,9 @@ type CommandDefinition<
2649
2760
  output extends z.ZodType | undefined = undefined,
2650
2761
  vars extends z.ZodObject<any> | undefined = undefined,
2651
2762
  cliEnv extends z.ZodObject<any> | undefined = undefined,
2652
- > = {
2653
- /** Map of option names to single-char aliases. */
2654
- alias?: options extends z.ZodObject<any>
2655
- ? Partial<Record<keyof z.output<options>, string>>
2656
- : Record<string, string> | undefined
2763
+ > = CommandMeta<options> & {
2657
2764
  /** Zod schema for positional arguments. */
2658
2765
  args?: args | undefined
2659
- /** A short description of what the command does. */
2660
- description?: string | undefined
2661
2766
  /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
2662
2767
  env?: env | undefined
2663
2768
  /** Usage examples for this command. */
@@ -2666,8 +2771,6 @@ type CommandDefinition<
2666
2771
  format?: Formatter.Format | undefined
2667
2772
  /** Plain text hint displayed after examples and before global options. */
2668
2773
  hint?: string | undefined
2669
- /** Zod schema for named options/flags. */
2670
- options?: options | undefined
2671
2774
  /** Zod schema for the command's return value. */
2672
2775
  output?: output | undefined
2673
2776
  /**