incur 0.4.0 → 0.4.2

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 (115) hide show
  1. package/README.md +83 -22
  2. package/SKILL.md +6 -6
  3. package/dist/Cli.d.ts +46 -26
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +728 -441
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Completions.d.ts +4 -3
  8. package/dist/Completions.d.ts.map +1 -1
  9. package/dist/Completions.js +17 -10
  10. package/dist/Completions.js.map +1 -1
  11. package/dist/Fetch.d.ts.map +1 -1
  12. package/dist/Fetch.js +10 -9
  13. package/dist/Fetch.js.map +1 -1
  14. package/dist/Filter.js +0 -18
  15. package/dist/Filter.js.map +1 -1
  16. package/dist/Formatter.d.ts.map +1 -1
  17. package/dist/Formatter.js +6 -1
  18. package/dist/Formatter.js.map +1 -1
  19. package/dist/Help.d.ts +7 -1
  20. package/dist/Help.d.ts.map +1 -1
  21. package/dist/Help.js +44 -27
  22. package/dist/Help.js.map +1 -1
  23. package/dist/Mcp.d.ts +37 -5
  24. package/dist/Mcp.d.ts.map +1 -1
  25. package/dist/Mcp.js +71 -72
  26. package/dist/Mcp.js.map +1 -1
  27. package/dist/Openapi.d.ts.map +1 -1
  28. package/dist/Openapi.js +22 -14
  29. package/dist/Openapi.js.map +1 -1
  30. package/dist/Parser.d.ts +4 -0
  31. package/dist/Parser.d.ts.map +1 -1
  32. package/dist/Parser.js +70 -38
  33. package/dist/Parser.js.map +1 -1
  34. package/dist/Schema.d.ts +5 -1
  35. package/dist/Schema.d.ts.map +1 -1
  36. package/dist/Schema.js +13 -2
  37. package/dist/Schema.js.map +1 -1
  38. package/dist/Skill.d.ts +2 -1
  39. package/dist/Skill.d.ts.map +1 -1
  40. package/dist/Skill.js +33 -19
  41. package/dist/Skill.js.map +1 -1
  42. package/dist/Skillgen.js +1 -1
  43. package/dist/Skillgen.js.map +1 -1
  44. package/dist/SyncSkills.d.ts +48 -0
  45. package/dist/SyncSkills.d.ts.map +1 -1
  46. package/dist/SyncSkills.js +108 -10
  47. package/dist/SyncSkills.js.map +1 -1
  48. package/dist/Typegen.js +4 -2
  49. package/dist/Typegen.js.map +1 -1
  50. package/dist/bin.d.ts +2 -1
  51. package/dist/bin.d.ts.map +1 -1
  52. package/dist/bin.js +17 -2
  53. package/dist/bin.js.map +1 -1
  54. package/dist/internal/command.d.ts +170 -0
  55. package/dist/internal/command.d.ts.map +1 -0
  56. package/dist/internal/command.js +292 -0
  57. package/dist/internal/command.js.map +1 -0
  58. package/dist/internal/configSchema.d.ts +8 -0
  59. package/dist/internal/configSchema.d.ts.map +1 -0
  60. package/dist/internal/configSchema.js +57 -0
  61. package/dist/internal/configSchema.js.map +1 -0
  62. package/dist/internal/dereference.d.ts +12 -0
  63. package/dist/internal/dereference.d.ts.map +1 -0
  64. package/dist/internal/dereference.js +71 -0
  65. package/dist/internal/dereference.js.map +1 -0
  66. package/dist/internal/helpers.d.ts +9 -0
  67. package/dist/internal/helpers.d.ts.map +1 -0
  68. package/dist/internal/helpers.js +54 -0
  69. package/dist/internal/helpers.js.map +1 -0
  70. package/dist/middleware.d.ts +6 -8
  71. package/dist/middleware.d.ts.map +1 -1
  72. package/dist/middleware.js +1 -1
  73. package/dist/middleware.js.map +1 -1
  74. package/examples/npm/.npmrc.json +21 -0
  75. package/examples/npm/config.schema.json +134 -0
  76. package/package.json +6 -29
  77. package/src/Cli.test-d.ts +44 -33
  78. package/src/Cli.test.ts +1231 -101
  79. package/src/Cli.ts +877 -569
  80. package/src/Completions.test.ts +136 -12
  81. package/src/Completions.ts +18 -13
  82. package/src/Fetch.test.ts +21 -0
  83. package/src/Fetch.ts +8 -10
  84. package/src/Filter.ts +0 -17
  85. package/src/Formatter.test.ts +15 -2
  86. package/src/Formatter.ts +5 -1
  87. package/src/Help.test.ts +184 -20
  88. package/src/Help.ts +52 -28
  89. package/src/Mcp.test.ts +159 -0
  90. package/src/Mcp.ts +108 -86
  91. package/src/Openapi.test.ts +17 -5
  92. package/src/Openapi.ts +21 -15
  93. package/src/Parser.test-d.ts +22 -0
  94. package/src/Parser.test.ts +89 -0
  95. package/src/Parser.ts +87 -36
  96. package/src/Schema.test.ts +29 -0
  97. package/src/Schema.ts +12 -2
  98. package/src/Skill.test.ts +87 -6
  99. package/src/Skill.ts +38 -21
  100. package/src/Skillgen.ts +1 -1
  101. package/src/SyncMcp.test.ts +6 -8
  102. package/src/SyncSkills.test.ts +146 -3
  103. package/src/SyncSkills.ts +191 -10
  104. package/src/Typegen.test.ts +15 -0
  105. package/src/Typegen.ts +4 -2
  106. package/src/bin.ts +21 -2
  107. package/src/e2e.test.ts +188 -98
  108. package/src/internal/command.ts +449 -0
  109. package/src/internal/configSchema.test.ts +193 -0
  110. package/src/internal/configSchema.ts +66 -0
  111. package/src/internal/dereference.test.ts +695 -0
  112. package/src/internal/dereference.ts +75 -0
  113. package/src/internal/helpers.test.ts +75 -0
  114. package/src/internal/helpers.ts +59 -0
  115. package/src/middleware.ts +5 -12
package/src/Cli.ts CHANGED
@@ -1,13 +1,25 @@
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
- import type { z } from 'zod'
5
+ import { 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 {
15
+ builtinCommands,
16
+ type CommandMeta,
17
+ findBuiltin,
18
+ type Shell,
19
+ shells,
20
+ } from './internal/command.js'
21
+ import * as Command from './internal/command.js'
22
+ import { isRecord, suggest } from './internal/helpers.js'
11
23
  import { detectRunner } from './internal/pm.js'
12
24
  import type { OneOf } from './internal/types.js'
13
25
  import * as Mcp from './Mcp.js'
@@ -26,7 +38,6 @@ export type Cli<
26
38
  commands extends CommandsMap = {},
27
39
  vars extends z.ZodObject<any> | undefined = undefined,
28
40
  env extends z.ZodObject<any> | undefined = undefined,
29
- opts extends z.ZodObject<any> | undefined = undefined,
30
41
  > = {
31
42
  /** Registers a root command or mounts a sub-CLI as a command group. */
32
43
  command: {
@@ -43,30 +54,23 @@ export type Cli<
43
54
  ): Cli<
44
55
  commands & { [key in name]: { args: InferOutput<args>; options: InferOutput<options> } },
45
56
  vars,
46
- env,
47
- opts
57
+ env
48
58
  >
49
59
  /** Mounts a sub-CLI as a command group. */
50
60
  <const name extends string, const sub extends CommandsMap>(
51
- cli: Cli<sub, any, any, any> & { name: name },
52
- ): Cli<
53
- commands & { [key in keyof sub & string as `${name} ${key}`]: sub[key] },
54
- vars,
55
- env,
56
- opts
57
- >
61
+ cli: Cli<sub, any, any> & { name: name },
62
+ ): Cli<commands & { [key in keyof sub & string as `${name} ${key}`]: sub[key] }, vars, env>
58
63
  /** Mounts a root CLI as a single command. */
59
64
  <
60
65
  const name extends string,
61
66
  const args extends z.ZodObject<any> | undefined,
62
- const cmdOpts extends z.ZodObject<any> | undefined,
67
+ const opts extends z.ZodObject<any> | undefined,
63
68
  >(
64
- cli: Root<args, cmdOpts> & { name: name },
69
+ cli: Root<args, opts> & { name: name },
65
70
  ): Cli<
66
- commands & { [key in name]: { args: InferOutput<args>; options: InferOutput<cmdOpts> } },
71
+ commands & { [key in name]: { args: InferOutput<args>; options: InferOutput<opts> } },
67
72
  vars,
68
- env,
69
- opts
73
+ env
70
74
  >
71
75
  /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */
72
76
  <const name extends string>(
@@ -78,7 +82,7 @@ export type Cli<
78
82
  openapi?: Openapi.OpenAPISpec | undefined
79
83
  outputPolicy?: OutputPolicy | undefined
80
84
  },
81
- ): Cli<commands, vars, env, opts>
85
+ ): Cli<commands, vars, env>
82
86
  }
83
87
  /** A short description of the CLI. */
84
88
  description?: string | undefined
@@ -90,10 +94,8 @@ export type Cli<
90
94
  fetch(req: Request): Promise<Response>
91
95
  /** Parses argv, runs the matched command, and writes the output envelope to stdout. */
92
96
  serve(argv?: string[], options?: serve.Options): Promise<void>
93
- /** The options schema, if declared. Use `typeof cli.options` with `middleware<vars, env, options>()` for typed middleware. */
94
- options: opts
95
97
  /** Registers middleware that runs around every command. */
96
- use(handler: MiddlewareHandler<vars, env, opts>): Cli<commands, vars, env, opts>
98
+ use(handler: MiddlewareHandler<vars, env>): Cli<commands, vars, env>
97
99
  /** The vars schema, if declared. Use `typeof cli.vars` with `middleware<vars, env>()` for typed middleware. */
98
100
  vars: vars
99
101
  }
@@ -161,12 +163,7 @@ export function create<
161
163
  >(
162
164
  name: string,
163
165
  definition: create.Options<args, env, opts, output, vars> & { run: Function },
164
- ): Cli<
165
- { [key in typeof name]: { args: InferOutput<args>; options: InferOutput<opts> } },
166
- vars,
167
- env,
168
- opts
169
- >
166
+ ): Cli<{ [key in typeof name]: { args: InferOutput<args>; options: InferOutput<opts> } }, vars, env>
170
167
  /** Creates a router CLI that registers subcommands. */
171
168
  export function create<
172
169
  const args extends z.ZodObject<any> | undefined = undefined,
@@ -174,10 +171,7 @@ export function create<
174
171
  const opts extends z.ZodObject<any> | undefined = undefined,
175
172
  const output extends z.ZodType | undefined = undefined,
176
173
  const vars extends z.ZodObject<any> | undefined = undefined,
177
- >(
178
- name: string,
179
- definition?: create.Options<args, env, opts, output, vars>,
180
- ): Cli<{}, vars, env, opts>
174
+ >(name: string, definition?: create.Options<args, env, opts, output, vars>): Cli<{}, vars, env>
181
175
  /** Creates a CLI with a root handler from a single options object. Can still register subcommands. */
182
176
  export function create<
183
177
  const args extends z.ZodObject<any> | undefined = undefined,
@@ -192,8 +186,7 @@ export function create<
192
186
  [key in (typeof definition)['name']]: { args: InferOutput<args>; options: InferOutput<opts> }
193
187
  },
194
188
  vars,
195
- env,
196
- opts
189
+ env
197
190
  >
198
191
  /** Creates a router CLI from a single options object (e.g. package.json). */
199
192
  export function create<
@@ -202,9 +195,7 @@ export function create<
202
195
  const opts extends z.ZodObject<any> | undefined = undefined,
203
196
  const output extends z.ZodType | undefined = undefined,
204
197
  const vars extends z.ZodObject<any> | undefined = undefined,
205
- >(
206
- definition: create.Options<args, env, opts, output, vars> & { name: string },
207
- ): Cli<{}, vars, env, opts>
198
+ >(definition: create.Options<args, env, opts, output, vars> & { name: string }): Cli<{}, vars, env>
208
199
  export function create(
209
200
  nameOrDefinition: string | (any & { name: string }),
210
201
  definition?: any,
@@ -223,7 +214,6 @@ export function create(
223
214
  name,
224
215
  description: def.description,
225
216
  env: def.env,
226
- options: def.options,
227
217
  vars: def.vars,
228
218
 
229
219
  command(nameOrCli: any, def?: any): any {
@@ -255,11 +245,16 @@ export function create(
255
245
  return cli
256
246
  }
257
247
  commands.set(nameOrCli, def)
248
+ if (def.aliases)
249
+ for (const a of def.aliases) commands.set(a, { _alias: true, target: nameOrCli })
258
250
  return cli
259
251
  }
260
252
  const mountedRootDef = toRootDefinition.get(nameOrCli)
261
253
  if (mountedRootDef) {
262
254
  commands.set(nameOrCli.name, mountedRootDef)
255
+ const rootAliases = toRootAliases.get(nameOrCli)
256
+ if (rootAliases)
257
+ for (const a of rootAliases) commands.set(a, { _alias: true, target: nameOrCli.name })
263
258
  return cli
264
259
  }
265
260
  const sub = nameOrCli as Cli
@@ -279,10 +274,13 @@ export function create(
279
274
  async fetch(req: Request) {
280
275
  if (pending.length > 0) await Promise.all(pending)
281
276
  return fetchImpl(name, commands, req, {
277
+ envSchema: def.env,
282
278
  mcpHandler,
283
279
  middlewares,
280
+ name,
284
281
  rootCommand: rootDef,
285
282
  vars: def.vars,
283
+ version: def.version,
286
284
  })
287
285
  },
288
286
 
@@ -291,12 +289,12 @@ export function create(
291
289
  return serveImpl(name, commands, argv, {
292
290
  ...serveOptions,
293
291
  aliases: def.aliases,
292
+ config: def.config,
294
293
  description: def.description,
295
294
  envSchema: def.env,
296
295
  format: def.format,
297
296
  mcp: def.mcp,
298
297
  middlewares,
299
- optionsSchema: def.options,
300
298
  outputPolicy: def.outputPolicy,
301
299
  rootCommand: rootDef,
302
300
  rootFetch,
@@ -313,6 +311,9 @@ export function create(
313
311
  }
314
312
 
315
313
  if (rootDef) toRootDefinition.set(cli as unknown as Root, rootDef)
314
+ if (rootDef && def.aliases) toRootAliases.set(cli as unknown as Root, def.aliases)
315
+ if (def.options) toRootOptions.set(cli, def.options)
316
+ if (def.config !== undefined) toConfigEnabled.set(cli, true)
316
317
  if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy)
317
318
  toMiddlewares.set(cli, middlewares)
318
319
  toCommands.set(cli, commands)
@@ -336,6 +337,24 @@ export declare namespace create {
336
337
  aliases?: string[] | undefined
337
338
  /** Zod schema for positional arguments. */
338
339
  args?: args | undefined
340
+ /** Enable config-file defaults for command options. */
341
+ config?:
342
+ | {
343
+ /** Global flag name for specifying a config file path (e.g. `'config'` → `--config <path>`). Omit to auto-load only, with no CLI flag. */
344
+ flag?: string | undefined
345
+ /** Ordered list of file paths to search. First existing file wins. Supports `~` for home dir. Defaults to `['<cli>.json']` relative to cwd. */
346
+ files?: string[] | undefined
347
+ /** 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. */
348
+ loader?:
349
+ | ((
350
+ path: string | undefined,
351
+ ) =>
352
+ | Record<string, unknown>
353
+ | undefined
354
+ | Promise<Record<string, unknown> | undefined>)
355
+ | undefined
356
+ }
357
+ | undefined
339
358
  /** A short description of what the CLI does. */
340
359
  description?: string | undefined
341
360
  /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
@@ -370,6 +389,8 @@ export declare namespace create {
370
389
  agent: boolean
371
390
  /** Positional arguments. */
372
391
  args: InferOutput<args>
392
+ /** The binary name the user invoked (e.g. an alias). Falls back to `name` when not resolvable. */
393
+ displayName: string
373
394
  /** Parsed environment variables. */
374
395
  env: InferOutput<env>
375
396
  /** Return an error result with optional CTAs. */
@@ -445,9 +466,28 @@ async function serveImpl(
445
466
  ) {
446
467
  const stdout = options.stdout ?? ((s: string) => process.stdout.write(s))
447
468
  const exit = options.exit ?? ((code: number) => process.exit(code))
469
+ const human = process.stdout.isTTY === true
470
+ const configEnabled = options.config !== undefined
471
+ const configFlag = options.config?.flag
472
+ const displayName = resolveDisplayName(name, options.aliases)
473
+
474
+ function writeln(s: string) {
475
+ stdout(s.endsWith('\n') ? s : `${s}\n`)
476
+ }
477
+
478
+ let builtinFlags: ReturnType<typeof extractBuiltinFlags>
479
+ try {
480
+ builtinFlags = extractBuiltinFlags(argv, { configFlag })
481
+ } catch (error) {
482
+ const message = error instanceof Error ? error.message : String(error)
483
+ if (human) writeln(formatHumanError({ code: 'UNKNOWN', message }))
484
+ else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'))
485
+ exit(1)
486
+ return
487
+ }
448
488
 
449
489
  const {
450
- verbose,
490
+ fullOutput,
451
491
  format: formatFlag,
452
492
  formatExplicit,
453
493
  filterOutput,
@@ -460,17 +500,24 @@ async function serveImpl(
460
500
  help,
461
501
  version,
462
502
  schema,
503
+ configPath,
504
+ configDisabled,
463
505
  rest: filtered,
464
- } = extractBuiltinFlags(argv)
506
+ } = builtinFlags
465
507
 
466
508
  // --mcp: start as MCP stdio server
467
509
  if (mcpFlag) {
468
- await Mcp.serve(name, options.version ?? '0.0.0', commands)
510
+ await Mcp.serve(name, options.version ?? '0.0.0', commands, {
511
+ middlewares: options.middlewares,
512
+ env: options.envSchema,
513
+ vars: options.vars,
514
+ version: options.version,
515
+ })
469
516
  return
470
517
  }
471
518
 
472
519
  // COMPLETE: dynamic shell completions (called by shell hook at tab-press)
473
- const completeShell = process.env.COMPLETE as Completions.Shell | undefined
520
+ const completeShell = process.env.COMPLETE as Shell | undefined
474
521
  if (completeShell) {
475
522
  // Remove separator `--` from argv
476
523
  const sepIdx = argv.indexOf('--')
@@ -482,35 +529,51 @@ async function serveImpl(
482
529
  } else {
483
530
  const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1)
484
531
  const candidates = Completions.complete(commands, options.rootCommand, words, index)
532
+ // Add built-in commands (completions, mcp, skills) to completions
533
+ const current = words[index] ?? ''
534
+ const nonFlags = words.slice(0, index).filter((w) => !w.startsWith('-'))
535
+ if (nonFlags.length <= 1) {
536
+ for (const b of builtinCommands) {
537
+ if (b.name.startsWith(current) && !candidates.some((c) => c.value === b.name))
538
+ candidates.push({
539
+ value: b.name,
540
+ description: b.description,
541
+ ...(b.subcommands ? { noSpace: true } : undefined),
542
+ })
543
+ }
544
+ } else if (nonFlags.length === 2) {
545
+ const parent = nonFlags[nonFlags.length - 1]!
546
+ const builtin = findBuiltin(parent)
547
+ if (builtin?.subcommands)
548
+ for (const sub of builtin.subcommands)
549
+ if (sub.name.startsWith(current))
550
+ candidates.push({ value: sub.name, description: sub.description })
551
+ }
485
552
  const out = Completions.format(completeShell, candidates)
486
553
  if (out) stdout(out)
487
554
  }
488
555
  return
489
556
  }
490
557
 
491
- // Human mode: stdout is a TTY.
492
- const human = process.stdout.isTTY === true
493
-
494
- function writeln(s: string) {
495
- stdout(s.endsWith('\n') ? s : `${s}\n`)
496
- }
497
-
498
558
  // Skills staleness check (skip for built-in commands)
559
+ let skillsCta: FormattedCtaBlock | undefined
499
560
  if (!llms && !llmsFull && !schema && !help && !version) {
500
- const isSkillsAdd =
501
- filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
502
- const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp')
561
+ const isSkillsAdd = builtinIdx(filtered, name, 'skills') !== -1
562
+ const isMcpAdd = builtinIdx(filtered, name, 'mcp') !== -1
503
563
  if (!isSkillsAdd && !isMcpAdd) {
504
564
  const stored = SyncSkills.readHash(name)
505
- if (stored) {
565
+ if (stored && SyncSkills.hasInstalledSkills(name, { cwd: options.sync?.cwd })) {
506
566
  const groups = new Map<string, string>()
507
- const entries = collectSkillCommands(commands, [], groups)
567
+ const entries = collectSkillCommands(commands, [], groups, options.rootCommand)
508
568
  if (Skill.hash(entries) !== stored) {
509
- const runner = detectRunner()
510
- const spec = SyncMcp.detectPackageSpecifier(name)
511
- process.stderr.write(
512
- `⚠ Skills are out of date. Run '${runner} ${spec} skills add' to update.\n\n`,
513
- )
569
+ const command =
570
+ process.env.npm_config_user_agent || process.env.npm_execpath
571
+ ? `${detectRunner()} ${SyncMcp.detectPackageSpecifier(name)} skills add`
572
+ : `${displayName} skills add`
573
+ skillsCta = {
574
+ description: 'Skills are out of date:',
575
+ commands: [{ command, description: 'sync outdated skills' }],
576
+ }
514
577
  }
515
578
  }
516
579
  }
@@ -522,8 +585,9 @@ async function serveImpl(
522
585
  const prefix: string[] = []
523
586
  let scopedDescription: string | undefined = options.description
524
587
  for (const token of filtered) {
525
- const entry = scopedCommands.get(token)
526
- if (!entry) break
588
+ const rawEntry = scopedCommands.get(token)
589
+ if (!rawEntry) break
590
+ const entry = resolveAlias(scopedCommands, rawEntry)
527
591
  if (isGroup(entry)) {
528
592
  scopedCommands = entry.commands
529
593
  scopedDescription = entry.description
@@ -535,10 +599,12 @@ async function serveImpl(
535
599
  }
536
600
  }
537
601
 
602
+ const scopedRoot = prefix.length === 0 ? options.rootCommand : undefined
603
+
538
604
  if (llmsFull) {
539
605
  if (!formatExplicit || formatFlag === 'md') {
540
606
  const groups = new Map<string, string>()
541
- const cmds = collectSkillCommands(scopedCommands, prefix, groups)
607
+ const cmds = collectSkillCommands(scopedCommands, prefix, groups, scopedRoot)
542
608
  const scopedName = prefix.length > 0 ? `${name} ${prefix.join(' ')}` : name
543
609
  writeln(Skill.generate(scopedName, cmds, groups))
544
610
  return
@@ -549,7 +615,7 @@ async function serveImpl(
549
615
 
550
616
  if (!formatExplicit || formatFlag === 'md') {
551
617
  const groups = new Map<string, string>()
552
- const cmds = collectSkillCommands(scopedCommands, prefix, groups)
618
+ const cmds = collectSkillCommands(scopedCommands, prefix, groups, scopedRoot)
553
619
  const scopedName = prefix.length > 0 ? `${name} ${prefix.join(' ')}` : name
554
620
  writeln(Skill.index(scopedName, cmds, scopedDescription))
555
621
  return
@@ -559,81 +625,119 @@ async function serveImpl(
559
625
  }
560
626
 
561
627
  // completions <shell>: print shell hook script to stdout
562
- const completionsIdx = (() => {
563
- // e.g. `completions bash`
564
- if (filtered[0] === 'completions') return 0
565
- // e.g. `my-cli completions bash`
566
- if (filtered[0] === name && filtered[1] === 'completions') return 1
567
- // not a completions invocation
568
- return -1
569
- })()
570
- if (completionsIdx !== -1 && filtered[completionsIdx] === 'completions') {
571
- if (help) {
628
+ const completionsIdx = builtinIdx(filtered, name, 'completions')
629
+ if (completionsIdx !== -1) {
630
+ const shell = filtered[completionsIdx + 1]
631
+ if (help || !shell) {
632
+ const b = findBuiltin('completions')!
572
633
  writeln(
573
- [
574
- `${name} completions — Generate shell completion script`,
575
- '',
576
- `Usage: ${name} completions <shell>`,
577
- '',
578
- 'Shells:',
579
- ' bash',
580
- ' fish',
581
- ' nushell',
582
- ' zsh',
583
- '',
584
- 'Setup:',
585
- ...(() => {
586
- const rows = [
587
- ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
588
- ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
589
- ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
590
- ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
591
- ]
592
- const shellW = Math.max(...rows.map((r) => r[0]!.length))
593
- const cmdW = Math.max(...rows.map((r) => r[1]!.length))
594
- return rows.map(
595
- ([shell, cmd, comment]) =>
596
- ` ${shell!.padEnd(shellW)} ${cmd!.padEnd(cmdW)} ${comment}`,
597
- )
598
- })(),
599
- ].join('\n'),
634
+ Help.formatCommand(`${name} completions`, {
635
+ args: b.args,
636
+ description: b.description,
637
+ hideGlobalOptions: true,
638
+ hint: b.hint?.(name),
639
+ }),
600
640
  )
601
641
  return
602
642
  }
603
- const shell = filtered[completionsIdx + 1]
604
- if (!shell || !['bash', 'fish', 'nushell', 'zsh'].includes(shell)) {
643
+ if (!shells.includes(shell as any)) {
605
644
  writeln(
606
645
  formatHumanError({
607
646
  code: 'INVALID_SHELL',
608
- message: shell
609
- ? `Unknown shell '${shell}'. Supported: bash, fish, nushell, zsh`
610
- : `Missing shell argument. Usage: ${name} completions <bash|fish|nushell|zsh>`,
647
+ message: `Unknown shell '${shell}'. Supported: ${shells.join(', ')}`,
611
648
  }),
612
649
  )
613
650
  exit(1)
614
651
  return
615
652
  }
616
653
  const names = [name, ...(options.aliases ?? [])]
617
- writeln(names.map((n) => Completions.register(shell as Completions.Shell, n)).join('\n'))
654
+ writeln(names.map((n) => Completions.register(shell as Shell, n)).join('\n'))
618
655
  return
619
656
  }
620
657
 
621
658
  // skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
622
- const skillsIdx =
623
- filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
624
- if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills' && filtered[skillsIdx + 1] === 'add') {
659
+ const skillsIdx = builtinIdx(filtered, name, 'skills')
660
+ if (skillsIdx !== -1) {
661
+ const skillsSub = filtered[skillsIdx + 1]
662
+ if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') {
663
+ const suggestion = suggest(skillsSub, ['add', 'list'])
664
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
665
+ const message = `'${skillsSub}' is not a command for '${name} skills'.${didYouMean}`
666
+ const ctaCommands: FormattedCta[] = []
667
+ if (suggestion) {
668
+ const corrected = argv.map((t) => (t === skillsSub ? suggestion : t))
669
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
670
+ }
671
+ ctaCommands.push({
672
+ command: `${name} skills --help`,
673
+ description: 'see all available commands',
674
+ })
675
+ const cta: FormattedCtaBlock = {
676
+ description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:',
677
+ commands: ctaCommands,
678
+ }
679
+ if (human) {
680
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
681
+ writeln(formatHumanCta(cta))
682
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
683
+ exit(1)
684
+ return
685
+ }
686
+ if (!skillsSub) {
687
+ const b = findBuiltin('skills')!
688
+ writeln(formatBuiltinHelp(name, b))
689
+ return
690
+ }
691
+ if (skillsSub === 'list') {
692
+ if (help) {
693
+ const b = findBuiltin('skills')!
694
+ writeln(formatBuiltinSubcommandHelp(name, b, 'list'))
695
+ return
696
+ }
697
+ try {
698
+ const result = await SyncSkills.list(name, commands, {
699
+ cwd: options.sync?.cwd,
700
+ depth: options.sync?.depth ?? 1,
701
+ description: options.description,
702
+ include: options.sync?.include,
703
+ rootCommand: options.rootCommand,
704
+ })
705
+ if (result.length === 0) {
706
+ writeln('No skills found.')
707
+ return
708
+ }
709
+ const lines: string[] = []
710
+ const maxLen = Math.max(...result.map((s) => s.name.length))
711
+ for (const s of result) {
712
+ const icon = s.installed ? '✓' : '✗'
713
+ const padding = s.description
714
+ ? `${' '.repeat(maxLen - s.name.length)} ${s.description}`
715
+ : ''
716
+ lines.push(` ${icon} ${s.name}${padding}`)
717
+ }
718
+ const installedCount = result.filter((s) => s.installed).length
719
+ lines.push('')
720
+ lines.push(
721
+ `${result.length} skill${result.length === 1 ? '' : 's'} (${installedCount} installed)`,
722
+ )
723
+ writeln(lines.join('\n'))
724
+ } catch (err) {
725
+ writeln(
726
+ Formatter.format(
727
+ {
728
+ code: 'LIST_SKILLS_FAILED',
729
+ message: err instanceof Error ? err.message : String(err),
730
+ },
731
+ formatExplicit ? formatFlag : 'toon',
732
+ ),
733
+ )
734
+ exit(1)
735
+ }
736
+ return
737
+ }
625
738
  if (help) {
626
- writeln(
627
- [
628
- `${name} skills add — Sync skill files to your agent`,
629
- '',
630
- `Usage: ${name} skills add [options]`,
631
- '',
632
- 'Options:',
633
- ' --depth <number> Grouping depth for skill files (default: 1)',
634
- ' --no-global Install to project instead of globally',
635
- ].join('\n'),
636
- )
739
+ const b = findBuiltin('skills')!
740
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
637
741
  return
638
742
  }
639
743
  const rest = filtered.slice(skillsIdx + 2)
@@ -654,11 +758,11 @@ async function serveImpl(
654
758
  description: options.description,
655
759
  global,
656
760
  include: options.sync?.include,
761
+ rootCommand: options.rootCommand,
657
762
  })
658
763
  stdout('\r\x1b[K')
659
764
  const lines: string[] = []
660
- const skillLabel = (s: (typeof result.skills)[number]) =>
661
- s.external || s.name === name ? s.name : `${name}-${s.name}`
765
+ const skillLabel = (s: (typeof result.skills)[number]) => s.name
662
766
  const maxLen = Math.max(...result.skills.map((s) => skillLabel(s).length))
663
767
  for (const s of result.skills) {
664
768
  const label = skillLabel(s)
@@ -678,9 +782,9 @@ async function serveImpl(
678
782
  lines.push('')
679
783
  lines.push(`Run \`${name} --help\` to see the full command reference.`)
680
784
  writeln(lines.join('\n'))
681
- if (verbose || formatExplicit) {
785
+ if (fullOutput || formatExplicit) {
682
786
  const output: Record<string, unknown> = { skills: result.paths }
683
- if (verbose && result.agents.length > 0) output.agents = result.agents
787
+ if (fullOutput && result.agents.length > 0) output.agents = result.agents
684
788
  writeln(Formatter.format(output, formatExplicit ? formatFlag : 'toon'))
685
789
  }
686
790
  } catch (err) {
@@ -696,21 +800,38 @@ async function serveImpl(
696
800
  }
697
801
 
698
802
  // mcp add: register CLI as MCP server via `npx add-mcp`
699
- const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
700
- if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp' && filtered[mcpIdx + 1] === 'add') {
803
+ const mcpIdx = builtinIdx(filtered, name, 'mcp')
804
+ if (mcpIdx !== -1) {
805
+ const mcpSub = filtered[mcpIdx + 1]
806
+ if (mcpSub && mcpSub !== 'add') {
807
+ const suggestion = suggest(mcpSub, ['add'])
808
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
809
+ const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`
810
+ const ctaCommands: FormattedCta[] = []
811
+ if (suggestion) {
812
+ const corrected = argv.map((t) => (t === mcpSub ? suggestion : t))
813
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
814
+ }
815
+ ctaCommands.push({ command: `${name} mcp --help`, description: 'see all available commands' })
816
+ const cta: FormattedCtaBlock = {
817
+ description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:',
818
+ commands: ctaCommands,
819
+ }
820
+ if (human) {
821
+ writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
822
+ writeln(formatHumanCta(cta))
823
+ } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
824
+ exit(1)
825
+ return
826
+ }
827
+ if (!mcpSub) {
828
+ const b = findBuiltin('mcp')!
829
+ writeln(formatBuiltinHelp(name, b))
830
+ return
831
+ }
701
832
  if (help) {
702
- writeln(
703
- [
704
- `${name} mcp add — Register as an MCP server for your agent`,
705
- '',
706
- `Usage: ${name} mcp add [options]`,
707
- '',
708
- 'Options:',
709
- ' -c, --command <cmd> Override the command agents will run (e.g. "pnpm my-cli --mcp")',
710
- ' --no-global Install to project instead of globally',
711
- ' --agent <agent> Target a specific agent (e.g. claude-code, cursor)',
712
- ].join('\n'),
713
- )
833
+ const b = findBuiltin('mcp')!
834
+ writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
714
835
  return
715
836
  }
716
837
  const rest = filtered.slice(mcpIdx + 2)
@@ -744,7 +865,7 @@ async function serveImpl(
744
865
  for (const s of suggestions) lines.push(` "${s}"`)
745
866
  }
746
867
  writeln(lines.join('\n'))
747
- if (verbose || formatExplicit)
868
+ if (fullOutput || formatExplicit)
748
869
  writeln(
749
870
  Formatter.format(
750
871
  { name, command: result.command, agents: result.agents },
@@ -782,6 +903,7 @@ async function serveImpl(
782
903
  Help.formatCommand(name, {
783
904
  alias: cmd.alias as Record<string, string> | undefined,
784
905
  aliases: options.aliases,
906
+ configFlag,
785
907
  description: cmd.description ?? options.description,
786
908
  version: options.version,
787
909
  args: cmd.args,
@@ -803,6 +925,7 @@ async function serveImpl(
803
925
  writeln(
804
926
  Help.formatRoot(name, {
805
927
  aliases: options.aliases,
928
+ configFlag,
806
929
  description: options.description,
807
930
  version: options.version,
808
931
  commands: collectHelpCommands(commands),
@@ -851,6 +974,7 @@ async function serveImpl(
851
974
  Help.formatCommand(name, {
852
975
  alias: cmd.alias as Record<string, string> | undefined,
853
976
  aliases: options.aliases,
977
+ configFlag,
854
978
  description: cmd.description ?? options.description,
855
979
  version: options.version,
856
980
  args: cmd.args,
@@ -868,6 +992,7 @@ async function serveImpl(
868
992
  writeln(
869
993
  Help.formatRoot(helpName, {
870
994
  aliases: isRoot ? options.aliases : undefined,
995
+ configFlag,
871
996
  description: helpDesc,
872
997
  version: isRoot ? options.version : undefined,
873
998
  commands: collectHelpCommands(helpCmds),
@@ -886,7 +1011,8 @@ async function serveImpl(
886
1011
  writeln(
887
1012
  Help.formatCommand(commandName, {
888
1013
  alias: cmd.alias as Record<string, string> | undefined,
889
- aliases: isRootCmd ? options.aliases : undefined,
1014
+ aliases: isRootCmd ? options.aliases : cmd.aliases,
1015
+ configFlag,
890
1016
  description: cmd.description,
891
1017
  version: isRootCmd ? options.version : undefined,
892
1018
  args: cmd.args,
@@ -909,6 +1035,7 @@ async function serveImpl(
909
1035
  if ('help' in resolved) {
910
1036
  writeln(
911
1037
  Help.formatRoot(`${name} ${resolved.path}`, {
1038
+ configFlag,
912
1039
  description: resolved.description,
913
1040
  commands: collectHelpCommands(resolved.commands),
914
1041
  }),
@@ -917,7 +1044,9 @@ async function serveImpl(
917
1044
  }
918
1045
  if ('error' in resolved) {
919
1046
  const parent = resolved.path ? `${name} ${resolved.path}` : name
920
- writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`)
1047
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
1048
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1049
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.${didYouMean}`)
921
1050
  exit(1)
922
1051
  return
923
1052
  }
@@ -940,6 +1069,7 @@ async function serveImpl(
940
1069
  if ('help' in resolved) {
941
1070
  writeln(
942
1071
  Help.formatRoot(`${name} ${resolved.path}`, {
1072
+ configFlag,
943
1073
  description: resolved.description,
944
1074
  commands: collectHelpCommands(resolved.commands),
945
1075
  }),
@@ -949,21 +1079,22 @@ async function serveImpl(
949
1079
 
950
1080
  const start = performance.now()
951
1081
 
952
- // Parse root CLI-level options (available to middleware via c.options)
953
- const rootOptions = options.optionsSchema
954
- ? Parser.parse(filtered, {
955
- alias: options.rootCommand?.alias as Record<string, string> | undefined,
956
- options: options.optionsSchema,
957
- }).options
958
- : {}
959
-
960
1082
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
961
1083
  const resolvedFormat = 'command' in resolved && (resolved as any).command.format
962
1084
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon'
963
1085
 
964
- // Fall back to root fetch when no subcommand matches
1086
+ // Fall back to root fetch/command when no subcommand matches,
1087
+ // but only if the token doesn't look like a typo of a known command.
1088
+ const rootFallbackBlocked =
1089
+ 'error' in resolved &&
1090
+ !resolved.path &&
1091
+ (() => {
1092
+ const candidates = [...resolved.commands.keys()]
1093
+ for (const b of builtinCommands) candidates.push(b.name)
1094
+ return suggest(resolved.error, candidates) !== undefined
1095
+ })()
965
1096
  const effective =
966
- 'error' in resolved && options.rootFetch && !resolved.path
1097
+ 'error' in resolved && options.rootFetch && !resolved.path && !rootFallbackBlocked
967
1098
  ? {
968
1099
  fetchGateway: {
969
1100
  _fetch: true as const,
@@ -974,7 +1105,7 @@ async function serveImpl(
974
1105
  path: name,
975
1106
  rest: filtered,
976
1107
  }
977
- : 'error' in resolved && options.rootCommand && !resolved.path
1108
+ : 'error' in resolved && options.rootCommand && !resolved.path && !rootFallbackBlocked
978
1109
  ? { command: options.rootCommand, path: name, rest: filtered }
979
1110
  : resolved
980
1111
 
@@ -1008,13 +1139,28 @@ async function serveImpl(
1008
1139
  function write(output: Output) {
1009
1140
  if (filterPaths && output.ok && output.data != null)
1010
1141
  output = { ...output, data: Filter.apply(output.data, filterPaths) }
1142
+ if (skillsCta) {
1143
+ const existing = output.meta.cta
1144
+ output = {
1145
+ ...output,
1146
+ meta: {
1147
+ ...output.meta,
1148
+ cta: existing
1149
+ ? {
1150
+ description: existing.description,
1151
+ commands: [...existing.commands, ...skillsCta.commands],
1152
+ }
1153
+ : skillsCta,
1154
+ },
1155
+ }
1156
+ }
1011
1157
  if (tokenCount) {
1012
1158
  const base = output.ok ? output.data : output.error
1013
1159
  const formatted = base != null ? Formatter.format(base, format) : ''
1014
1160
  return writeln(String(estimateTokenCount(formatted)))
1015
1161
  }
1016
1162
  const cta = output.meta.cta
1017
- if (human && !verbose) {
1163
+ if (human && !fullOutput) {
1018
1164
  if (output.ok && output.data != null && renderOutput) {
1019
1165
  const t = truncate(Formatter.format(output.data, format))
1020
1166
  writeln(t.text)
@@ -1022,7 +1168,7 @@ async function serveImpl(
1022
1168
  if (cta) writeln(formatHumanCta(cta))
1023
1169
  return
1024
1170
  }
1025
- if (verbose) {
1171
+ if (fullOutput) {
1026
1172
  if (tokenLimit != null || tokenOffset != null) {
1027
1173
  // Truncate data separately so meta (including nextOffset) is always visible
1028
1174
  const dataFormatted =
@@ -1058,14 +1204,27 @@ async function serveImpl(
1058
1204
  if ('error' in effective) {
1059
1205
  const helpCmd = effective.path ? `${name} ${effective.path} --help` : `${name} --help`
1060
1206
  const parent = effective.path ? `${name} ${effective.path}` : name
1061
- const message = `'${effective.error}' is not a command for '${parent}'.`
1207
+ const candidates = 'commands' in effective ? [...effective.commands.keys()] : []
1208
+ if (!effective.path) for (const b of builtinCommands) candidates.push(b.name)
1209
+ const suggestion = suggest(effective.error, candidates)
1210
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1211
+ const message = `'${effective.error}' is not a command for '${parent}'.${didYouMean}`
1212
+ const ctaCommands: FormattedCta[] = []
1213
+ if (suggestion) {
1214
+ const corrected = argv.map((t) => (t === effective.error ? suggestion : t))
1215
+ ctaCommands.push({ command: `${name} ${corrected.join(' ')}` })
1216
+ }
1217
+ ctaCommands.push({ command: helpCmd, description: 'see all available commands' })
1062
1218
  const cta: FormattedCtaBlock = {
1063
- description: 'See available commands:',
1064
- commands: [{ command: helpCmd }],
1219
+ description: ctaCommands.length === 1 ? 'Suggested command:' : 'Suggested commands:',
1220
+ commands: ctaCommands,
1065
1221
  }
1066
- if (human && !verbose) {
1222
+ if (human && !fullOutput) {
1067
1223
  writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
1068
- writeln(formatHumanCta(cta))
1224
+ const mergedCta = skillsCta
1225
+ ? { ...cta, commands: [...cta.commands, ...skillsCta.commands] }
1226
+ : cta
1227
+ writeln(formatHumanCta(mergedCta))
1069
1228
  exit(1)
1070
1229
  return
1071
1230
  }
@@ -1107,7 +1266,7 @@ async function serveImpl(
1107
1266
  formatExplicit,
1108
1267
  human,
1109
1268
  renderOutput,
1110
- verbose,
1269
+ fullOutput,
1111
1270
  truncate,
1112
1271
  write,
1113
1272
  writeln,
@@ -1164,12 +1323,12 @@ async function serveImpl(
1164
1323
  const mwCtx: MiddlewareContext = {
1165
1324
  agent: !human,
1166
1325
  command: path,
1326
+ displayName,
1167
1327
  env: cliEnv,
1168
1328
  error: errorFn,
1169
1329
  format,
1170
1330
  formatExplicit,
1171
1331
  name,
1172
- options: rootOptions,
1173
1332
  set(key: string, value: unknown) {
1174
1333
  varsMap[key] = value
1175
1334
  },
@@ -1179,7 +1338,7 @@ async function serveImpl(
1179
1338
  const handleMwSentinel = (result: unknown) => {
1180
1339
  if (!isSentinel(result) || result[sentinel] !== 'error') return
1181
1340
  const err = result as ErrorResult
1182
- const cta = formatCtaBlock(name, err.cta)
1341
+ const cta = formatCtaBlock(displayName, err.cta)
1183
1342
  write({
1184
1343
  ok: false,
1185
1344
  error: {
@@ -1230,212 +1389,151 @@ async function serveImpl(
1230
1389
  ...((command.middleware as MiddlewareHandler[] | undefined) ?? []),
1231
1390
  ]
1232
1391
 
1233
- // Initialize vars from schema defaults
1234
- const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1235
- const envSource = options.env ?? process.env
1236
-
1237
- const runCommand = async () => {
1238
- const { args, options: parsedOptions } = Parser.parse(rest, {
1239
- alias: command.alias as Record<string, string> | undefined,
1240
- args: command.args,
1241
- options: command.options,
1242
- })
1243
-
1244
- if (human)
1245
- emitDeprecationWarnings(
1246
- rest,
1247
- command.options,
1248
- command.alias as Record<string, string> | undefined,
1249
- )
1250
-
1251
- const env = command.env ? Parser.parseEnv(command.env, envSource) : {}
1252
-
1253
- const okFn = (data: unknown, meta: { cta?: CtaBlock | undefined } = {}): never => {
1254
- return { [sentinel]: 'ok', data, cta: meta.cta } as never
1255
- }
1256
- const errorFn = (opts: {
1257
- code: string
1258
- exitCode?: number | undefined
1259
- message: string
1260
- retryable?: boolean | undefined
1261
- cta?: CtaBlock | undefined
1262
- }): never => {
1263
- return { [sentinel]: 'error', ...opts } as never
1264
- }
1265
-
1266
- const result = command.run({
1267
- agent: !human,
1268
- args,
1269
- env,
1270
- error: errorFn,
1271
- format,
1272
- formatExplicit,
1273
- name,
1274
- ok: okFn,
1275
- options: parsedOptions,
1276
- var: varsMap,
1277
- version: options.version,
1278
- })
1392
+ if (human)
1393
+ emitDeprecationWarnings(
1394
+ rest,
1395
+ command.options,
1396
+ command.alias as Record<string, string> | undefined,
1397
+ )
1279
1398
 
1280
- // Streaming path async generator
1281
- if (isAsyncGenerator(result)) {
1282
- await handleStreaming(result, {
1283
- name,
1284
- path,
1285
- start,
1286
- format,
1287
- formatExplicit,
1288
- human,
1289
- renderOutput,
1290
- verbose,
1291
- truncate,
1292
- write,
1293
- writeln,
1294
- exit,
1399
+ let defaults: Record<string, unknown> | undefined
1400
+ if (configEnabled) {
1401
+ try {
1402
+ defaults = await loadCommandOptionDefaults(name, path, {
1403
+ configDisabled,
1404
+ configPath,
1405
+ files: options.config?.files,
1406
+ loader: options.config?.loader,
1295
1407
  })
1296
- return
1297
- }
1298
-
1299
- const awaited = await result
1300
-
1301
- if (isSentinel(awaited)) {
1302
- const cta = formatCtaBlock(name, awaited.cta)
1303
- if (awaited[sentinel] === 'ok') {
1304
- write({
1305
- ok: true,
1306
- data: awaited.data,
1307
- meta: {
1308
- command: path,
1309
- duration: `${Math.round(performance.now() - start)}ms`,
1310
- ...(cta ? { cta } : undefined),
1311
- },
1312
- })
1313
- } else {
1314
- const err = awaited as ErrorResult
1315
- write({
1316
- ok: false,
1317
- error: {
1318
- code: err.code,
1319
- message: err.message,
1320
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1321
- },
1322
- meta: {
1323
- command: path,
1324
- duration: `${Math.round(performance.now() - start)}ms`,
1325
- ...(cta ? { cta } : undefined),
1326
- },
1327
- })
1328
- exit(err.exitCode ?? 1)
1329
- }
1330
- } else {
1408
+ } catch (error) {
1331
1409
  write({
1332
- ok: true,
1333
- data: awaited,
1334
- meta: {
1335
- command: path,
1336
- duration: `${Math.round(performance.now() - start)}ms`,
1410
+ ok: false,
1411
+ error: {
1412
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1413
+ message: error instanceof Error ? error.message : String(error),
1337
1414
  },
1415
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1338
1416
  })
1417
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1418
+ return
1339
1419
  }
1340
1420
  }
1341
1421
 
1342
- try {
1343
- const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, envSource) : {}
1422
+ const result = await Command.execute(command, {
1423
+ agent: !human,
1424
+ argv: rest,
1425
+ defaults,
1426
+ displayName,
1427
+ env: options.envSchema,
1428
+ envSource: options.env,
1429
+ format,
1430
+ formatExplicit,
1431
+ inputOptions: {},
1432
+ middlewares: allMiddleware,
1433
+ name,
1434
+ path,
1435
+ vars: options.vars,
1436
+ version: options.version,
1437
+ })
1344
1438
 
1345
- if (allMiddleware.length > 0) {
1346
- const errorFn = (opts: {
1347
- code: string
1348
- exitCode?: number | undefined
1349
- message: string
1350
- retryable?: boolean | undefined
1351
- cta?: CtaBlock | undefined
1352
- }): never => {
1353
- return { [sentinel]: 'error', ...opts } as never
1354
- }
1355
- const mwCtx: MiddlewareContext = {
1356
- agent: !human,
1439
+ const duration = `${Math.round(performance.now() - start)}ms`
1440
+
1441
+ // Streaming path — async generator
1442
+ if ('stream' in result) {
1443
+ await handleStreaming(result.stream, {
1444
+ name: displayName,
1445
+ path,
1446
+ start,
1447
+ format,
1448
+ formatExplicit,
1449
+ human,
1450
+ renderOutput,
1451
+ fullOutput,
1452
+ truncate,
1453
+ write,
1454
+ writeln,
1455
+ exit,
1456
+ })
1457
+ return
1458
+ }
1459
+
1460
+ if (result.ok) {
1461
+ const cta = formatCtaBlock(displayName, result.cta as CtaBlock | undefined)
1462
+ write({
1463
+ ok: true,
1464
+ data: result.data,
1465
+ meta: {
1357
1466
  command: path,
1358
- env: cliEnv,
1359
- error: errorFn,
1360
- format,
1361
- formatExplicit,
1362
- name,
1363
- options: rootOptions,
1364
- set(key: string, value: unknown) {
1365
- varsMap[key] = value
1366
- },
1367
- var: varsMap,
1368
- version: options.version,
1369
- }
1370
- const handleMwSentinel = (result: unknown) => {
1371
- if (!isSentinel(result) || result[sentinel] !== 'error') return
1372
- const err = result as ErrorResult
1373
- const cta = formatCtaBlock(name, err.cta)
1374
- write({
1375
- ok: false,
1376
- error: {
1377
- code: err.code,
1378
- message: err.message,
1379
- ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1380
- },
1381
- meta: {
1382
- command: path,
1383
- duration: `${Math.round(performance.now() - start)}ms`,
1384
- ...(cta ? { cta } : undefined),
1385
- },
1386
- })
1387
- exit(err.exitCode ?? 1)
1388
- }
1389
- const composed = allMiddleware.reduceRight(
1390
- (next: () => Promise<void>, mw) => async () => {
1391
- handleMwSentinel(await mw(mwCtx, next))
1392
- },
1393
- runCommand,
1467
+ duration,
1468
+ ...(cta ? { cta } : undefined),
1469
+ },
1470
+ })
1471
+ } else {
1472
+ const cta = formatCtaBlock(displayName, result.cta as CtaBlock | undefined)
1473
+
1474
+ if (human && !formatExplicit && result.error.fieldErrors) {
1475
+ writeln(
1476
+ formatHumanValidationError(
1477
+ displayName,
1478
+ path,
1479
+ command,
1480
+ new ValidationError({
1481
+ message: result.error.message,
1482
+ fieldErrors: result.error.fieldErrors,
1483
+ }),
1484
+ options.env,
1485
+ configFlag,
1486
+ ),
1394
1487
  )
1395
- await composed()
1396
- } else {
1397
- await runCommand()
1488
+ exit(1)
1489
+ return
1398
1490
  }
1399
- } catch (error) {
1400
- const errorOutput: Output = {
1491
+
1492
+ write({
1401
1493
  ok: false,
1402
1494
  error: {
1403
- code:
1404
- error instanceof IncurError
1405
- ? error.code
1406
- : error instanceof ValidationError
1407
- ? 'VALIDATION_ERROR'
1408
- : 'UNKNOWN',
1409
- message: error instanceof Error ? error.message : String(error),
1410
- ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
1411
- ...(error instanceof ValidationError ? { fieldErrors: error.fieldErrors } : undefined),
1495
+ code: result.error.code,
1496
+ message: result.error.message,
1497
+ ...(result.error.retryable !== undefined
1498
+ ? { retryable: result.error.retryable }
1499
+ : undefined),
1500
+ ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined),
1412
1501
  },
1413
1502
  meta: {
1414
1503
  command: path,
1415
- duration: `${Math.round(performance.now() - start)}ms`,
1504
+ duration,
1505
+ ...(cta ? { cta } : undefined),
1416
1506
  },
1417
- }
1418
-
1419
- if (human && !formatExplicit && error instanceof ValidationError) {
1420
- writeln(formatHumanValidationError(name, path, command, error, options.env))
1421
- exit(1)
1422
- return
1423
- }
1424
-
1425
- write(errorOutput)
1426
- exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1507
+ })
1508
+ exit(result.exitCode ?? 1)
1427
1509
  }
1428
1510
  }
1429
1511
 
1430
1512
  /** @internal Options for fetchImpl. */
1431
1513
  declare namespace fetchImpl {
1432
1514
  type Options = {
1515
+ /** CLI-level env schema. */
1516
+ envSchema?: z.ZodObject<any> | undefined
1517
+ /** Group-level middleware collected during command resolution. */
1518
+ groupMiddlewares?: MiddlewareHandler[] | undefined
1433
1519
  mcpHandler?:
1434
- | ((req: Request, commands: Map<string, CommandEntry>) => Promise<Response>)
1520
+ | ((
1521
+ req: Request,
1522
+ commands: Map<string, CommandEntry>,
1523
+ mcpOptions?: {
1524
+ middlewares?: MiddlewareHandler[] | undefined
1525
+ env?: z.ZodObject<any> | undefined
1526
+ vars?: z.ZodObject<any> | undefined
1527
+ },
1528
+ ) => Promise<Response>)
1435
1529
  | undefined
1436
1530
  middlewares?: MiddlewareHandler[] | undefined
1531
+ /** CLI name. */
1532
+ name?: string | undefined
1437
1533
  rootCommand?: CommandDefinition<any, any, any> | undefined
1438
1534
  vars?: z.ZodObject<any> | undefined
1535
+ /** CLI version string. */
1536
+ version?: string | undefined
1439
1537
  }
1440
1538
  }
1441
1539
 
@@ -1443,11 +1541,18 @@ declare namespace fetchImpl {
1443
1541
  function createMcpHttpHandler(name: string, version: string) {
1444
1542
  let transport: any
1445
1543
 
1446
- return async (req: Request, commands: Map<string, CommandEntry>): Promise<Response> => {
1544
+ return async (
1545
+ req: Request,
1546
+ commands: Map<string, CommandEntry>,
1547
+ mcpOptions?: {
1548
+ middlewares?: MiddlewareHandler[] | undefined
1549
+ env?: z.ZodObject<any> | undefined
1550
+ vars?: z.ZodObject<any> | undefined
1551
+ },
1552
+ ): Promise<Response> => {
1447
1553
  if (!transport) {
1448
- const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
1449
- const { WebStandardStreamableHTTPServerTransport } =
1450
- await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js')
1554
+ const { McpServer, WebStandardStreamableHTTPServerTransport } =
1555
+ await import('@modelcontextprotocol/server')
1451
1556
 
1452
1557
  const server = new McpServer({ name, version })
1453
1558
 
@@ -1462,11 +1567,17 @@ function createMcpHttpHandler(name: string, version: string) {
1462
1567
  tool.name,
1463
1568
  {
1464
1569
  ...(tool.description ? { description: tool.description } : undefined),
1465
- ...(hasInput ? { inputSchema: mergedShape } : undefined),
1570
+ ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
1466
1571
  },
1467
1572
  async (...callArgs: any[]) => {
1468
1573
  const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
1469
- return Mcp.callTool(tool, params)
1574
+ return Mcp.callTool(tool, params, {
1575
+ name,
1576
+ version,
1577
+ middlewares: mcpOptions?.middlewares,
1578
+ env: mcpOptions?.env,
1579
+ vars: mcpOptions?.vars,
1580
+ })
1470
1581
  },
1471
1582
  )
1472
1583
  }
@@ -1495,7 +1606,11 @@ async function fetchImpl(
1495
1606
 
1496
1607
  // MCP over HTTP: route /mcp to the MCP transport
1497
1608
  if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler)
1498
- return options.mcpHandler(req, commands)
1609
+ return options.mcpHandler(req, commands, {
1610
+ middlewares: options.middlewares,
1611
+ env: options.envSchema,
1612
+ vars: options.vars,
1613
+ })
1499
1614
 
1500
1615
  // .well-known/skills/ — Agent Skills Discovery (RFC)
1501
1616
  if (
@@ -1505,7 +1620,7 @@ async function fetchImpl(
1505
1620
  req.method === 'GET'
1506
1621
  ) {
1507
1622
  const groups = new Map<string, string>()
1508
- const cmds = collectSkillCommands(commands, [], groups)
1623
+ const cmds = collectSkillCommands(commands, [], groups, options.rootCommand)
1509
1624
 
1510
1625
  // GET /.well-known/skills/index.json
1511
1626
  if (segments[2] === 'index.json' && segments.length === 3) {
@@ -1575,18 +1690,22 @@ async function fetchImpl(
1575
1690
 
1576
1691
  const resolved = resolveCommand(commands, segments)
1577
1692
 
1578
- if ('error' in resolved)
1693
+ if ('error' in resolved) {
1694
+ const parent = resolved.path ? `${name} ${resolved.path}` : name
1695
+ const suggestion = suggest(resolved.error, resolved.commands.keys())
1696
+ const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1579
1697
  return jsonResponse(
1580
1698
  {
1581
1699
  ok: false,
1582
1700
  error: {
1583
1701
  code: 'COMMAND_NOT_FOUND',
1584
- message: `'${resolved.error}' is not a command for '${resolved.path ? `${name} ${resolved.path}` : name}'.`,
1702
+ message: `'${resolved.error}' is not a command for '${parent}'.${didYouMean}`,
1585
1703
  },
1586
1704
  meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` },
1587
1705
  },
1588
1706
  404,
1589
1707
  )
1708
+ }
1590
1709
 
1591
1710
  if ('help' in resolved)
1592
1711
  return jsonResponse(
@@ -1604,7 +1723,11 @@ async function fetchImpl(
1604
1723
  if ('fetchGateway' in resolved) return resolved.fetchGateway.fetch(req)
1605
1724
 
1606
1725
  const { command, path, rest } = resolved
1607
- return executeCommand(path, command, rest, inputOptions, start, options)
1726
+ const groupMiddlewares = 'middlewares' in resolved ? resolved.middlewares : []
1727
+ return executeCommand(path, command, rest, inputOptions, start, {
1728
+ ...options,
1729
+ groupMiddlewares,
1730
+ })
1608
1731
  }
1609
1732
 
1610
1733
  /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
@@ -1623,201 +1746,107 @@ async function executeCommand(
1623
1746
  })
1624
1747
  }
1625
1748
 
1626
- const sentinel_ = Symbol.for('incur.sentinel')
1627
- const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1628
- let response: Response | undefined
1749
+ const allMiddleware = [
1750
+ ...(options.middlewares ?? []),
1751
+ ...((options.groupMiddlewares as MiddlewareHandler[] | undefined) ?? []),
1752
+ ...((command.middleware as MiddlewareHandler[] | undefined) ?? []),
1753
+ ]
1629
1754
 
1630
- const runCommand = async () => {
1631
- const { args } = Parser.parse(rest, { args: command.args })
1632
- const parsedOptions = command.options ? command.options.parse(inputOptions) : {}
1755
+ const result = await Command.execute(command, {
1756
+ agent: true,
1757
+ argv: rest,
1758
+ env: options.envSchema,
1759
+ format: 'json',
1760
+ formatExplicit: true,
1761
+ inputOptions,
1762
+ middlewares: allMiddleware,
1763
+ name: options.name ?? path,
1764
+ parseMode: 'split',
1765
+ path,
1766
+ vars: options.vars,
1767
+ version: options.version,
1768
+ })
1633
1769
 
1634
- const okFn = (data: unknown): never => ({ [sentinel_]: 'ok', data }) as never
1635
- const errorFn = (opts: {
1636
- code: string
1637
- message: string
1638
- exitCode?: number | undefined
1639
- }): never => ({ [sentinel_]: 'error', ...opts }) as never
1640
-
1641
- const result = command.run({
1642
- agent: true,
1643
- args,
1644
- env: {},
1645
- error: errorFn,
1646
- format: 'json',
1647
- formatExplicit: true,
1648
- name: path,
1649
- ok: okFn,
1650
- options: parsedOptions,
1651
- var: varsMap,
1652
- version: undefined,
1653
- })
1770
+ const duration = `${Math.round(performance.now() - start)}ms`
1654
1771
 
1655
- // Streaming path — async generator → NDJSON response
1656
- if (isAsyncGenerator(result)) {
1657
- const stream = new ReadableStream({
1658
- async start(controller) {
1659
- const encoder = new TextEncoder()
1660
- try {
1661
- let returnValue: unknown
1662
- while (true) {
1663
- const { value, done } = await result.next()
1664
- if (done) {
1665
- returnValue = value
1666
- break
1667
- }
1668
- if (isSentinel(value) && (value as any)[sentinel] === 'error') {
1669
- const tagged = value as any
1670
- controller.enqueue(
1671
- encoder.encode(
1672
- JSON.stringify({
1673
- type: 'error',
1674
- ok: false,
1675
- error: { code: tagged.code, message: tagged.message },
1676
- }) + '\n',
1677
- ),
1678
- )
1679
- controller.close()
1680
- return
1681
- }
1682
- controller.enqueue(
1683
- encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'),
1684
- )
1685
- }
1686
- const meta: Record<string, unknown> = { command: path }
1687
- if (isSentinel(returnValue) && (returnValue as any)[sentinel] === 'error') {
1688
- const tagged = returnValue as any
1689
- controller.enqueue(
1690
- encoder.encode(
1691
- JSON.stringify({
1692
- type: 'error',
1693
- ok: false,
1694
- error: { code: tagged.code, message: tagged.message },
1695
- }) + '\n',
1696
- ),
1697
- )
1698
- } else {
1699
- controller.enqueue(
1700
- encoder.encode(JSON.stringify({ type: 'done', ok: true, meta }) + '\n'),
1701
- )
1702
- }
1703
- } catch (error) {
1772
+ // Streaming path — async generator → NDJSON response
1773
+ if ('stream' in result) {
1774
+ const stream = new ReadableStream({
1775
+ async start(controller) {
1776
+ const encoder = new TextEncoder()
1777
+ try {
1778
+ for await (const value of result.stream) {
1704
1779
  controller.enqueue(
1705
- encoder.encode(
1706
- JSON.stringify({
1707
- type: 'error',
1708
- ok: false,
1709
- error: {
1710
- code: 'UNKNOWN',
1711
- message: error instanceof Error ? error.message : String(error),
1712
- },
1713
- }) + '\n',
1714
- ),
1780
+ encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'),
1715
1781
  )
1716
1782
  }
1717
- controller.close()
1718
- },
1719
- })
1720
- response = new Response(stream, {
1721
- status: 200,
1722
- headers: { 'content-type': 'application/x-ndjson' },
1723
- })
1724
- return
1725
- }
1726
-
1727
- const awaited = await result
1728
- const duration = `${Math.round(performance.now() - start)}ms`
1729
-
1730
- if (typeof awaited === 'object' && awaited !== null && sentinel_ in awaited) {
1731
- const tagged = awaited as any
1732
- if (tagged[sentinel_] === 'error')
1733
- response = jsonResponse(
1734
- {
1735
- ok: false,
1736
- error: { code: tagged.code, message: tagged.message },
1737
- meta: { command: path, duration },
1738
- },
1739
- 500,
1740
- )
1741
- else
1742
- response = jsonResponse(
1743
- { ok: true, data: tagged.data, meta: { command: path, duration } },
1744
- 200,
1745
- )
1746
- return
1747
- }
1748
-
1749
- response = jsonResponse({ ok: true, data: awaited, meta: { command: path, duration } }, 200)
1783
+ controller.enqueue(
1784
+ encoder.encode(
1785
+ JSON.stringify({
1786
+ type: 'done',
1787
+ ok: true,
1788
+ meta: { command: path },
1789
+ }) + '\n',
1790
+ ),
1791
+ )
1792
+ } catch (error) {
1793
+ controller.enqueue(
1794
+ encoder.encode(
1795
+ JSON.stringify({
1796
+ type: 'error',
1797
+ ok: false,
1798
+ error: {
1799
+ code: 'UNKNOWN',
1800
+ message: error instanceof Error ? error.message : String(error),
1801
+ },
1802
+ }) + '\n',
1803
+ ),
1804
+ )
1805
+ }
1806
+ controller.close()
1807
+ },
1808
+ })
1809
+ return new Response(stream, {
1810
+ status: 200,
1811
+ headers: { 'content-type': 'application/x-ndjson' },
1812
+ })
1750
1813
  }
1751
1814
 
1752
- try {
1753
- const allMiddleware = options.middlewares ?? []
1754
- if (allMiddleware.length > 0) {
1755
- const errorFn = (opts: {
1756
- code: string
1757
- message: string
1758
- exitCode?: number | undefined
1759
- }): never => {
1760
- const duration = `${Math.round(performance.now() - start)}ms`
1761
- response = jsonResponse(
1762
- {
1763
- ok: false,
1764
- error: { code: opts.code, message: opts.message },
1765
- meta: { command: path, duration },
1766
- },
1767
- 500,
1768
- )
1769
- return undefined as never
1770
- }
1771
- const mwCtx: MiddlewareContext = {
1772
- agent: true,
1773
- command: path,
1774
- env: {},
1775
- error: errorFn,
1776
- format: 'json',
1777
- formatExplicit: true,
1778
- name: path,
1779
- options: {},
1780
- set(key: string, value: unknown) {
1781
- varsMap[key] = value
1782
- },
1783
- var: varsMap,
1784
- version: undefined,
1785
- }
1786
- const composed = allMiddleware.reduceRight(
1787
- (next: () => Promise<void>, mw) => async () => {
1788
- await mw(mwCtx, next)
1789
- },
1790
- runCommand,
1791
- )
1792
- await composed()
1793
- } else {
1794
- await runCommand()
1795
- }
1796
- } catch (error) {
1797
- const duration = `${Math.round(performance.now() - start)}ms`
1798
- if (error instanceof ValidationError)
1799
- return jsonResponse(
1800
- {
1801
- ok: false,
1802
- error: { code: 'VALIDATION_ERROR', message: error.message },
1803
- meta: { command: path, duration },
1804
- },
1805
- 400,
1806
- )
1815
+ if (!result.ok) {
1816
+ const cta = formatCtaBlock(options.name ?? path, result.cta as CtaBlock | undefined)
1807
1817
  return jsonResponse(
1808
1818
  {
1809
1819
  ok: false,
1810
1820
  error: {
1811
- code: error instanceof IncurError ? error.code : 'UNKNOWN',
1812
- message: error instanceof Error ? error.message : String(error),
1821
+ code: result.error.code,
1822
+ message: result.error.message,
1823
+ ...(result.error.retryable !== undefined
1824
+ ? { retryable: result.error.retryable }
1825
+ : undefined),
1826
+ },
1827
+ meta: {
1828
+ command: path,
1829
+ duration,
1830
+ ...(cta ? { cta } : undefined),
1813
1831
  },
1814
- meta: { command: path, duration },
1815
1832
  },
1816
- 500,
1833
+ result.error.code === 'VALIDATION_ERROR' ? 400 : 500,
1817
1834
  )
1818
1835
  }
1819
1836
 
1820
- return response!
1837
+ const cta = formatCtaBlock(options.name ?? path, result.cta as CtaBlock | undefined)
1838
+ return jsonResponse(
1839
+ {
1840
+ ok: true,
1841
+ data: result.data,
1842
+ meta: {
1843
+ command: path,
1844
+ duration,
1845
+ ...(cta ? { cta } : undefined),
1846
+ },
1847
+ },
1848
+ 200,
1849
+ )
1821
1850
  }
1822
1851
 
1823
1852
  /** @internal Formats a validation error for TTY with usage hint. */
@@ -1827,6 +1856,7 @@ function formatHumanValidationError(
1827
1856
  command: CommandDefinition<any, any, any>,
1828
1857
  error: ValidationError,
1829
1858
  envSource?: Record<string, string | undefined>,
1859
+ configFlag?: string,
1830
1860
  ): string {
1831
1861
  const lines: string[] = []
1832
1862
  for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`)
@@ -1835,6 +1865,7 @@ function formatHumanValidationError(
1835
1865
  lines.push(
1836
1866
  Help.formatCommand(path === cli ? cli : `${cli} ${path}`, {
1837
1867
  alias: command.alias as Record<string, string> | undefined,
1868
+ configFlag,
1838
1869
  description: command.description,
1839
1870
  args: command.args,
1840
1871
  env: command.env,
@@ -1873,12 +1904,12 @@ function resolveCommand(
1873
1904
  description?: string | undefined
1874
1905
  commands: Map<string, CommandEntry>
1875
1906
  }
1876
- | { error: string; path: string } {
1907
+ | { error: string; path: string; commands: Map<string, CommandEntry>; rest: string[] } {
1877
1908
  const [first, ...rest] = tokens
1878
1909
 
1879
- if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '' }
1910
+ if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest }
1880
1911
 
1881
- let entry = commands.get(first)!
1912
+ let entry = resolveAlias(commands, commands.get(first)!)
1882
1913
  const path = [first]
1883
1914
  let remaining = rest
1884
1915
  let inheritedOutputPolicy: OutputPolicy | undefined
@@ -1908,10 +1939,16 @@ function resolveCommand(
1908
1939
  commands: entry.commands,
1909
1940
  }
1910
1941
 
1911
- const child = entry.commands.get(next)
1912
- if (!child) {
1913
- return { error: next, path: path.join(' ') }
1942
+ const rawChild = entry.commands.get(next)
1943
+ if (!rawChild) {
1944
+ return {
1945
+ error: next,
1946
+ path: path.join(' '),
1947
+ commands: entry.commands,
1948
+ rest: remaining.slice(1),
1949
+ }
1914
1950
  }
1951
+ let child = resolveAlias(entry.commands, rawChild)
1915
1952
 
1916
1953
  path.push(next)
1917
1954
  remaining = remaining.slice(1)
@@ -1944,6 +1981,20 @@ declare namespace serveImpl {
1944
1981
  type Options = serve.Options & {
1945
1982
  /** Alternative binary names for this CLI. */
1946
1983
  aliases?: string[] | undefined
1984
+ config?:
1985
+ | {
1986
+ flag?: string | undefined
1987
+ files?: string[] | undefined
1988
+ loader?:
1989
+ | ((
1990
+ path: string | undefined,
1991
+ ) =>
1992
+ | Record<string, unknown>
1993
+ | undefined
1994
+ | Promise<Record<string, unknown> | undefined>)
1995
+ | undefined
1996
+ }
1997
+ | undefined
1947
1998
  description?: string | undefined
1948
1999
  /** CLI-level env schema. Parsed before middleware runs. */
1949
2000
  envSchema?: z.ZodObject<any> | undefined
@@ -1951,8 +2002,6 @@ declare namespace serveImpl {
1951
2002
  format?: Formatter.Format | undefined
1952
2003
  /** Middleware handlers registered on the root CLI. */
1953
2004
  middlewares?: MiddlewareHandler[] | undefined
1954
- /** CLI-level options schema. Parsed before middleware runs. */
1955
- optionsSchema?: z.ZodObject<any> | undefined
1956
2005
  /** CLI-level default output policy. */
1957
2006
  outputPolicy?: OutputPolicy | undefined
1958
2007
  mcp?:
@@ -1979,9 +2028,11 @@ declare namespace serveImpl {
1979
2028
  }
1980
2029
  }
1981
2030
 
1982
- /** @internal Extracts built-in flags (--verbose, --format, --json, --llms, --help, --version) from argv. */
1983
- function extractBuiltinFlags(argv: string[]) {
1984
- let verbose = false
2031
+ /** @internal Extracts built-in flags (--full-output, --format, --json, --llms, --help, --version) from argv. */
2032
+ const validFormats = new Set(['toon', 'json', 'yaml', 'md', 'jsonl'] as const)
2033
+
2034
+ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Options = {}) {
2035
+ let fullOutput = false
1985
2036
  let llms = false
1986
2037
  let llmsFull = false
1987
2038
  let mcp = false
@@ -1990,15 +2041,21 @@ function extractBuiltinFlags(argv: string[]) {
1990
2041
  let schema = false
1991
2042
  let format: Formatter.Format = 'toon'
1992
2043
  let formatExplicit = false
2044
+ let configPath: string | undefined
2045
+ let configDisabled = false
1993
2046
  let filterOutput: string | undefined
1994
2047
  let tokenLimit: number | undefined
1995
2048
  let tokenOffset: number | undefined
1996
2049
  let tokenCount = false
1997
2050
  const rest: string[] = []
1998
2051
 
2052
+ const cfgFlag = options.configFlag ? `--${options.configFlag}` : undefined
2053
+ const cfgFlagEq = options.configFlag ? `--${options.configFlag}=` : undefined
2054
+ const noCfgFlag = options.configFlag ? `--no-${options.configFlag}` : undefined
2055
+
1999
2056
  for (let i = 0; i < argv.length; i++) {
2000
2057
  const token = argv[i]!
2001
- if (token === '--verbose') verbose = true
2058
+ if (token === '--full-output') fullOutput = true
2002
2059
  else if (token === '--llms') llms = true
2003
2060
  else if (token === '--llms-full') llmsFull = true
2004
2061
  else if (token === '--mcp') mcp = true
@@ -2009,26 +2066,54 @@ function extractBuiltinFlags(argv: string[]) {
2009
2066
  format = 'json'
2010
2067
  formatExplicit = true
2011
2068
  } else if (token === '--format' && argv[i + 1]) {
2069
+ if (!validFormats.has(argv[i + 1]! as any))
2070
+ throw new ParseError({
2071
+ message: `Invalid format: "${argv[i + 1]}". Expected one of: ${[...validFormats].join(', ')}`,
2072
+ })
2012
2073
  format = argv[i + 1] as Formatter.Format
2013
2074
  formatExplicit = true
2014
2075
  i++
2076
+ } else if (cfgFlag && token === cfgFlag) {
2077
+ const value = argv[i + 1]
2078
+ if (value === undefined)
2079
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
2080
+ configPath = value
2081
+ configDisabled = false
2082
+ i++
2083
+ } else if (cfgFlagEq && token.startsWith(cfgFlagEq)) {
2084
+ const value = token.slice(cfgFlagEq.length)
2085
+ if (value.length === 0)
2086
+ throw new ParseError({ message: `Missing value for flag: ${cfgFlag}` })
2087
+ configPath = value
2088
+ configDisabled = false
2089
+ } else if (noCfgFlag && token === noCfgFlag) {
2090
+ configPath = undefined
2091
+ configDisabled = true
2015
2092
  } else if (token === '--filter-output' && argv[i + 1]) {
2016
2093
  filterOutput = argv[i + 1]!
2017
2094
  i++
2018
2095
  } else if (token === '--token-limit' && argv[i + 1]) {
2019
- tokenLimit = Number(argv[i + 1])
2096
+ const n = Number(argv[i + 1])
2097
+ if (!Number.isFinite(n) || argv[i + 1]!.trim() === '')
2098
+ throw new ParseError({ message: `Invalid value for --token-limit: "${argv[i + 1]}"` })
2099
+ tokenLimit = n
2020
2100
  i++
2021
2101
  } else if (token === '--token-offset' && argv[i + 1]) {
2022
- tokenOffset = Number(argv[i + 1])
2102
+ const n = Number(argv[i + 1])
2103
+ if (!Number.isFinite(n) || argv[i + 1]!.trim() === '')
2104
+ throw new ParseError({ message: `Invalid value for --token-offset: "${argv[i + 1]}"` })
2105
+ tokenOffset = n
2023
2106
  i++
2024
2107
  } else if (token === '--token-count') tokenCount = true
2025
2108
  else rest.push(token)
2026
2109
  }
2027
2110
 
2028
2111
  return {
2029
- verbose,
2112
+ fullOutput,
2030
2113
  format,
2031
2114
  formatExplicit,
2115
+ configPath,
2116
+ configDisabled,
2032
2117
  filterOutput,
2033
2118
  tokenLimit,
2034
2119
  tokenOffset,
@@ -2043,17 +2128,191 @@ function extractBuiltinFlags(argv: string[]) {
2043
2128
  }
2044
2129
  }
2045
2130
 
2131
+ declare namespace extractBuiltinFlags {
2132
+ type Options = {
2133
+ configFlag?: string | undefined
2134
+ }
2135
+ }
2136
+
2137
+ /** @internal Loads config-backed option defaults for the active command. */
2138
+ async function loadCommandOptionDefaults(
2139
+ cli: string,
2140
+ path: string,
2141
+ options: loadCommandOptionDefaults.Options = {},
2142
+ ): Promise<Record<string, unknown> | undefined> {
2143
+ if (options.configDisabled) return undefined
2144
+
2145
+ const { loader } = options
2146
+
2147
+ // Resolve the target file path
2148
+ let targetPath: string | undefined
2149
+ if (options.configPath) {
2150
+ targetPath = resolveConfigPath(options.configPath)
2151
+ } else {
2152
+ const searchPaths = options.files ?? [`${cli}.json`]
2153
+ targetPath = await findFirstExisting(searchPaths)
2154
+ }
2155
+
2156
+ // Load and parse the config
2157
+ let parsed: Record<string, unknown>
2158
+ if (loader) {
2159
+ const result = await loader(targetPath)
2160
+ if (result === undefined) return undefined
2161
+ if (!isRecord(result))
2162
+ throw new ParseError({ message: 'Config loader must return a plain object or undefined' })
2163
+ parsed = result
2164
+ } else {
2165
+ if (!targetPath) return undefined
2166
+ const result = await readJsonConfig(targetPath, !!options.configPath)
2167
+ if (!result) return undefined
2168
+ parsed = result
2169
+ }
2170
+
2171
+ // Extract the command section from the config tree
2172
+ return extractCommandSection(parsed, cli, path)
2173
+ }
2174
+
2175
+ declare namespace loadCommandOptionDefaults {
2176
+ type Options = {
2177
+ configDisabled?: boolean | undefined
2178
+ configPath?: string | undefined
2179
+ files?: string[] | undefined
2180
+ loader?:
2181
+ | ((
2182
+ path: string | undefined,
2183
+ ) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>)
2184
+ | undefined
2185
+ }
2186
+ }
2187
+
2188
+ /** @internal Resolves a config file path, expanding `~` to home dir. */
2189
+ function resolveConfigPath(filePath: string): string {
2190
+ if (filePath.startsWith('~/') || filePath === '~') {
2191
+ return path.join(os.homedir(), filePath.slice(1))
2192
+ }
2193
+ return path.resolve(process.cwd(), filePath)
2194
+ }
2195
+
2196
+ /** @internal Returns the first readable file from a list of paths, or `undefined`. */
2197
+ async function findFirstExisting(paths: string[]): Promise<string | undefined> {
2198
+ for (const p of paths) {
2199
+ const resolved = resolveConfigPath(p)
2200
+ try {
2201
+ await fs.access(resolved, fs.constants.R_OK)
2202
+ return resolved
2203
+ } catch {}
2204
+ }
2205
+ return undefined
2206
+ }
2207
+
2208
+ /** @internal Reads and parses a JSON config file. */
2209
+ async function readJsonConfig(
2210
+ targetPath: string,
2211
+ explicit: boolean,
2212
+ ): Promise<Record<string, unknown> | undefined> {
2213
+ let raw: string
2214
+ try {
2215
+ raw = await fs.readFile(targetPath, 'utf8')
2216
+ } catch (error) {
2217
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
2218
+ if (explicit) throw new ParseError({ message: `Config file not found: ${targetPath}` })
2219
+ return undefined
2220
+ }
2221
+ throw error
2222
+ }
2223
+
2224
+ let parsed: unknown
2225
+ try {
2226
+ parsed = JSON.parse(raw)
2227
+ } catch (error) {
2228
+ throw new ParseError({
2229
+ message: `Invalid JSON config file: ${targetPath}`,
2230
+ cause: error instanceof Error ? error : undefined,
2231
+ })
2232
+ }
2233
+
2234
+ if (!isRecord(parsed))
2235
+ throw new ParseError({
2236
+ message: `Invalid config file: expected a top-level object in ${targetPath}`,
2237
+ })
2238
+ return parsed
2239
+ }
2240
+
2241
+ /** @internal Walks the nested config tree to extract option defaults for a command path. */
2242
+ function extractCommandSection(
2243
+ parsed: Record<string, unknown>,
2244
+ cli: string,
2245
+ path: string,
2246
+ ): Record<string, unknown> | undefined {
2247
+ const segments = path === cli ? [] : path.split(' ')
2248
+ let node: unknown = parsed
2249
+ for (const seg of segments) {
2250
+ if (!isRecord(node)) return undefined
2251
+ const commands = node.commands
2252
+ if (!isRecord(commands)) return undefined
2253
+ node = commands[seg]
2254
+ if (node === undefined) return undefined
2255
+ }
2256
+ if (!isRecord(node))
2257
+ throw new ParseError({
2258
+ message: `Invalid config section for '${path}': expected an object`,
2259
+ })
2260
+
2261
+ const options = node.options
2262
+ if (options === undefined) return undefined
2263
+ if (!isRecord(options))
2264
+ throw new ParseError({
2265
+ message: `Invalid config 'options' for '${path}': expected an object`,
2266
+ })
2267
+ return Object.keys(options).length > 0 ? options : undefined
2268
+ }
2269
+
2046
2270
  /** @internal Collects immediate child commands/groups for help output. */
2047
2271
  function collectHelpCommands(
2048
2272
  commands: Map<string, CommandEntry>,
2049
2273
  ): { name: string; description?: string | undefined }[] {
2050
2274
  const result: { name: string; description?: string | undefined }[] = []
2051
2275
  for (const [name, entry] of commands) {
2276
+ if (isAlias(entry)) continue
2052
2277
  result.push({ name, description: entry.description })
2053
2278
  }
2054
2279
  return result.sort((a, b) => a.name.localeCompare(b.name))
2055
2280
  }
2056
2281
 
2282
+ /** @internal Finds the index of a builtin command token in the filtered argv. Returns -1 if not found. */
2283
+ function builtinIdx(filtered: string[], cliName: string, builtin: string): number {
2284
+ // e.g. `skills add` or `skill add`
2285
+ if (findBuiltin(filtered[0]!)?.name === builtin) return 0
2286
+ // e.g. `my-cli skills add`
2287
+ if (filtered[0] === cliName && findBuiltin(filtered[1]!)?.name === builtin) return 1
2288
+ // not a match
2289
+ return -1
2290
+ }
2291
+
2292
+ /** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */
2293
+ function formatBuiltinHelp(cli: string, builtin: (typeof builtinCommands)[number]): string {
2294
+ return Help.formatRoot(`${cli} ${builtin.name}`, {
2295
+ aliases: builtin.aliases,
2296
+ description: builtin.description,
2297
+ commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })),
2298
+ })
2299
+ }
2300
+
2301
+ /** @internal Formats subcommand-level help for a built-in command (e.g. `cli skills add --help`). */
2302
+ function formatBuiltinSubcommandHelp(
2303
+ cli: string,
2304
+ builtin: (typeof builtinCommands)[number],
2305
+ subName: string,
2306
+ ): string {
2307
+ const sub = builtin.subcommands?.find((s) => s.name === subName)
2308
+ return Help.formatCommand(`${cli} ${builtin.name} ${subName}`, {
2309
+ alias: sub?.alias,
2310
+ description: sub?.description,
2311
+ hideGlobalOptions: true,
2312
+ options: sub?.options,
2313
+ })
2314
+ }
2315
+
2057
2316
  /** @internal Formats help text for a fetch gateway command. */
2058
2317
  function formatFetchHelp(name: string, description?: string): string {
2059
2318
  const lines: string[] = []
@@ -2080,7 +2339,11 @@ export type CommandsMap = Record<
2080
2339
  >
2081
2340
 
2082
2341
  /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */
2083
- type CommandEntry = CommandDefinition<any, any, any> | InternalGroup | InternalFetchGateway
2342
+ type CommandEntry =
2343
+ | CommandDefinition<any, any, any>
2344
+ | InternalGroup
2345
+ | InternalFetchGateway
2346
+ | InternalAlias
2084
2347
 
2085
2348
  /** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */
2086
2349
  export type OutputPolicy = 'agent-only' | 'all'
@@ -2116,6 +2379,27 @@ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
2116
2379
  return '_fetch' in entry
2117
2380
  }
2118
2381
 
2382
+ /** @internal An alias entry that points to another command by name. */
2383
+ type InternalAlias = {
2384
+ _alias: true
2385
+ /** The canonical command name this alias resolves to. */
2386
+ target: string
2387
+ }
2388
+
2389
+ /** @internal Type guard for alias entries. */
2390
+ function isAlias(entry: CommandEntry): entry is InternalAlias {
2391
+ return '_alias' in entry
2392
+ }
2393
+
2394
+ /** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */
2395
+ function resolveAlias(
2396
+ commands: Map<string, CommandEntry>,
2397
+ entry: CommandEntry,
2398
+ ): Exclude<CommandEntry, InternalAlias> {
2399
+ if (isAlias(entry)) return commands.get(entry.target)! as Exclude<CommandEntry, InternalAlias>
2400
+ return entry
2401
+ }
2402
+
2119
2403
  /** @internal Maps CLI instances to their command maps. */
2120
2404
  export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
2121
2405
 
@@ -2123,11 +2407,20 @@ export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
2123
2407
  const toMiddlewares = new WeakMap<Cli, MiddlewareHandler[]>()
2124
2408
 
2125
2409
  /** @internal Maps root CLI instances to their command definitions. */
2126
- const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2410
+ export const toRootDefinition = new WeakMap<Root, CommandDefinition<any, any, any>>()
2411
+
2412
+ /** @internal Maps CLI instances to their root options schema. */
2413
+ export const toRootOptions = new WeakMap<Cli, z.ZodObject<any>>()
2414
+
2415
+ /** @internal Maps CLI instances to whether config file loading is enabled. */
2416
+ export const toConfigEnabled = new WeakMap<Cli, boolean>()
2127
2417
 
2128
2418
  /** @internal Maps CLI instances to their output policy. */
2129
2419
  const toOutputPolicy = new WeakMap<Cli, OutputPolicy>()
2130
2420
 
2421
+ /** @internal Maps root CLI instances to their command aliases. */
2422
+ const toRootAliases = new WeakMap<Root, string[]>()
2423
+
2131
2424
  /** @internal Sentinel symbol for `ok()` and `error()` return values. */
2132
2425
  const sentinel = Symbol.for('incur.sentinel')
2133
2426
 
@@ -2152,7 +2445,7 @@ type ErrorResult = {
2152
2445
  type CtaBlock<commands extends CommandsMap = Commands> = {
2153
2446
  /** Commands to suggest. */
2154
2447
  commands: Cta<commands>[]
2155
- /** Human-readable label. Defaults to `"Suggested commands:"`. */
2448
+ /** Human-readable label. Defaults to `"Suggested command:"` or `"Suggested commands:"` based on count. */
2156
2449
  description?: string | undefined
2157
2450
  }
2158
2451
 
@@ -2174,8 +2467,9 @@ function formatHumanError(error: {
2174
2467
  /** @internal Formats a CTA block for human-readable TTY output. */
2175
2468
  function formatHumanCta(cta: FormattedCtaBlock): string {
2176
2469
  const lines: string[] = ['', cta.description]
2470
+ const maxLen = Math.max(...cta.commands.map((c) => c.command.length))
2177
2471
  for (const c of cta.commands) {
2178
- const desc = c.description ? ` # ${c.description}` : ''
2472
+ const desc = c.description ? ` ${''.padEnd(maxLen - c.command.length)}# ${c.description}` : ''
2179
2473
  lines.push(` ${c.command}${desc}`)
2180
2474
  }
2181
2475
  return lines.join('\n')
@@ -2190,16 +2484,6 @@ function isSentinel(value: unknown): value is OkResult | ErrorResult {
2190
2484
  return typeof value === 'object' && value !== null && sentinel in value
2191
2485
  }
2192
2486
 
2193
- /** @internal Type guard for async generators returned by streaming `run` handlers. */
2194
- function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
2195
- return (
2196
- typeof value === 'object' &&
2197
- value !== null &&
2198
- Symbol.asyncIterator in value &&
2199
- typeof (value as any).next === 'function'
2200
- )
2201
- }
2202
-
2203
2487
  /** @internal Handles streaming output from an async generator `run` handler. */
2204
2488
  async function handleStreaming(
2205
2489
  generator: AsyncGenerator<unknown, unknown, unknown>,
@@ -2211,7 +2495,7 @@ async function handleStreaming(
2211
2495
  formatExplicit: boolean
2212
2496
  human: boolean
2213
2497
  renderOutput: boolean
2214
- verbose: boolean
2498
+ fullOutput: boolean
2215
2499
  truncate: (s: string) => { text: string; truncated: boolean; nextOffset?: number | undefined }
2216
2500
  write: (output: Output) => void
2217
2501
  writeln: (s: string) => void
@@ -2405,7 +2689,9 @@ async function handleStreaming(
2405
2689
  function formatCtaBlock(name: string, block: CtaBlock | undefined): FormattedCtaBlock | undefined {
2406
2690
  if (!block || block.commands.length === 0) return undefined
2407
2691
  return {
2408
- description: block.description ?? 'Suggested commands:',
2692
+ description:
2693
+ block.description ??
2694
+ (block.commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'),
2409
2695
  commands: block.commands.map((c) => formatCta(name, c)),
2410
2696
  }
2411
2697
  }
@@ -2439,6 +2725,7 @@ function collectIndexCommands(
2439
2725
  ): { name: string; description?: string | undefined }[] {
2440
2726
  const result: { name: string; description?: string | undefined }[] = []
2441
2727
  for (const [name, entry] of commands) {
2728
+ if (isAlias(entry)) continue
2442
2729
  const path = [...prefix, name]
2443
2730
  if (isGroup(entry)) {
2444
2731
  result.push(...collectIndexCommands(entry.commands, path))
@@ -2473,6 +2760,7 @@ function collectCommands(
2473
2760
  }[] {
2474
2761
  const result: ReturnType<typeof collectCommands> = []
2475
2762
  for (const [name, entry] of commands) {
2763
+ if (isAlias(entry)) continue
2476
2764
  const path = [...prefix, name]
2477
2765
  if (isFetchGateway(entry)) {
2478
2766
  const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -2513,9 +2801,23 @@ function collectSkillCommands(
2513
2801
  commands: Map<string, CommandEntry>,
2514
2802
  prefix: string[],
2515
2803
  groups: Map<string, string>,
2804
+ rootCommand?: CommandDefinition<any, any, any> | undefined,
2516
2805
  ): Skill.CommandInfo[] {
2517
2806
  const result: Skill.CommandInfo[] = []
2807
+ if (rootCommand) {
2808
+ const cmd: Skill.CommandInfo = {}
2809
+ if (rootCommand.description) cmd.description = rootCommand.description
2810
+ if (rootCommand.args) cmd.args = rootCommand.args
2811
+ if (rootCommand.env) cmd.env = rootCommand.env
2812
+ if (rootCommand.hint) cmd.hint = rootCommand.hint
2813
+ if (rootCommand.options) cmd.options = rootCommand.options
2814
+ if (rootCommand.output) cmd.output = rootCommand.output
2815
+ const examples = formatExamples(rootCommand.examples)
2816
+ if (examples) cmd.examples = examples
2817
+ result.push(cmd)
2818
+ }
2518
2819
  for (const [name, entry] of commands) {
2820
+ if (isAlias(entry)) continue
2519
2821
  const path = [...prefix, name]
2520
2822
  if (isFetchGateway(entry)) {
2521
2823
  const cmd: Skill.CommandInfo = { name: path.join(' ') }
@@ -2544,7 +2846,7 @@ function collectSkillCommands(
2544
2846
  result.push(cmd)
2545
2847
  }
2546
2848
  }
2547
- return result.sort((a, b) => a.name.localeCompare(b.name))
2849
+ return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
2548
2850
  }
2549
2851
 
2550
2852
  /** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */
@@ -2684,15 +2986,11 @@ type CommandDefinition<
2684
2986
  output extends z.ZodType | undefined = undefined,
2685
2987
  vars extends z.ZodObject<any> | undefined = undefined,
2686
2988
  cliEnv extends z.ZodObject<any> | undefined = undefined,
2687
- > = {
2688
- /** Map of option names to single-char aliases. */
2689
- alias?: options extends z.ZodObject<any>
2690
- ? Partial<Record<keyof z.output<options>, string>>
2691
- : Record<string, string> | undefined
2989
+ > = CommandMeta<options> & {
2990
+ /** Alternative names for this command (e.g. `['extensions', 'ext']` for an `extension` command). */
2991
+ aliases?: string[] | undefined
2692
2992
  /** Zod schema for positional arguments. */
2693
2993
  args?: args | undefined
2694
- /** A short description of what the command does. */
2695
- description?: string | undefined
2696
2994
  /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */
2697
2995
  env?: env | undefined
2698
2996
  /** Usage examples for this command. */
@@ -2701,8 +2999,6 @@ type CommandDefinition<
2701
2999
  format?: Formatter.Format | undefined
2702
3000
  /** Plain text hint displayed after examples and before global options. */
2703
3001
  hint?: string | undefined
2704
- /** Zod schema for named options/flags. */
2705
- options?: options | undefined
2706
3002
  /** Zod schema for the command's return value. */
2707
3003
  output?: output | undefined
2708
3004
  /**
@@ -2724,6 +3020,8 @@ type CommandDefinition<
2724
3020
  agent: boolean
2725
3021
  /** Positional arguments. */
2726
3022
  args: InferOutput<args>
3023
+ /** The binary name the user invoked (e.g. an alias). Falls back to `name` when not resolvable. */
3024
+ displayName: string
2727
3025
  /** Parsed environment variables. */
2728
3026
  env: InferOutput<env>
2729
3027
  /** Return an error result with optional CTAs. */
@@ -2801,3 +3099,13 @@ function emitDeprecationWarnings(
2801
3099
  }
2802
3100
  }
2803
3101
  }
3102
+
3103
+ /** @internal Resolves the display name from `process.argv[1]` basename. Returns the basename if it matches `name` or one of the `aliases`, otherwise falls back to `name`. */
3104
+ function resolveDisplayName(name: string, aliases?: string[]): string {
3105
+ const bin = process.argv[1]
3106
+ if (!bin) return name
3107
+ const basename = path.basename(bin)
3108
+ if (basename === name) return name
3109
+ if (aliases?.includes(basename)) return basename
3110
+ return name
3111
+ }