clap-ts 0.3.0 → 0.4.0

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 (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
package/src/runner.ts ADDED
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Command runner - entry point for CLI execution.
3
+ * Handles subcommand resolution, lifecycle hooks, error handling.
4
+ * Supports inferSubcommands, subcommandRequired, allowExternalSubcommands,
5
+ * argsConflictsWithSubcommands, argRequiredElseHelp, and custom styles.
6
+ */
7
+
8
+ import type {
9
+ ArgDef,
10
+ ArgsDef,
11
+ CommandContext,
12
+ CommandDef,
13
+ ParsedArgs,
14
+ ParseResult,
15
+ OutputSink,
16
+ MissingArg,
17
+ RunOptions,
18
+ StylesDef,
19
+ ValueSource,
20
+ } from './types.js';
21
+ import {
22
+ CliParseError,
23
+ coerceValue,
24
+ collectGlobalArgs,
25
+ getRawArgs,
26
+ hasSubCommands,
27
+ kebabToCamel,
28
+ mergeGlobalArgs,
29
+ parseArgs,
30
+ subCommandsOf,
31
+ } from './parser.js';
32
+ import { validate } from './validation.js';
33
+ import { showError, showHelp, showVersion } from './help.js';
34
+
35
+ // ---- defineCommand ----
36
+
37
+ /**
38
+ * Define a command with full type inference on arguments.
39
+ * This is the primary API for creating commands.
40
+ *
41
+ * ```ts
42
+ * const cmd = defineCommand({
43
+ * meta: { name: 'my-tool', version: '1.0.0', description: 'My tool' },
44
+ * args: {
45
+ * verbose: { type: 'boolean', short: 'v', description: 'Verbose output' },
46
+ * port: { type: 'number', short: 'p', default: 3000, description: 'Port' },
47
+ * },
48
+ * run({ args }) {
49
+ * console.log(args.verbose, args.port);
50
+ * },
51
+ * });
52
+ * ```
53
+ */
54
+ export function defineCommand<const T extends ArgsDef>(def: CommandDef<T>): CommandDef<T> {
55
+ return def;
56
+ }
57
+
58
+ // ---- defineArgs / defineArg ----
59
+
60
+ /**
61
+ * Define a reusable argument group with full type inference.
62
+ * Use this for shared args that are spread into multiple commands.
63
+ *
64
+ * ```ts
65
+ * const envArgs = defineArgs({
66
+ * env: { type: 'string', valueParser: ['dev', 'staging', 'prod'] },
67
+ * dev: { type: 'boolean', conflictsWith: ['env', 'staging', 'prod'] },
68
+ * });
69
+ * ```
70
+ */
71
+ export function defineArgs<const T extends ArgsDef>(args: T): T {
72
+ return args;
73
+ }
74
+
75
+ /**
76
+ * Define a single argument with full type inference.
77
+ *
78
+ * ```ts
79
+ * const portArg = defineArg({ type: 'number', short: 'p', default: 3003 });
80
+ * ```
81
+ */
82
+ export function defineArg<const T extends ArgDef>(arg: T): T {
83
+ return arg;
84
+ }
85
+
86
+ // ---- Config Layer ----
87
+
88
+ /**
89
+ * The config section that applies to a command, built by walking the path from
90
+ * the root. A nested object named for a subcommand scopes its contents to that
91
+ * command; scalar keys stay in scope all the way down.
92
+ */
93
+ function configForPath(
94
+ config: Record<string, unknown>,
95
+ path: readonly string[],
96
+ ): Record<string, unknown> {
97
+ let scope: Record<string, unknown> = config;
98
+ const merged: Record<string, unknown> = {};
99
+
100
+ const takeScalars = (from: Record<string, unknown>): void => {
101
+ for (const key of Object.keys(from)) {
102
+ const value = from[key];
103
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
104
+ continue;
105
+ }
106
+ merged[key] = value;
107
+ }
108
+ };
109
+
110
+ takeScalars(scope);
111
+ for (const segment of path) {
112
+ const next = scope[segment];
113
+ if (next === null || typeof next !== 'object' || Array.isArray(next)) {
114
+ break;
115
+ }
116
+ scope = next as Record<string, unknown>;
117
+ takeScalars(scope);
118
+ }
119
+ return merged;
120
+ }
121
+
122
+ /**
123
+ * Fill args the command line and environment left alone from the config.
124
+ * A default already applied during parsing loses to a config value, which is
125
+ * what makes the precedence CLI > env > config > default.
126
+ */
127
+ function applyConfig(
128
+ result: ParseResult,
129
+ command: CommandDef,
130
+ source: Record<string, unknown> | (() => Record<string, unknown> | undefined),
131
+ path: readonly string[],
132
+ ): ParseResult {
133
+ const argsDef: ArgsDef = command.args ?? {};
134
+
135
+ // Nothing to fill means nothing to load: with a thunk, a command line that
136
+ // answered every argument never touches the filesystem.
137
+ let anyOpen = false;
138
+ for (const key of Object.keys(argsDef)) {
139
+ const from = result.valueSources.get(key);
140
+ if (from !== 'cli' && from !== 'env') {
141
+ anyOpen = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!anyOpen) {
146
+ return result;
147
+ }
148
+
149
+ const config = typeof source === 'function' ? source() : source;
150
+ if (config === undefined) {
151
+ return result;
152
+ }
153
+ const section = configForPath(config, path);
154
+
155
+ const args = { ...result.args };
156
+ const valueSources = new Map(result.valueSources);
157
+ let changed = false;
158
+
159
+ for (const key of Object.keys(argsDef)) {
160
+ const raw = section[key];
161
+ if (raw === undefined) {
162
+ continue;
163
+ }
164
+ const source = valueSources.get(key);
165
+ if (source === 'cli' || source === 'env') {
166
+ continue;
167
+ }
168
+
169
+ const def = argsDef[key]!;
170
+ const name = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
171
+ const coerce = (value: unknown): string | number | boolean =>
172
+ coerceValue(String(value), def, `config:${name}`);
173
+
174
+ args[key] = Array.isArray(raw) ? raw.map((v) => String(coerce(v))) : coerce(raw);
175
+ args[kebabToCamel(key)] = args[key]!;
176
+ valueSources.set(key, 'config');
177
+ changed = true;
178
+ }
179
+
180
+ return changed ? { ...result, args, valueSources } : result;
181
+ }
182
+
183
+ /** Required args that argv, the environment and the config all left empty. */
184
+ function missingRequired(command: CommandDef, result: ParseResult): MissingArg[] {
185
+ const argsDef: ArgsDef = command.args ?? {};
186
+ const missing: MissingArg[] = [];
187
+
188
+ for (const key of Object.keys(argsDef)) {
189
+ const def = argsDef[key]!;
190
+ if (def.required !== true || result.args[key] !== undefined) {
191
+ continue;
192
+ }
193
+ // requiredUnlessPresent and friends may excuse it; leave those to validate.
194
+ if (def.requiredUnlessPresent !== undefined || def.requiredUnlessPresentAll !== undefined) {
195
+ continue;
196
+ }
197
+ missing.push({
198
+ key,
199
+ def,
200
+ label: def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`,
201
+ });
202
+ }
203
+ return missing;
204
+ }
205
+
206
+ /** Fold values supplied by a fillMissing hook into the result. */
207
+ function applyFilled(
208
+ result: ParseResult,
209
+ command: CommandDef,
210
+ filled: Record<string, unknown>,
211
+ ): ParseResult {
212
+ const argsDef: ArgsDef = command.args ?? {};
213
+ const args = { ...result.args };
214
+ const explicitlySet = new Set(result.explicitlySet);
215
+ const valueSources = new Map(result.valueSources);
216
+
217
+ for (const key of Object.keys(filled)) {
218
+ const def = argsDef[key];
219
+ const raw = filled[key];
220
+ if (def === undefined || raw === undefined) {
221
+ continue;
222
+ }
223
+ const name = def.type === 'positional' ? `<${def.valueName ?? key}>` : `--${def.long ?? key}`;
224
+ const coerce = (value: unknown): string | number | boolean =>
225
+ typeof value === 'boolean' ? value : coerceValue(String(value), def, `prompt:${name}`);
226
+
227
+ args[key] = Array.isArray(raw) ? raw.map((v) => String(coerce(v))) : coerce(raw);
228
+ args[kebabToCamel(key)] = args[key]!;
229
+ explicitlySet.add(key);
230
+ valueSources.set(key, 'prompt');
231
+ }
232
+
233
+ return { ...result, args, explicitlySet, valueSources };
234
+ }
235
+
236
+ // ---- Global Args ----
237
+
238
+ /**
239
+ * Overlay inherited global args onto a command. The command's own args win, and
240
+ * a command with nothing to inherit is returned untouched so the parser's spec
241
+ * cache still hits.
242
+ */
243
+ function applyGlobals(
244
+ command: CommandDef,
245
+ globals: ArgsDef,
246
+ propagatedVersion: string | undefined,
247
+ ): CommandDef {
248
+ let hasGlobals = false;
249
+ for (const _key in globals) {
250
+ hasGlobals = true;
251
+ break;
252
+ }
253
+ const needsVersion = propagatedVersion !== undefined && command.meta.version === undefined;
254
+
255
+ if (!hasGlobals && !needsVersion) {
256
+ return command;
257
+ }
258
+ return {
259
+ ...command,
260
+ meta: needsVersion ? { ...command.meta, version: propagatedVersion } : command.meta,
261
+ args: hasGlobals ? mergeGlobalArgs(globals, command.args ?? {}) : command.args,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Walk a chain of subcommand names from a command, for `app help sub subsub`.
267
+ * Returns the deepest command reached and the names of its ancestors.
268
+ */
269
+ function resolveHelpTarget(
270
+ root: CommandDef,
271
+ rootParents: readonly string[],
272
+ path: readonly string[],
273
+ ): { command: CommandDef; parentNames: string[] } {
274
+ let current = root;
275
+ const parentNames = [...rootParents];
276
+ for (const name of path) {
277
+ const next = subCommandsOf(current)[name];
278
+ if (next === undefined) {
279
+ break;
280
+ }
281
+ parentNames.push(current.meta.name);
282
+ current = next;
283
+ }
284
+ return { command: current, parentNames };
285
+ }
286
+
287
+ /**
288
+ * Overlay global values captured at ancestor levels onto the resolved command's
289
+ * result. A value given again at this level always wins.
290
+ */
291
+ function applyInheritedGlobals(
292
+ result: ParseResult,
293
+ inherited: ReadonlyMap<string, string | number | boolean | string[]>,
294
+ ): ParseResult {
295
+ if (inherited.size === 0) {
296
+ return result;
297
+ }
298
+
299
+ const args = { ...result.args };
300
+ const explicitlySet = new Set(result.explicitlySet);
301
+ const valueSources = new Map(result.valueSources);
302
+ for (const [key, value] of inherited) {
303
+ if (explicitlySet.has(key)) {
304
+ continue;
305
+ }
306
+ args[kebabToCamel(key)] = value;
307
+ args[key] = value;
308
+ explicitlySet.add(key);
309
+ valueSources.set(key, 'cli');
310
+ }
311
+
312
+ return { ...result, args, explicitlySet, valueSources };
313
+ }
314
+
315
+ // ---- runCommand ----
316
+
317
+ /**
318
+ * Run a specific command with pre-parsed arguments.
319
+ * Executes the setup -> run -> cleanup lifecycle.
320
+ */
321
+ export async function runCommand<T extends ArgsDef>(
322
+ command: CommandDef<T>,
323
+ args: ParsedArgs<T>,
324
+ rawArgs: readonly string[] = [],
325
+ subCommand?: string,
326
+ valueSources: ReadonlyMap<string, ValueSource> = new Map(),
327
+ io?: { stdout: OutputSink; stderr: OutputSink },
328
+ ): Promise<void> {
329
+ const ctx: CommandContext<T> = {
330
+ rawArgs,
331
+ args,
332
+ cmd: command,
333
+ subCommand,
334
+ valueSources,
335
+ stdout: io?.stdout ?? process.stdout,
336
+ stderr: io?.stderr ?? process.stderr,
337
+ data: {},
338
+ };
339
+
340
+ let runError: unknown;
341
+
342
+ // Setup phase
343
+ if (command.setup) {
344
+ await command.setup(ctx);
345
+ }
346
+
347
+ // Run phase
348
+ try {
349
+ if (command.run) {
350
+ await command.run(ctx);
351
+ }
352
+ } catch (error) {
353
+ runError = error;
354
+ }
355
+
356
+ // Cleanup phase (always runs)
357
+ if (command.cleanup) {
358
+ try {
359
+ await command.cleanup(ctx);
360
+ } catch (error) {
361
+ runError ??= error;
362
+ }
363
+ }
364
+
365
+ if (runError) {
366
+ if (runError instanceof Error) {
367
+ throw runError;
368
+ }
369
+ throw new Error(JSON.stringify(runError));
370
+ }
371
+ }
372
+
373
+ // ---- Typo Suggestion ----
374
+
375
+ /** Collect all known subcommand names and aliases from a command. */
376
+ function collectSubcommandNames(subCommands: Record<string, CommandDef>): string[] {
377
+ const names: string[] = Object.keys(subCommands);
378
+ for (const def of Object.values(subCommands)) {
379
+ if (def.meta.aliases) {
380
+ names.push(...def.meta.aliases);
381
+ }
382
+ }
383
+ return names;
384
+ }
385
+
386
+ /**
387
+ * Find the closest match for a string among candidates using simple character diff.
388
+ */
389
+ function findClosestSubcommand(target: string, candidates: string[]): string | undefined {
390
+ const a = target.toLowerCase();
391
+ let bestMatch: string | undefined;
392
+ let bestDist = 4;
393
+
394
+ for (const name of candidates) {
395
+ const b = name.toLowerCase();
396
+ const dist = simpleCharDistance(a, b, bestDist);
397
+ if (dist < bestDist) {
398
+ bestDist = dist;
399
+ bestMatch = name;
400
+ }
401
+ }
402
+
403
+ return bestMatch;
404
+ }
405
+
406
+ /** Quick character-level distance heuristic. */
407
+ function simpleCharDistance(a: string, b: string, maxDist: number): number {
408
+ if (Math.abs(a.length - b.length) >= maxDist) {
409
+ return maxDist;
410
+ }
411
+
412
+ let dist = Math.abs(a.length - b.length);
413
+ const minLen = Math.min(a.length, b.length);
414
+ for (let i = 0; i < minLen; i++) {
415
+ if (a[i] !== b[i]) {
416
+ dist++;
417
+ }
418
+ }
419
+ return dist;
420
+ }
421
+
422
+ // ---- runMain helpers ----
423
+
424
+ /** Handle --help request with mode and style support. */
425
+ function handleHelpRequest(
426
+ command: CommandDef,
427
+ parentNames: string[],
428
+ isShortHelp: boolean,
429
+ io: RunIO,
430
+ ): void {
431
+ showHelp(command, parentNames.length > 0 ? parentNames : undefined, isShortHelp, io.styles, io.stdout);
432
+ io.finish(0);
433
+ }
434
+
435
+ /** Handle --version request. */
436
+ function handleVersionRequest(
437
+ effectiveCommand: CommandDef,
438
+ rootCommand: CommandDef,
439
+ isShort: boolean,
440
+ io: RunIO,
441
+ ): void {
442
+ const versionMeta = effectiveCommand.meta.version ? effectiveCommand.meta : rootCommand.meta;
443
+ showVersion(versionMeta, isShort, io.stdout);
444
+ io.finish(0);
445
+ }
446
+
447
+ /** Handle an unrecognized subcommand with typo suggestion. */
448
+ function handleUnrecognizedSubcommand(
449
+ unknownName: string,
450
+ command: CommandDef,
451
+ parentNames: string[],
452
+ io: RunIO,
453
+ ): void {
454
+ const allNames = collectSubcommandNames(subCommandsOf(command));
455
+ let msg = `unrecognized subcommand '${unknownName}'`;
456
+
457
+ const bestMatch = findClosestSubcommand(unknownName, allNames);
458
+ if (bestMatch) {
459
+ msg += `\n\n tip: a similar subcommand exists: '${bestMatch}'`;
460
+ }
461
+
462
+ fail(msg, command, parentNames, io);
463
+ }
464
+
465
+ /**
466
+ * Where a run writes and how it ends. `finish` reports the code to any onExit
467
+ * hook and exits the process unless the caller opted out.
468
+ */
469
+ interface RunIO {
470
+ readonly stdout: OutputSink;
471
+ readonly stderr: OutputSink;
472
+ readonly styles?: Partial<StylesDef>;
473
+ finish(code: number): void;
474
+ }
475
+
476
+ function makeRunIO(opts?: RunOptions): RunIO {
477
+ const shouldExit = opts?.exit !== false;
478
+ return {
479
+ stdout: opts?.stdout ?? process.stdout,
480
+ stderr: opts?.stderr ?? process.stderr,
481
+ styles: opts?.styles,
482
+ finish(code) {
483
+ opts?.onExit?.(code);
484
+ if (shouldExit) {
485
+ process.exit(code);
486
+ }
487
+ },
488
+ };
489
+ }
490
+
491
+ /** Print a usage error against the given command and settle on exit code 2. */
492
+ function fail(message: string, command: CommandDef, parentNames: string[], io: RunIO): void {
493
+ showError(message, command, parentNames.length > 0 ? parentNames : undefined, io.styles, io.stderr);
494
+ io.finish(2);
495
+ }
496
+
497
+ // ---- runMain ----
498
+
499
+ /**
500
+ * Main entry point for CLI applications.
501
+ * Parses args, resolves subcommands, validates, and runs.
502
+ *
503
+ * ```ts
504
+ * const main = defineCommand({ ... });
505
+ * runMain(main);
506
+ * ```
507
+ */
508
+ export async function runMain(rootCommand: CommandDef<any>, opts?: RunOptions): Promise<void> {
509
+ const io = makeRunIO(opts);
510
+ const shouldExit = opts?.exit !== false;
511
+ const showHelpOnEmpty = opts?.showHelpOnEmpty !== false;
512
+
513
+ // Tracked through the descent so an error names the command that failed,
514
+ // not the root.
515
+ let errorCommand: CommandDef = rootCommand;
516
+ let errorParents: string[] = [];
517
+
518
+ try {
519
+ let rawArgs = getRawArgs(opts?.argv, rootCommand.meta.noBinaryName === true);
520
+
521
+ // multicall: the name the binary was invoked under selects the subcommand.
522
+ if (rootCommand.meta.multicall && opts?.argv === undefined) {
523
+ const invoked = process.argv[1];
524
+ if (invoked !== undefined) {
525
+ const base = invoked.slice(invoked.lastIndexOf('/') + 1).replace(/\.[cm]?[jt]s$/, '');
526
+ rawArgs = [base, ...rawArgs];
527
+ }
528
+ }
529
+
530
+ let command: CommandDef = rootCommand;
531
+ let effectiveCommand: CommandDef = rootCommand;
532
+ let inheritedGlobals: ArgsDef = {};
533
+ // Values of global args given at an ancestor level. clap carries these down
534
+ // to the command that finally runs, so `app --verbose serve` reaches serve.
535
+ const inheritedValues = new Map<string, string | number | boolean | string[]>();
536
+ let propagatedVersion: string | undefined;
537
+ let argv: readonly string[] = rawArgs;
538
+ const parentNames: string[] = [];
539
+ let parseResult = parseArgs([], rootCommand);
540
+ let externalSubcommand: string | undefined;
541
+
542
+ // Descend the subcommand chain one level at a time, parsing as we go: only
543
+ // the arg spec can say whether a bare token is a subcommand or the value of
544
+ // a preceding flag.
545
+ for (;;) {
546
+ inheritedGlobals = mergeGlobalArgs(inheritedGlobals, collectGlobalArgs(command));
547
+ if (command.meta.propagateVersion && command.meta.version !== undefined) {
548
+ propagatedVersion = command.meta.version;
549
+ }
550
+ effectiveCommand = applyGlobals(command, inheritedGlobals, propagatedVersion);
551
+ errorCommand = effectiveCommand;
552
+ errorParents = [...parentNames];
553
+
554
+ parseResult = parseArgs(argv, effectiveCommand);
555
+
556
+ for (const warning of parseResult.warnings) {
557
+ io.stderr.write(`warning: ${warning}\n`);
558
+ }
559
+ if (command.meta.deprecated !== undefined && command.meta.deprecated !== false) {
560
+ const reason =
561
+ typeof command.meta.deprecated === 'string' ? `: ${command.meta.deprecated}` : '';
562
+ const instead =
563
+ command.meta.replacedBy === undefined ? '' : `; use '${command.meta.replacedBy}' instead`;
564
+ io.stderr.write(`warning: '${command.meta.name}' is deprecated${reason}${instead}\n`);
565
+ }
566
+
567
+ // The built-in `help` subcommand: `app help`, `app help sub sub`.
568
+ if (
569
+ parseResult.subCommand === undefined &&
570
+ hasSubCommands(command) &&
571
+ command.meta.disableHelpSubcommand !== true &&
572
+ parseResult.positionals[0] === 'help'
573
+ ) {
574
+ const target = resolveHelpTarget(
575
+ effectiveCommand,
576
+ parentNames,
577
+ parseResult.positionals.slice(1),
578
+ );
579
+ handleHelpRequest(target.command, target.parentNames, false, io);
580
+ return;
581
+ }
582
+
583
+ for (const key of Object.keys(inheritedGlobals)) {
584
+ if (parseResult.explicitlySet.has(key)) {
585
+ const value = parseResult.args[key];
586
+ if (value !== undefined) {
587
+ inheritedValues.set(key, value);
588
+ }
589
+ }
590
+ }
591
+
592
+ if (
593
+ parseResult.subCommand === undefined ||
594
+ parseResult.helpRequested ||
595
+ parseResult.versionRequested
596
+ ) {
597
+ break;
598
+ }
599
+
600
+ if (parseResult.subCommandIsExternal) {
601
+ externalSubcommand = parseResult.subCommand;
602
+ break;
603
+ }
604
+
605
+ if (command.meta.argsConflictsWithSubcommands && parseResult.explicitlySet.size > 0) {
606
+ fail('arguments cannot be used with subcommands', effectiveCommand, parentNames, io);
607
+ return;
608
+ }
609
+
610
+ const next = subCommandsOf(command)[parseResult.subCommand];
611
+ if (next === undefined) {
612
+ break;
613
+ }
614
+
615
+ // A parent's own constraints still apply to the flags given before the
616
+ // subcommand, unless it opted out with subcommandNegatesReqs.
617
+ if (!command.meta.subcommandNegatesReqs) {
618
+ validate(parseResult, effectiveCommand);
619
+ }
620
+
621
+ parentNames.push(command.meta.name);
622
+ command = next;
623
+ argv = parseResult.subCommandArgs;
624
+ }
625
+
626
+ // External subcommand: hand the name and the untouched remainder to the
627
+ // command that declared allowExternalSubcommands.
628
+ if (externalSubcommand !== undefined) {
629
+ if (effectiveCommand.run) {
630
+ const parser = effectiveCommand.externalSubcommandValueParser;
631
+ const externalArgs =
632
+ parser === undefined
633
+ ? parseResult.subCommandArgs
634
+ : parseResult.subCommandArgs.map((value) => {
635
+ try {
636
+ return String(parser(value));
637
+ } catch (error) {
638
+ const message = error instanceof Error ? error.message : String(error);
639
+ throw new CliParseError(`invalid value '${value}': ${message}`);
640
+ }
641
+ });
642
+ await runCommand(
643
+ effectiveCommand,
644
+ parseResult.args,
645
+ externalArgs,
646
+ externalSubcommand,
647
+ parseResult.valueSources,
648
+ io,
649
+ );
650
+ }
651
+ io.finish(0);
652
+ return;
653
+ }
654
+
655
+ // Handle --help
656
+ if (parseResult.helpRequested) {
657
+ handleHelpRequest(effectiveCommand, parentNames, parseResult.helpIsShort, io);
658
+ return;
659
+ }
660
+
661
+ // Handle --version
662
+ if (parseResult.versionRequested) {
663
+ handleVersionRequest(effectiveCommand, rootCommand, parseResult.versionIsShort, io);
664
+ return;
665
+ }
666
+
667
+ // Show help if no args and command has subcommands
668
+ if (
669
+ showHelpOnEmpty &&
670
+ rawArgs.length === 0 &&
671
+ hasSubCommands(rootCommand)
672
+ ) {
673
+ handleHelpRequest(rootCommand, [], false, io);
674
+ return;
675
+ }
676
+
677
+ // argRequiredElseHelp: show help if no args were explicitly provided
678
+ if (command.meta.argRequiredElseHelp && parseResult.explicitlySet.size === 0) {
679
+ handleHelpRequest(effectiveCommand, parentNames, false, io);
680
+ return;
681
+ }
682
+
683
+ // If we resolved to a parent command that has subcommands but no run handler,
684
+ // and the user didn't pass a valid subcommand, show help or error
685
+ if (
686
+ hasSubCommands(command) &&
687
+ !command.run &&
688
+ !parseResult.subCommand
689
+ ) {
690
+ // subcommandRequired: error if no subcommand
691
+ if (command.meta.subcommandRequired) {
692
+ if (parseResult.positionals.length > 0) {
693
+ handleUnrecognizedSubcommand(
694
+ parseResult.positionals[0]!, effectiveCommand, parentNames, io,
695
+ );
696
+ } else {
697
+ fail('a subcommand is required but one was not provided', effectiveCommand, parentNames, io);
698
+ }
699
+ return;
700
+ }
701
+
702
+ if (parseResult.positionals.length > 0) {
703
+ handleUnrecognizedSubcommand(
704
+ parseResult.positionals[0]!, effectiveCommand, parentNames, io,
705
+ );
706
+ return;
707
+ }
708
+
709
+ handleHelpRequest(effectiveCommand, parentNames, false, io);
710
+ return;
711
+ }
712
+
713
+ // Fold ancestor-provided global values into the command that runs, then
714
+ // let the config fill whatever is still on its default.
715
+ let finalResult = applyInheritedGlobals(parseResult, inheritedValues);
716
+ if (opts?.config !== undefined) {
717
+ finalResult = applyConfig(finalResult, effectiveCommand, opts.config, [
718
+ ...parentNames.slice(1),
719
+ ...(parentNames.length > 0 ? [command.meta.name] : []),
720
+ ]);
721
+ }
722
+
723
+ // Last chance to supply what is still missing, before validation says no.
724
+ if (opts?.fillMissing !== undefined) {
725
+ const missing = missingRequired(effectiveCommand, finalResult);
726
+ if (missing.length > 0) {
727
+ const filled = await opts.fillMissing(missing, effectiveCommand);
728
+ if (filled !== undefined) {
729
+ finalResult = applyFilled(finalResult, effectiveCommand, filled);
730
+ }
731
+ }
732
+ }
733
+
734
+ // Validate parsed args
735
+ validate(finalResult, effectiveCommand);
736
+
737
+ // Run the command
738
+ await runCommand(
739
+ effectiveCommand,
740
+ finalResult.args,
741
+ rawArgs,
742
+ undefined,
743
+ finalResult.valueSources,
744
+ io,
745
+ );
746
+
747
+ // If the command's run() didn't call process.exit() itself, exit cleanly.
748
+ // This prevents the process from hanging when the caller uses `void runMain()`
749
+ // instead of `await runMain()` — the unawaited Promise would otherwise keep
750
+ // the event loop alive due to pending NAPI handles or other resources.
751
+ io.finish(0);
752
+ } catch (error) {
753
+ if (error instanceof CliParseError) {
754
+ fail(error.message, errorCommand, errorParents, io);
755
+ return;
756
+ }
757
+
758
+ // Unexpected error
759
+ if (shouldExit || opts?.onExit !== undefined) {
760
+ const message = error instanceof Error ? error.message : String(error);
761
+ io.stderr.write(`error: ${message}\n`);
762
+ io.finish(1);
763
+ if (!shouldExit) {
764
+ return;
765
+ }
766
+ }
767
+ throw error;
768
+ }
769
+ }