incur 0.3.5 → 0.3.7

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 (51) hide show
  1. package/README.md +61 -0
  2. package/dist/Cli.d.ts +15 -0
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +300 -25
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Filter.js +0 -18
  7. package/dist/Filter.js.map +1 -1
  8. package/dist/Help.d.ts +4 -0
  9. package/dist/Help.d.ts.map +1 -1
  10. package/dist/Help.js +17 -14
  11. package/dist/Help.js.map +1 -1
  12. package/dist/Parser.d.ts +2 -0
  13. package/dist/Parser.d.ts.map +1 -1
  14. package/dist/Parser.js +69 -37
  15. package/dist/Parser.js.map +1 -1
  16. package/dist/bin.d.ts +1 -0
  17. package/dist/bin.d.ts.map +1 -1
  18. package/dist/bin.js +17 -2
  19. package/dist/bin.js.map +1 -1
  20. package/dist/internal/command.d.ts +2 -0
  21. package/dist/internal/command.d.ts.map +1 -1
  22. package/dist/internal/command.js +1 -0
  23. package/dist/internal/command.js.map +1 -1
  24. package/dist/internal/configSchema.d.ts +8 -0
  25. package/dist/internal/configSchema.d.ts.map +1 -0
  26. package/dist/internal/configSchema.js +57 -0
  27. package/dist/internal/configSchema.js.map +1 -0
  28. package/dist/internal/helpers.d.ts +9 -0
  29. package/dist/internal/helpers.d.ts.map +1 -0
  30. package/dist/internal/helpers.js +39 -0
  31. package/dist/internal/helpers.js.map +1 -0
  32. package/examples/npm/.npmrc.json +21 -0
  33. package/examples/npm/config.schema.json +137 -0
  34. package/package.json +1 -1
  35. package/src/Cli.test-d.ts +39 -0
  36. package/src/Cli.test.ts +714 -25
  37. package/src/Cli.ts +353 -27
  38. package/src/Filter.ts +0 -17
  39. package/src/Help.test.ts +66 -0
  40. package/src/Help.ts +20 -13
  41. package/src/Openapi.test.ts +6 -1
  42. package/src/Parser.test-d.ts +22 -0
  43. package/src/Parser.test.ts +89 -0
  44. package/src/Parser.ts +86 -35
  45. package/src/bin.ts +21 -2
  46. package/src/e2e.test.ts +22 -19
  47. package/src/internal/command.ts +3 -0
  48. package/src/internal/configSchema.test.ts +193 -0
  49. package/src/internal/configSchema.ts +66 -0
  50. package/src/internal/helpers.test.ts +54 -0
  51. package/src/internal/helpers.ts +41 -0
package/src/Cli.ts CHANGED
@@ -1,15 +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'
11
14
  import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js'
12
15
  import * as Command from './internal/command.js'
16
+ import { isRecord, suggest } from './internal/helpers.js'
13
17
  import { detectRunner } from './internal/pm.js'
14
18
  import type { OneOf } from './internal/types.js'
15
19
  import * as Mcp from './Mcp.js'
@@ -274,6 +278,7 @@ export function create(
274
278
  return serveImpl(name, commands, argv, {
275
279
  ...serveOptions,
276
280
  aliases: def.aliases,
281
+ config: def.config,
277
282
  description: def.description,
278
283
  envSchema: def.env,
279
284
  format: def.format,
@@ -295,6 +300,8 @@ export function create(
295
300
  }
296
301
 
297
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)
298
305
  if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy)
299
306
  toMiddlewares.set(cli, middlewares)
300
307
  toCommands.set(cli, commands)
@@ -318,6 +325,24 @@ export declare namespace create {
318
325
  aliases?: string[] | undefined
319
326
  /** Zod schema for positional arguments. */
320
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
321
346
  /** A short description of what the CLI does. */
322
347
  description?: string | undefined
323
348
  /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
@@ -427,6 +452,24 @@ async function serveImpl(
427
452
  ) {
428
453
  const stdout = options.stdout ?? ((s: string) => process.stdout.write(s))
429
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
+ }
430
473
 
431
474
  const {
432
475
  verbose,
@@ -442,8 +485,10 @@ async function serveImpl(
442
485
  help,
443
486
  version,
444
487
  schema,
488
+ configPath,
489
+ configDisabled,
445
490
  rest: filtered,
446
- } = extractBuiltinFlags(argv)
491
+ } = builtinFlags
447
492
 
448
493
  // --mcp: start as MCP stdio server
449
494
  if (mcpFlag) {
@@ -495,14 +540,8 @@ async function serveImpl(
495
540
  return
496
541
  }
497
542
 
498
- // Human mode: stdout is a TTY.
499
- const human = process.stdout.isTTY === true
500
-
501
- function writeln(s: string) {
502
- stdout(s.endsWith('\n') ? s : `${s}\n`)
503
- }
504
-
505
543
  // Skills staleness check (skip for built-in commands)
544
+ let skillsCta: FormattedCtaBlock | undefined
506
545
  if (!llms && !llmsFull && !schema && !help && !version) {
507
546
  const isSkillsAdd =
508
547
  filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
@@ -515,9 +554,10 @@ async function serveImpl(
515
554
  if (Skill.hash(entries) !== stored) {
516
555
  const runner = detectRunner()
517
556
  const spec = SyncMcp.detectPackageSpecifier(name)
518
- process.stderr.write(
519
- `⚠ Skills are out of date. Run '${runner} ${spec} skills add' to update.\n\n`,
520
- )
557
+ skillsCta = {
558
+ description: 'Skills are out of date:',
559
+ commands: [{ command: `${runner} ${spec} skills add`, description: 'sync outdated skills' }],
560
+ }
521
561
  }
522
562
  }
523
563
  }
@@ -608,7 +648,26 @@ async function serveImpl(
608
648
  const skillsIdx =
609
649
  filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
610
650
  if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
611
- if (filtered[skillsIdx + 1] !== 'add') {
651
+ const skillsSub = filtered[skillsIdx + 1]
652
+ if (skillsSub && skillsSub !== 'add') {
653
+ const suggestion = suggest(skillsSub, ['add'])
654
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
655
+ const message = `'${skillsSub}' is not a command for '${name} skills'.${didYouMean}`
656
+ const ctaCommands: FormattedCta[] = []
657
+ if (suggestion) {
658
+ const corrected = argv.map((t) => (t === skillsSub ? suggestion : t))
659
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
660
+ }
661
+ ctaCommands.push({ command: `${name} skills --help`, description: 'see all available commands' })
662
+ const cta: FormattedCtaBlock = { description: 'Next steps:', commands: ctaCommands }
663
+ if (human) {
664
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
665
+ writeln(formatHumanCta(cta))
666
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
667
+ exit(1)
668
+ return
669
+ }
670
+ if (!skillsSub) {
612
671
  const b = builtinCommands.find((c) => c.name === 'skills')!
613
672
  writeln(formatBuiltinHelp(name, b))
614
673
  return
@@ -680,7 +739,26 @@ async function serveImpl(
680
739
  // mcp add: register CLI as MCP server via `npx add-mcp`
681
740
  const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
682
741
  if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
683
- if (filtered[mcpIdx + 1] !== 'add') {
742
+ const mcpSub = filtered[mcpIdx + 1]
743
+ if (mcpSub && mcpSub !== 'add') {
744
+ const suggestion = suggest(mcpSub, ['add'])
745
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
746
+ const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`
747
+ const ctaCommands: FormattedCta[] = []
748
+ if (suggestion) {
749
+ const corrected = argv.map((t) => (t === mcpSub ? suggestion : t))
750
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
751
+ }
752
+ ctaCommands.push({ command: `${name} mcp --help`, description: 'see all available commands' })
753
+ const cta: FormattedCtaBlock = { description: 'Next steps:', commands: ctaCommands }
754
+ if (human) {
755
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
756
+ writeln(formatHumanCta(cta))
757
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
758
+ exit(1)
759
+ return
760
+ }
761
+ if (!mcpSub) {
684
762
  const b = builtinCommands.find((c) => c.name === 'mcp')!
685
763
  writeln(formatBuiltinHelp(name, b))
686
764
  return
@@ -759,6 +837,7 @@ async function serveImpl(
759
837
  Help.formatCommand(name, {
760
838
  alias: cmd.alias as Record<string, string> | undefined,
761
839
  aliases: options.aliases,
840
+ configFlag,
762
841
  description: cmd.description ?? options.description,
763
842
  version: options.version,
764
843
  args: cmd.args,
@@ -780,6 +859,7 @@ async function serveImpl(
780
859
  writeln(
781
860
  Help.formatRoot(name, {
782
861
  aliases: options.aliases,
862
+ configFlag,
783
863
  description: options.description,
784
864
  version: options.version,
785
865
  commands: collectHelpCommands(commands),
@@ -828,6 +908,7 @@ async function serveImpl(
828
908
  Help.formatCommand(name, {
829
909
  alias: cmd.alias as Record<string, string> | undefined,
830
910
  aliases: options.aliases,
911
+ configFlag,
831
912
  description: cmd.description ?? options.description,
832
913
  version: options.version,
833
914
  args: cmd.args,
@@ -845,6 +926,7 @@ async function serveImpl(
845
926
  writeln(
846
927
  Help.formatRoot(helpName, {
847
928
  aliases: isRoot ? options.aliases : undefined,
929
+ configFlag,
848
930
  description: helpDesc,
849
931
  version: isRoot ? options.version : undefined,
850
932
  commands: collectHelpCommands(helpCmds),
@@ -864,6 +946,7 @@ async function serveImpl(
864
946
  Help.formatCommand(commandName, {
865
947
  alias: cmd.alias as Record<string, string> | undefined,
866
948
  aliases: isRootCmd ? options.aliases : undefined,
949
+ configFlag,
867
950
  description: cmd.description,
868
951
  version: isRootCmd ? options.version : undefined,
869
952
  args: cmd.args,
@@ -886,6 +969,7 @@ async function serveImpl(
886
969
  if ('help' in resolved) {
887
970
  writeln(
888
971
  Help.formatRoot(`${name} ${resolved.path}`, {
972
+ configFlag,
889
973
  description: resolved.description,
890
974
  commands: collectHelpCommands(resolved.commands),
891
975
  }),
@@ -894,7 +978,9 @@ async function serveImpl(
894
978
  }
895
979
  if ('error' in resolved) {
896
980
  const parent = resolved.path ? `${name} ${resolved.path}` : name
897
- writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`)
981
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
982
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
983
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.${didYouMean}`)
898
984
  exit(1)
899
985
  return
900
986
  }
@@ -917,6 +1003,7 @@ async function serveImpl(
917
1003
  if ('help' in resolved) {
918
1004
  writeln(
919
1005
  Help.formatRoot(`${name} ${resolved.path}`, {
1006
+ configFlag,
920
1007
  description: resolved.description,
921
1008
  commands: collectHelpCommands(resolved.commands),
922
1009
  }),
@@ -977,6 +1064,21 @@ async function serveImpl(
977
1064
  function write(output: Output) {
978
1065
  if (filterPaths && output.ok && output.data != null)
979
1066
  output = { ...output, data: Filter.apply(output.data, filterPaths) }
1067
+ if (skillsCta) {
1068
+ const existing = output.meta.cta
1069
+ output = {
1070
+ ...output,
1071
+ meta: {
1072
+ ...output.meta,
1073
+ cta: existing
1074
+ ? {
1075
+ description: existing.description,
1076
+ commands: [...existing.commands, ...skillsCta.commands],
1077
+ }
1078
+ : skillsCta,
1079
+ },
1080
+ }
1081
+ }
980
1082
  if (tokenCount) {
981
1083
  const base = output.ok ? output.data : output.error
982
1084
  const formatted = base != null ? Formatter.format(base, format) : ''
@@ -1027,14 +1129,24 @@ async function serveImpl(
1027
1129
  if ('error' in effective) {
1028
1130
  const helpCmd = effective.path ? `${name} ${effective.path} --help` : `${name} --help`
1029
1131
  const parent = effective.path ? `${name} ${effective.path}` : name
1030
- const message = `'${effective.error}' is not a command for '${parent}'.`
1031
- const cta: FormattedCtaBlock = {
1032
- description: 'See available commands:',
1033
- commands: [{ command: helpCmd }],
1132
+ const candidates = 'commands' in effective ? [...effective.commands.keys()] : []
1133
+ if (!effective.path) for (const b of builtinCommands) candidates.push(b.name)
1134
+ const suggestion = suggest(effective.error, candidates)
1135
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1136
+ const message = `'${effective.error}' is not a command for '${parent}'.${didYouMean}`
1137
+ const ctaCommands: FormattedCta[] = []
1138
+ if (suggestion) {
1139
+ const corrected = argv.map((t) => (t === effective.error ? suggestion : t))
1140
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
1034
1141
  }
1142
+ ctaCommands.push({ command: helpCmd, description: 'see all available commands' })
1143
+ const cta: FormattedCtaBlock = { description: 'Next steps:', commands: ctaCommands }
1035
1144
  if (human && !verbose) {
1036
1145
  writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
1037
- writeln(formatHumanCta(cta))
1146
+ const mergedCta = skillsCta
1147
+ ? { ...cta, commands: [...cta.commands, ...skillsCta.commands] }
1148
+ : cta
1149
+ writeln(formatHumanCta(mergedCta))
1038
1150
  exit(1)
1039
1151
  return
1040
1152
  }
@@ -1205,9 +1317,33 @@ async function serveImpl(
1205
1317
  command.alias as Record<string, string> | undefined,
1206
1318
  )
1207
1319
 
1320
+ let defaults: Record<string, unknown> | undefined
1321
+ if (configEnabled) {
1322
+ try {
1323
+ defaults = await loadCommandOptionDefaults(name, path, {
1324
+ configDisabled,
1325
+ configPath,
1326
+ files: options.config?.files,
1327
+ loader: options.config?.loader,
1328
+ })
1329
+ } catch (error) {
1330
+ write({
1331
+ ok: false,
1332
+ error: {
1333
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1334
+ message: error instanceof Error ? error.message : String(error),
1335
+ },
1336
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1337
+ })
1338
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1339
+ return
1340
+ }
1341
+ }
1342
+
1208
1343
  const result = await Command.execute(command, {
1209
1344
  agent: !human,
1210
1345
  argv: rest,
1346
+ defaults,
1211
1347
  env: options.envSchema,
1212
1348
  envSource: options.env,
1213
1349
  format,
@@ -1266,6 +1402,7 @@ async function serveImpl(
1266
1402
  fieldErrors: result.error.fieldErrors,
1267
1403
  }),
1268
1404
  options.env,
1405
+ configFlag,
1269
1406
  ),
1270
1407
  )
1271
1408
  exit(1)
@@ -1474,18 +1611,22 @@ async function fetchImpl(
1474
1611
 
1475
1612
  const resolved = resolveCommand(commands, segments)
1476
1613
 
1477
- if ('error' in resolved)
1614
+ if ('error' in resolved) {
1615
+ const parent = resolved.path ? `${name} ${resolved.path}` : name
1616
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
1617
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1478
1618
  return jsonResponse(
1479
1619
  {
1480
1620
  ok: false,
1481
1621
  error: {
1482
1622
  code: 'COMMAND_NOT_FOUND',
1483
- message: `'${resolved.error}' is not a command for '${resolved.path ? `${name} ${resolved.path}` : name}'.`,
1623
+ message: `'${resolved.error}' is not a command for '${parent}'.${didYouMean}`,
1484
1624
  },
1485
1625
  meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` },
1486
1626
  },
1487
1627
  404,
1488
1628
  )
1629
+ }
1489
1630
 
1490
1631
  if ('help' in resolved)
1491
1632
  return jsonResponse(
@@ -1636,6 +1777,7 @@ function formatHumanValidationError(
1636
1777
  command: CommandDefinition<any, any, any>,
1637
1778
  error: ValidationError,
1638
1779
  envSource?: Record<string, string | undefined>,
1780
+ configFlag?: string,
1639
1781
  ): string {
1640
1782
  const lines: string[] = []
1641
1783
  for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`)
@@ -1644,6 +1786,7 @@ function formatHumanValidationError(
1644
1786
  lines.push(
1645
1787
  Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
1646
1788
  alias: command.alias as Record<string, string> | undefined,
1789
+ configFlag,
1647
1790
  description: command.description,
1648
1791
  args: command.args,
1649
1792
  env: command.env,
@@ -1682,10 +1825,10 @@ function resolveCommand(
1682
1825
  description?: string | undefined
1683
1826
  commands: Map<string, CommandEntry>
1684
1827
  }
1685
- | { error: string; path: string } {
1828
+ | { error: string; path: string; commands: Map<string, CommandEntry>; rest: string[] } {
1686
1829
  const [first, ...rest] = tokens
1687
1830
 
1688
- if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '' }
1831
+ if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest }
1689
1832
 
1690
1833
  let entry = commands.get(first)!
1691
1834
  const path = [first]
@@ -1719,7 +1862,7 @@ function resolveCommand(
1719
1862
 
1720
1863
  const child = entry.commands.get(next)
1721
1864
  if (!child) {
1722
- return { error: next, path: path.join(' ') }
1865
+ return { error: next, path: path.join(' '), commands: entry.commands, rest: remaining.slice(1) }
1723
1866
  }
1724
1867
 
1725
1868
  path.push(next)
@@ -1753,6 +1896,20 @@ declare namespace serveImpl {
1753
1896
  type Options = serve.Options & {
1754
1897
  /** Alternative binary names for this CLI. */
1755
1898
  aliases?: string[] | undefined
1899
+ config?:
1900
+ | {
1901
+ flag?: string | undefined
1902
+ files?: string[] | undefined
1903
+ loader?:
1904
+ | ((
1905
+ path: string | undefined,
1906
+ ) =>
1907
+ | Record<string, unknown>
1908
+ | undefined
1909
+ | Promise<Record<string, unknown> | undefined>)
1910
+ | undefined
1911
+ }
1912
+ | undefined
1756
1913
  description?: string | undefined
1757
1914
  /** CLI-level env schema. Parsed before middleware runs. */
1758
1915
  envSchema?: z.ZodObject<any> | undefined
@@ -1787,7 +1944,7 @@ declare namespace serveImpl {
1787
1944
  }
1788
1945
 
1789
1946
  /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
1790
- function extractBuiltinFlags(argv: string[]) {
1947
+ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Options = {}) {
1791
1948
  let verbose = false
1792
1949
  let llms = false
1793
1950
  let llmsFull = false
@@ -1797,12 +1954,18 @@ function extractBuiltinFlags(argv: string[]) {
1797
1954
  let schema = false
1798
1955
  let format: Formatter.Format = 'toon'
1799
1956
  let formatExplicit = false
1957
+ let configPath: string | undefined
1958
+ let configDisabled = false
1800
1959
  let filterOutput: string | undefined
1801
1960
  let tokenLimit: number | undefined
1802
1961
  let tokenOffset: number | undefined
1803
1962
  let tokenCount = false
1804
1963
  const rest: string[] = []
1805
1964
 
1965
+ const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined
1966
+ const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined
1967
+ const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined
1968
+
1806
1969
  for (let i = 0; i < argv.length; i++) {
1807
1970
  const token = argv[i]!
1808
1971
  if (token === '--verbose') verbose = true
@@ -1819,6 +1982,22 @@ function extractBuiltinFlags(argv: string[]) {
1819
1982
  format = argv[i + 1] as Formatter.Format
1820
1983
  formatExplicit = true
1821
1984
  i++
1985
+ } else if (cfgFlag && token === cfgFlag) {
1986
+ const value = argv[i + 1]
1987
+ if (value === undefined)
1988
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
1989
+ configPath = value
1990
+ configDisabled = false
1991
+ i++
1992
+ } else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
1993
+ const value = token.slice(cfgFlagEq.length)
1994
+ if (value.length === 0)
1995
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
1996
+ configPath = value
1997
+ configDisabled = false
1998
+ } else if (noCfgFlag && token === noCfgFlag) {
1999
+ configPath = undefined
2000
+ configDisabled = true
1822
2001
  } else if (token === '--filter-output' && argv[i + 1]) {
1823
2002
  filterOutput = argv[i + 1]!
1824
2003
  i++
@@ -1836,6 +2015,8 @@ function extractBuiltinFlags(argv: string[]) {
1836
2015
  verbose,
1837
2016
  format,
1838
2017
  formatExplicit,
2018
+ configPath,
2019
+ configDisabled,
1839
2020
  filterOutput,
1840
2021
  tokenLimit,
1841
2022
  tokenOffset,
@@ -1850,6 +2031,145 @@ function extractBuiltinFlags(argv: string[]) {
1850
2031
  }
1851
2032
  }
1852
2033
 
2034
+ declare namespace extractBuiltinFlags {
2035
+ type Options = {
2036
+ configFlag?: string | undefined
2037
+ }
2038
+ }
2039
+
2040
+ /** @internal Loads config-backed option defaults for the active command. */
2041
+ async function loadCommandOptionDefaults(
2042
+ cli: string,
2043
+ path: string,
2044
+ options: loadCommandOptionDefaults.Options = {},
2045
+ ): Promise<Record<string, unknown> | undefined> {
2046
+ if (options.configDisabled) return undefined
2047
+
2048
+ const { loader } = options
2049
+
2050
+ // Resolve the target file path
2051
+ let targetPath: string | undefined
2052
+ if (options.configPath) {
2053
+ targetPath = resolveConfigPath(options.configPath)
2054
+ } else {
2055
+ const searchPaths = options.files ?? [`${cli}.json`]
2056
+ targetPath = await findFirstExisting(searchPaths)
2057
+ }
2058
+
2059
+ // Load and parse the config
2060
+ let parsed: Record<string, unknown>
2061
+ if (loader) {
2062
+ const result = await loader(targetPath)
2063
+ if (result === undefined) return undefined
2064
+ if (!isRecord(result))
2065
+ throw new ParseError({ message: 'Config loader must return a plain object or undefined' })
2066
+ parsed = result
2067
+ } else {
2068
+ if (!targetPath) return undefined
2069
+ const result = await readJsonConfig(targetPath, !!options.configPath)
2070
+ if (!result) return undefined
2071
+ parsed = result
2072
+ }
2073
+
2074
+ // Extract the command section from the config tree
2075
+ return extractCommandSection(parsed, cli, path)
2076
+ }
2077
+
2078
+ declare namespace loadCommandOptionDefaults {
2079
+ type Options = {
2080
+ configDisabled?: boolean | undefined
2081
+ configPath?: string | undefined
2082
+ files?: string[] | undefined
2083
+ loader?:
2084
+ | ((
2085
+ path: string | undefined,
2086
+ ) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>)
2087
+ | undefined
2088
+ }
2089
+ }
2090
+
2091
+ /** @internal Resolves a config file path, expanding `~` to home dir. */
2092
+ function resolveConfigPath(filePath: string): string {
2093
+ if (filePath.startsWith('~/') || filePath === '~') {
2094
+ return path.join(os.homedir(), filePath.slice(1))
2095
+ }
2096
+ return path.resolve(process.cwd(), filePath)
2097
+ }
2098
+
2099
+ /** @internal Returns the first readable file from a list of paths, or `undefined`. */
2100
+ async function findFirstExisting(paths: string[]): Promise<string | undefined> {
2101
+ for (const p of paths) {
2102
+ const resolved = resolveConfigPath(p)
2103
+ try {
2104
+ await fs.access(resolved, fs.constants.R_OK)
2105
+ return resolved
2106
+ } catch {}
2107
+ }
2108
+ return undefined
2109
+ }
2110
+
2111
+ /** @internal Reads and parses a JSON config file. */
2112
+ async function readJsonConfig(
2113
+ targetPath: string,
2114
+ explicit: boolean,
2115
+ ): Promise<Record<string, unknown> | undefined> {
2116
+ let raw: string
2117
+ try {
2118
+ raw = await fs.readFile(targetPath, 'utf8')
2119
+ } catch (error) {
2120
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
2121
+ if (explicit) throw new ParseError({ message: `Config file not found: ${targetPath}` })
2122
+ return undefined
2123
+ }
2124
+ throw error
2125
+ }
2126
+
2127
+ let parsed: unknown
2128
+ try {
2129
+ parsed = JSON.parse(raw)
2130
+ } catch (error) {
2131
+ throw new ParseError({
2132
+ message: `Invalid JSON config file: ${targetPath}`,
2133
+ cause: error instanceof Error ? error : undefined,
2134
+ })
2135
+ }
2136
+
2137
+ if (!isRecord(parsed))
2138
+ throw new ParseError({
2139
+ message: `Invalid config file: expected a top-level object in ${targetPath}`,
2140
+ })
2141
+ return parsed
2142
+ }
2143
+
2144
+ /** @internal Walks the nested config tree to extract option defaults for a command path. */
2145
+ function extractCommandSection(
2146
+ parsed: Record<string, unknown>,
2147
+ cli: string,
2148
+ path: string,
2149
+ ): Record<string, unknown> | undefined {
2150
+ const segments = path === cli ? [] : path.split(' ')
2151
+ let node: unknown = parsed
2152
+ for (const seg of segments) {
2153
+ if (!isRecord(node)) return undefined
2154
+ const commands = node.commands
2155
+ if (!isRecord(commands)) return undefined
2156
+ node = commands[seg]
2157
+ if (node === undefined) return undefined
2158
+ }
2159
+ if (!isRecord(node))
2160
+ throw new ParseError({
2161
+ message: `Invalid config section for '${path}': expected an object`,
2162
+ })
2163
+
2164
+ const options = node.options
2165
+ if (options === undefined) return undefined
2166
+ if (!isRecord(options))
2167
+ throw new ParseError({
2168
+ message: `Invalid config 'options' for '${path}': expected an object`,
2169
+ })
2170
+ return Object.keys(options).length > 0 ? options : undefined
2171
+ }
2172
+
1853
2173
  /** @internal Collects immediate child commands/groups for help output. */
1854
2174
  function collectHelpCommands(
1855
2175
  commands: Map<string, CommandEntry>,
@@ -1953,7 +2273,13 @@ export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
1953
2273
  const toMiddlewares = new WeakMap<Cli, MiddlewareHandler[]>()
1954
2274
 
1955
2275
  /** @internal Maps root CLI instances to their command definitions. */
1956
- const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2276
+ export const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2277
+
2278
+ /** @internal Maps CLI instances to their root options schema. */
2279
+ export const toRootOptions = new WeakMap<Cli, z.ZodObject<any>>()
2280
+
2281
+ /** @internal Maps CLI instances to whether config file loading is enabled. */
2282
+ export const toConfigEnabled = new WeakMap<Cli, boolean>()
1957
2283
 
1958
2284
  /** @internal Maps CLI instances to their output policy. */
1959
2285
  const toOutputPolicy = new WeakMap<Cli, OutputPolicy>()
package/src/Filter.ts CHANGED
@@ -80,23 +80,6 @@ export function apply(data: unknown, paths: FilterPath[]): unknown {
80
80
  return result
81
81
  }
82
82
 
83
- function resolve(data: unknown, segments: Segment[], index: number): unknown {
84
- if (index >= segments.length) return data
85
- const segment = segments[index]!
86
-
87
- if ('key' in segment) {
88
- if (typeof data !== 'object' || data === null) return undefined
89
- const val = (data as Record<string, unknown>)[segment.key]
90
- return resolve(val, segments, index + 1)
91
- }
92
-
93
- // slice segment
94
- if (!Array.isArray(data)) return undefined
95
- const sliced = data.slice(segment.start, segment.end)
96
- if (index + 1 >= segments.length) return sliced
97
- return sliced.map((item) => resolve(item, segments, index + 1))
98
- }
99
-
100
83
  function merge(
101
84
  target: Record<string, unknown>,
102
85
  data: unknown,