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/dist/Cli.js CHANGED
@@ -1,12 +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';
8
11
  import { builtinCommands, shells } from './internal/command.js';
9
12
  import * as Command from './internal/command.js';
13
+ import { isRecord, suggest } from './internal/helpers.js';
10
14
  import { detectRunner } from './internal/pm.js';
11
15
  import * as Mcp from './Mcp.js';
12
16
  import * as Openapi from './Openapi.js';
@@ -93,6 +97,7 @@ export function create(nameOrDefinition, definition) {
93
97
  return serveImpl(name, commands, argv, {
94
98
  ...serveOptions,
95
99
  aliases: def.aliases,
100
+ config: def.config,
96
101
  description: def.description,
97
102
  envSchema: def.env,
98
103
  format: def.format,
@@ -113,6 +118,10 @@ export function create(nameOrDefinition, definition) {
113
118
  };
114
119
  if (rootDef)
115
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);
116
125
  if (def.outputPolicy)
117
126
  toOutputPolicy.set(cli, def.outputPolicy);
118
127
  toMiddlewares.set(cli, middlewares);
@@ -124,7 +133,26 @@ export function create(nameOrDefinition, definition) {
124
133
  async function serveImpl(name, commands, argv, options = {}) {
125
134
  const stdout = options.stdout ?? ((s) => process.stdout.write(s));
126
135
  const exit = options.exit ?? ((code) => process.exit(code));
127
- 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;
128
156
  // --mcp: start as MCP stdio server
129
157
  if (mcpFlag) {
130
158
  await Mcp.serve(name, options.version ?? '0.0.0', commands, {
@@ -176,12 +204,8 @@ async function serveImpl(name, commands, argv, options = {}) {
176
204
  }
177
205
  return;
178
206
  }
179
- // Human mode: stdout is a TTY.
180
- const human = process.stdout.isTTY === true;
181
- function writeln(s) {
182
- stdout(s.endsWith('\n') ? s : `${s}\n`);
183
- }
184
207
  // Skills staleness check (skip for built-in commands)
208
+ let skillsCta;
185
209
  if (!llms && !llmsFull && !schema && !help && !version) {
186
210
  const isSkillsAdd = filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills');
187
211
  const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp');
@@ -193,7 +217,10 @@ async function serveImpl(name, commands, argv, options = {}) {
193
217
  if (Skill.hash(entries) !== stored) {
194
218
  const runner = detectRunner();
195
219
  const spec = SyncMcp.detectPackageSpecifier(name);
196
- process.stderr.write(`⚠ Skills are out of date. Run '${runner} ${spec} skills add' to update.\n\n`);
220
+ skillsCta = {
221
+ description: 'Skills are out of date:',
222
+ commands: [{ command: `${runner} ${spec} skills add`, description: 'sync outdated skills' }],
223
+ };
197
224
  }
198
225
  }
199
226
  }
@@ -278,7 +305,28 @@ async function serveImpl(name, commands, argv, options = {}) {
278
305
  // skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
279
306
  const skillsIdx = filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1;
280
307
  if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
281
- if (filtered[skillsIdx + 1] !== 'add') {
308
+ const skillsSub = filtered[skillsIdx + 1];
309
+ if (skillsSub && skillsSub !== 'add') {
310
+ const suggestion = suggest(skillsSub, ['add']);
311
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
312
+ const message = `'${skillsSub}' is not a command for '${name} skills'.${didYouMean}`;
313
+ const ctaCommands = [];
314
+ if (suggestion) {
315
+ const corrected = argv.map((t) => (t === skillsSub ? suggestion : t));
316
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` });
317
+ }
318
+ ctaCommands.push({ command: `${name} skills --help`, description: 'see all available commands' });
319
+ const cta = { description: 'Next steps:', commands: ctaCommands };
320
+ if (human) {
321
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }));
322
+ writeln(formatHumanCta(cta));
323
+ }
324
+ else
325
+ writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'));
326
+ exit(1);
327
+ return;
328
+ }
329
+ if (!skillsSub) {
282
330
  const b = builtinCommands.find((c) => c.name === 'skills');
283
331
  writeln(formatBuiltinHelp(name, b));
284
332
  return;
@@ -345,7 +393,28 @@ async function serveImpl(name, commands, argv, options = {}) {
345
393
  // mcp add: register CLI as MCP server via `npx add-mcp`
346
394
  const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1;
347
395
  if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
348
- if (filtered[mcpIdx + 1] !== 'add') {
396
+ const mcpSub = filtered[mcpIdx + 1];
397
+ if (mcpSub && mcpSub !== 'add') {
398
+ const suggestion = suggest(mcpSub, ['add']);
399
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
400
+ const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`;
401
+ const ctaCommands = [];
402
+ if (suggestion) {
403
+ const corrected = argv.map((t) => (t === mcpSub ? suggestion : t));
404
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` });
405
+ }
406
+ ctaCommands.push({ command: `${name} mcp --help`, description: 'see all available commands' });
407
+ const cta = { description: 'Next steps:', commands: ctaCommands };
408
+ if (human) {
409
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }));
410
+ writeln(formatHumanCta(cta));
411
+ }
412
+ else
413
+ writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'));
414
+ exit(1);
415
+ return;
416
+ }
417
+ if (!mcpSub) {
349
418
  const b = builtinCommands.find((c) => c.name === 'mcp');
350
419
  writeln(formatBuiltinHelp(name, b));
351
420
  return;
@@ -412,6 +481,7 @@ async function serveImpl(name, commands, argv, options = {}) {
412
481
  writeln(Help.formatCommand(name, {
413
482
  alias: cmd.alias,
414
483
  aliases: options.aliases,
484
+ configFlag,
415
485
  description: cmd.description ?? options.description,
416
486
  version: options.version,
417
487
  args: cmd.args,
@@ -432,6 +502,7 @@ async function serveImpl(name, commands, argv, options = {}) {
432
502
  else {
433
503
  writeln(Help.formatRoot(name, {
434
504
  aliases: options.aliases,
505
+ configFlag,
435
506
  description: options.description,
436
507
  version: options.version,
437
508
  commands: collectHelpCommands(commands),
@@ -474,6 +545,7 @@ async function serveImpl(name, commands, argv, options = {}) {
474
545
  writeln(Help.formatCommand(name, {
475
546
  alias: cmd.alias,
476
547
  aliases: options.aliases,
548
+ configFlag,
477
549
  description: cmd.description ?? options.description,
478
550
  version: options.version,
479
551
  args: cmd.args,
@@ -490,6 +562,7 @@ async function serveImpl(name, commands, argv, options = {}) {
490
562
  else {
491
563
  writeln(Help.formatRoot(helpName, {
492
564
  aliases: isRoot ? options.aliases : undefined,
565
+ configFlag,
493
566
  description: helpDesc,
494
567
  version: isRoot ? options.version : undefined,
495
568
  commands: collectHelpCommands(helpCmds),
@@ -507,6 +580,7 @@ async function serveImpl(name, commands, argv, options = {}) {
507
580
  writeln(Help.formatCommand(commandName, {
508
581
  alias: cmd.alias,
509
582
  aliases: isRootCmd ? options.aliases : undefined,
583
+ configFlag,
510
584
  description: cmd.description,
511
585
  version: isRootCmd ? options.version : undefined,
512
586
  args: cmd.args,
@@ -526,6 +600,7 @@ async function serveImpl(name, commands, argv, options = {}) {
526
600
  if (schema) {
527
601
  if ('help' in resolved) {
528
602
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
603
+ configFlag,
529
604
  description: resolved.description,
530
605
  commands: collectHelpCommands(resolved.commands),
531
606
  }));
@@ -533,7 +608,9 @@ async function serveImpl(name, commands, argv, options = {}) {
533
608
  }
534
609
  if ('error' in resolved) {
535
610
  const parent = resolved.path ? `${name} ${resolved.path}` : name;
536
- writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`);
611
+ const suggestion = suggest(resolved.error, resolved.commands.keys());
612
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
613
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.${didYouMean}`);
537
614
  exit(1);
538
615
  return;
539
616
  }
@@ -558,6 +635,7 @@ async function serveImpl(name, commands, argv, options = {}) {
558
635
  }
559
636
  if ('help' in resolved) {
560
637
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
638
+ configFlag,
561
639
  description: resolved.description,
562
640
  commands: collectHelpCommands(resolved.commands),
563
641
  }));
@@ -606,6 +684,21 @@ async function serveImpl(name, commands, argv, options = {}) {
606
684
  function write(output) {
607
685
  if (filterPaths && output.ok && output.data != null)
608
686
  output = { ...output, data: Filter.apply(output.data, filterPaths) };
687
+ if (skillsCta) {
688
+ const existing = output.meta.cta;
689
+ output = {
690
+ ...output,
691
+ meta: {
692
+ ...output.meta,
693
+ cta: existing
694
+ ? {
695
+ description: existing.description,
696
+ commands: [...existing.commands, ...skillsCta.commands],
697
+ }
698
+ : skillsCta,
699
+ },
700
+ };
701
+ }
609
702
  if (tokenCount) {
610
703
  const base = output.ok ? output.data : output.error;
611
704
  const formatted = base != null ? Formatter.format(base, format) : '';
@@ -658,14 +751,26 @@ async function serveImpl(name, commands, argv, options = {}) {
658
751
  if ('error' in effective) {
659
752
  const helpCmd = effective.path ? `${name} ${effective.path} --help` : `${name} --help`;
660
753
  const parent = effective.path ? `${name} ${effective.path}` : name;
661
- const message = `'${effective.error}' is not a command for '${parent}'.`;
662
- const cta = {
663
- description: 'See available commands:',
664
- commands: [{ command: helpCmd }],
665
- };
754
+ const candidates = 'commands' in effective ? [...effective.commands.keys()] : [];
755
+ if (!effective.path)
756
+ for (const b of builtinCommands)
757
+ candidates.push(b.name);
758
+ const suggestion = suggest(effective.error, candidates);
759
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
760
+ const message = `'${effective.error}' is not a command for '${parent}'.${didYouMean}`;
761
+ const ctaCommands = [];
762
+ if (suggestion) {
763
+ const corrected = argv.map((t) => (t === effective.error ? suggestion : t));
764
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` });
765
+ }
766
+ ctaCommands.push({ command: helpCmd, description: 'see all available commands' });
767
+ const cta = { description: 'Next steps:', commands: ctaCommands };
666
768
  if (human && !verbose) {
667
769
  writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }));
668
- writeln(formatHumanCta(cta));
770
+ const mergedCta = skillsCta
771
+ ? { ...cta, commands: [...cta.commands, ...skillsCta.commands] }
772
+ : cta;
773
+ writeln(formatHumanCta(mergedCta));
669
774
  exit(1);
670
775
  return;
671
776
  }
@@ -817,9 +922,33 @@ async function serveImpl(name, commands, argv, options = {}) {
817
922
  ];
818
923
  if (human)
819
924
  emitDeprecationWarnings(rest, command.options, command.alias);
925
+ let defaults;
926
+ if (configEnabled) {
927
+ try {
928
+ defaults = await loadCommandOptionDefaults(name, path, {
929
+ configDisabled,
930
+ configPath,
931
+ files: options.config?.files,
932
+ loader: options.config?.loader,
933
+ });
934
+ }
935
+ catch (error) {
936
+ write({
937
+ ok: false,
938
+ error: {
939
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
940
+ message: error instanceof Error ? error.message : String(error),
941
+ },
942
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
943
+ });
944
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
945
+ return;
946
+ }
947
+ }
820
948
  const result = await Command.execute(command, {
821
949
  agent: !human,
822
950
  argv: rest,
951
+ defaults,
823
952
  env: options.envSchema,
824
953
  envSource: options.env,
825
954
  format,
@@ -868,7 +997,7 @@ async function serveImpl(name, commands, argv, options = {}) {
868
997
  writeln(formatHumanValidationError(name, path, command, new ValidationError({
869
998
  message: result.error.message,
870
999
  fieldErrors: result.error.fieldErrors,
871
- }), options.env));
1000
+ }), options.env, configFlag));
872
1001
  exit(1);
873
1002
  return;
874
1003
  }
@@ -1008,15 +1137,19 @@ async function fetchImpl(name, commands, req, options = {}) {
1008
1137
  }, 404);
1009
1138
  }
1010
1139
  const resolved = resolveCommand(commands, segments);
1011
- if ('error' in resolved)
1140
+ if ('error' in resolved) {
1141
+ const parent = resolved.path ? `${name} ${resolved.path}` : name;
1142
+ const suggestion = suggest(resolved.error, resolved.commands.keys());
1143
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '';
1012
1144
  return jsonResponse({
1013
1145
  ok: false,
1014
1146
  error: {
1015
1147
  code: 'COMMAND_NOT_FOUND',
1016
- message: `'${resolved.error}' is not a command for '${resolved.path ? `${name} ${resolved.path}` : name}'.`,
1148
+ message: `'${resolved.error}' is not a command for '${parent}'.${didYouMean}`,
1017
1149
  },
1018
1150
  meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` },
1019
1151
  }, 404);
1152
+ }
1020
1153
  if ('help' in resolved)
1021
1154
  return jsonResponse({
1022
1155
  ok: false,
@@ -1126,7 +1259,7 @@ async function executeCommand(path, command, rest, inputOptions, start, options)
1126
1259
  }, 200);
1127
1260
  }
1128
1261
  /** @internal Formats a validation error for TTY with usage hint. */
1129
- function formatHumanValidationError(cli, path, command, error, envSource) {
1262
+ function formatHumanValidationError(cli, path, command, error, envSource, configFlag) {
1130
1263
  const lines = [];
1131
1264
  for (const fe of error.fieldErrors)
1132
1265
  lines.push(`Error: missing required argument <${fe.path}>`);
@@ -1134,6 +1267,7 @@ function formatHumanValidationError(cli, path, command, error, envSource) {
1134
1267
  lines.push('');
1135
1268
  lines.push(Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
1136
1269
  alias: command.alias,
1270
+ configFlag,
1137
1271
  description: command.description,
1138
1272
  args: command.args,
1139
1273
  env: command.env,
@@ -1149,7 +1283,7 @@ function formatHumanValidationError(cli, path, command, error, envSource) {
1149
1283
  function resolveCommand(commands, tokens) {
1150
1284
  const [first, ...rest] = tokens;
1151
1285
  if (!first || !commands.has(first))
1152
- return { error: first ?? '(none)', path: '' };
1286
+ return { error: first ?? '(none)', path: '', commands, rest };
1153
1287
  let entry = commands.get(first);
1154
1288
  const path = [first];
1155
1289
  let remaining = rest;
@@ -1181,7 +1315,7 @@ function resolveCommand(commands, tokens) {
1181
1315
  };
1182
1316
  const child = entry.commands.get(next);
1183
1317
  if (!child) {
1184
- return { error: next, path: path.join(' ') };
1318
+ return { error: next, path: path.join(' '), commands: entry.commands, rest: remaining.slice(1) };
1185
1319
  }
1186
1320
  path.push(next);
1187
1321
  remaining = remaining.slice(1);
@@ -1207,7 +1341,7 @@ function resolveCommand(commands, tokens) {
1207
1341
  };
1208
1342
  }
1209
1343
  /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
1210
- function extractBuiltinFlags(argv) {
1344
+ function extractBuiltinFlags(argv, options = {}) {
1211
1345
  let verbose = false;
1212
1346
  let llms = false;
1213
1347
  let llmsFull = false;
@@ -1217,11 +1351,16 @@ function extractBuiltinFlags(argv) {
1217
1351
  let schema = false;
1218
1352
  let format = 'toon';
1219
1353
  let formatExplicit = false;
1354
+ let configPath;
1355
+ let configDisabled = false;
1220
1356
  let filterOutput;
1221
1357
  let tokenLimit;
1222
1358
  let tokenOffset;
1223
1359
  let tokenCount = false;
1224
1360
  const rest = [];
1361
+ const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined;
1362
+ const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined;
1363
+ const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined;
1225
1364
  for (let i = 0; i < argv.length; i++) {
1226
1365
  const token = argv[i];
1227
1366
  if (token === '--verbose')
@@ -1247,6 +1386,25 @@ function extractBuiltinFlags(argv) {
1247
1386
  formatExplicit = true;
1248
1387
  i++;
1249
1388
  }
1389
+ else if (cfgFlag && token === cfgFlag) {
1390
+ const value = argv[i + 1];
1391
+ if (value === undefined)
1392
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` });
1393
+ configPath = value;
1394
+ configDisabled = false;
1395
+ i++;
1396
+ }
1397
+ else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
1398
+ const value = token.slice(cfgFlagEq.length);
1399
+ if (value.length === 0)
1400
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` });
1401
+ configPath = value;
1402
+ configDisabled = false;
1403
+ }
1404
+ else if (noCfgFlag && token === noCfgFlag) {
1405
+ configPath = undefined;
1406
+ configDisabled = true;
1407
+ }
1250
1408
  else if (token === '--filter-output' && argv[i + 1]) {
1251
1409
  filterOutput = argv[i + 1];
1252
1410
  i++;
@@ -1268,6 +1426,8 @@ function extractBuiltinFlags(argv) {
1268
1426
  verbose,
1269
1427
  format,
1270
1428
  formatExplicit,
1429
+ configPath,
1430
+ configDisabled,
1271
1431
  filterOutput,
1272
1432
  tokenLimit,
1273
1433
  tokenOffset,
@@ -1281,6 +1441,117 @@ function extractBuiltinFlags(argv) {
1281
1441
  rest,
1282
1442
  };
1283
1443
  }
1444
+ /** @internal Loads config-backed option defaults for the active command. */
1445
+ async function loadCommandOptionDefaults(cli, path, options = {}) {
1446
+ if (options.configDisabled)
1447
+ return undefined;
1448
+ const { loader } = options;
1449
+ // Resolve the target file path
1450
+ let targetPath;
1451
+ if (options.configPath) {
1452
+ targetPath = resolveConfigPath(options.configPath);
1453
+ }
1454
+ else {
1455
+ const searchPaths = options.files ?? [`${cli}.json`];
1456
+ targetPath = await findFirstExisting(searchPaths);
1457
+ }
1458
+ // Load and parse the config
1459
+ let parsed;
1460
+ if (loader) {
1461
+ const result = await loader(targetPath);
1462
+ if (result === undefined)
1463
+ return undefined;
1464
+ if (!isRecord(result))
1465
+ throw new ParseError({ message: 'Config loader must return a plain object or undefined' });
1466
+ parsed = result;
1467
+ }
1468
+ else {
1469
+ if (!targetPath)
1470
+ return undefined;
1471
+ const result = await readJsonConfig(targetPath, !!options.configPath);
1472
+ if (!result)
1473
+ return undefined;
1474
+ parsed = result;
1475
+ }
1476
+ // Extract the command section from the config tree
1477
+ return extractCommandSection(parsed, cli, path);
1478
+ }
1479
+ /** @internal Resolves a config file path, expanding `~` to home dir. */
1480
+ function resolveConfigPath(filePath) {
1481
+ if (filePath.startsWith('~/') || filePath === '~') {
1482
+ return path.join(os.homedir(), filePath.slice(1));
1483
+ }
1484
+ return path.resolve(process.cwd(), filePath);
1485
+ }
1486
+ /** @internal Returns the first readable file from a list of paths, or `undefined`. */
1487
+ async function findFirstExisting(paths) {
1488
+ for (const p of paths) {
1489
+ const resolved = resolveConfigPath(p);
1490
+ try {
1491
+ await fs.access(resolved, fs.constants.R_OK);
1492
+ return resolved;
1493
+ }
1494
+ catch { }
1495
+ }
1496
+ return undefined;
1497
+ }
1498
+ /** @internal Reads and parses a JSON config file. */
1499
+ async function readJsonConfig(targetPath, explicit) {
1500
+ let raw;
1501
+ try {
1502
+ raw = await fs.readFile(targetPath, 'utf8');
1503
+ }
1504
+ catch (error) {
1505
+ if (error.code === 'ENOENT') {
1506
+ if (explicit)
1507
+ throw new ParseError({ message: `Config file not found: ${targetPath}` });
1508
+ return undefined;
1509
+ }
1510
+ throw error;
1511
+ }
1512
+ let parsed;
1513
+ try {
1514
+ parsed = JSON.parse(raw);
1515
+ }
1516
+ catch (error) {
1517
+ throw new ParseError({
1518
+ message: `Invalid JSON config file: ${targetPath}`,
1519
+ cause: error instanceof Error ? error : undefined,
1520
+ });
1521
+ }
1522
+ if (!isRecord(parsed))
1523
+ throw new ParseError({
1524
+ message: `Invalid config file: expected a top-level object in ${targetPath}`,
1525
+ });
1526
+ return parsed;
1527
+ }
1528
+ /** @internal Walks the nested config tree to extract option defaults for a command path. */
1529
+ function extractCommandSection(parsed, cli, path) {
1530
+ const segments = path === cli ? [] : path.split(' ');
1531
+ let node = parsed;
1532
+ for (const seg of segments) {
1533
+ if (!isRecord(node))
1534
+ return undefined;
1535
+ const commands = node.commands;
1536
+ if (!isRecord(commands))
1537
+ return undefined;
1538
+ node = commands[seg];
1539
+ if (node === undefined)
1540
+ return undefined;
1541
+ }
1542
+ if (!isRecord(node))
1543
+ throw new ParseError({
1544
+ message: `Invalid config section for '${path}': expected an object`,
1545
+ });
1546
+ const options = node.options;
1547
+ if (options === undefined)
1548
+ return undefined;
1549
+ if (!isRecord(options))
1550
+ throw new ParseError({
1551
+ message: `Invalid config 'options' for '${path}': expected an object`,
1552
+ });
1553
+ return Object.keys(options).length > 0 ? options : undefined;
1554
+ }
1284
1555
  /** @internal Collects immediate child commands/groups for help output. */
1285
1556
  function collectHelpCommands(commands) {
1286
1557
  const result = [];
@@ -1339,7 +1610,11 @@ export const toCommands = new WeakMap();
1339
1610
  /** @internal Maps CLI instances to their middleware arrays. */
1340
1611
  const toMiddlewares = new WeakMap();
1341
1612
  /** @internal Maps root CLI instances to their command definitions. */
1342
- const toRootDefinition = new WeakMap();
1613
+ export const toRootDefinition = new WeakMap();
1614
+ /** @internal Maps CLI instances to their root options schema. */
1615
+ export const toRootOptions = new WeakMap();
1616
+ /** @internal Maps CLI instances to whether config file loading is enabled. */
1617
+ export const toConfigEnabled = new WeakMap();
1343
1618
  /** @internal Maps CLI instances to their output policy. */
1344
1619
  const toOutputPolicy = new WeakMap();
1345
1620
  /** @internal Sentinel symbol for `ok()` and `error()` return values. */