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.test.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { Cli, Errors, z } from 'incur'
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
2
5
 
3
6
  const originalIsTTY = process.stdout.isTTY
4
7
  beforeAll(() => {
@@ -9,10 +12,15 @@ afterAll(() => {
9
12
  })
10
13
 
11
14
  let __mockSkillsHash: string | undefined
15
+ let __mockSkillsInstalled = true
12
16
 
13
17
  vi.mock('./SyncSkills.js', async (importOriginal) => {
14
18
  const actual = await importOriginal<typeof import('./SyncSkills.js')>()
15
- return { ...actual, readHash: () => __mockSkillsHash }
19
+ return {
20
+ ...actual,
21
+ hasInstalledSkills: () => __mockSkillsInstalled,
22
+ readHash: () => __mockSkillsHash,
23
+ }
16
24
  })
17
25
 
18
26
  async function serve(
@@ -37,6 +45,42 @@ async function serve(
37
45
  }
38
46
  }
39
47
 
48
+ function createConfigCli(flag?: string) {
49
+ const project = Cli.create('project').command('list', {
50
+ options: z.object({
51
+ label: z.array(z.string()).default([]),
52
+ limit: z.number().default(10),
53
+ }),
54
+ run(c) {
55
+ return c.options
56
+ },
57
+ })
58
+
59
+ const cli = Cli.create('test', {
60
+ config: flag !== undefined ? { flag } : {},
61
+ options: z.object({
62
+ rootValue: z.string().default('root-default'),
63
+ }),
64
+ run(c) {
65
+ return c.options
66
+ },
67
+ })
68
+
69
+ cli.command('echo', {
70
+ options: z.object({
71
+ prefix: z.string().default(''),
72
+ upper: z.boolean().default(false),
73
+ }),
74
+ run(c) {
75
+ return c.options
76
+ },
77
+ })
78
+
79
+ cli.command(project)
80
+
81
+ return cli
82
+ }
83
+
40
84
  describe('create', () => {
41
85
  test('returns cli instance with name', () => {
42
86
  const cli = Cli.create('test')
@@ -62,6 +106,518 @@ describe('command', () => {
62
106
  })
63
107
  })
64
108
 
109
+ describe('config defaults', () => {
110
+ let cwd: string
111
+ let dir: string
112
+
113
+ beforeEach(async () => {
114
+ cwd = process.cwd()
115
+ dir = await mkdtemp(join(tmpdir(), 'incur-config-'))
116
+ process.chdir(dir)
117
+ })
118
+
119
+ afterEach(async () => {
120
+ process.chdir(cwd)
121
+ await rm(dir, { force: true, recursive: true })
122
+ })
123
+
124
+ test('auto-loads <cli>.json for leaf commands', async () => {
125
+ await writeFile(
126
+ join(dir, 'test.json'),
127
+ JSON.stringify({
128
+ commands: {
129
+ echo: {
130
+ options: {
131
+ prefix: 'cfg',
132
+ upper: true,
133
+ },
134
+ },
135
+ },
136
+ }),
137
+ )
138
+
139
+ const { output } = await serve(createConfigCli(), ['echo', '--json'])
140
+ expect(JSON.parse(output)).toEqual({ prefix: 'cfg', upper: true })
141
+ })
142
+
143
+ test('ignores a missing auto config file', async () => {
144
+ const { output } = await serve(createConfigCli(), ['echo', '--json'])
145
+ expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
146
+ })
147
+
148
+ test('root options coexist with subcommand keys', async () => {
149
+ await writeFile(
150
+ join(dir, 'test.json'),
151
+ JSON.stringify({
152
+ options: { rootValue: 'cfg-root' },
153
+ commands: {
154
+ echo: { options: { prefix: 'cfg' } },
155
+ },
156
+ }),
157
+ )
158
+
159
+ const rootResult = await serve(createConfigCli(), ['--json'])
160
+ expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'cfg-root' })
161
+
162
+ const echoResult = await serve(createConfigCli(), ['echo', '--json'])
163
+ expect(JSON.parse(echoResult.output)).toEqual({ prefix: 'cfg', upper: false })
164
+ })
165
+
166
+ test('walks nested command sections in config tree', async () => {
167
+ await writeFile(
168
+ join(dir, 'test.json'),
169
+ JSON.stringify({
170
+ commands: {
171
+ project: {
172
+ commands: {
173
+ list: {
174
+ options: {
175
+ label: ['cfg'],
176
+ limit: 25,
177
+ },
178
+ },
179
+ },
180
+ },
181
+ },
182
+ }),
183
+ )
184
+
185
+ const { output } = await serve(createConfigCli(), ['project', 'list', '--json'])
186
+ expect(JSON.parse(output)).toEqual({ label: ['cfg'], limit: 25 })
187
+ })
188
+
189
+ test('uses an explicit --config path instead of the auto file', async () => {
190
+ await writeFile(
191
+ join(dir, 'test.json'),
192
+ JSON.stringify({
193
+ commands: { echo: { options: { prefix: 'auto' } } },
194
+ }),
195
+ )
196
+ await writeFile(
197
+ join(dir, 'custom.json'),
198
+ JSON.stringify({
199
+ commands: { echo: { options: { prefix: 'custom', upper: true } } },
200
+ }),
201
+ )
202
+
203
+ const { output } = await serve(createConfigCli('config'), [
204
+ 'echo',
205
+ '--config',
206
+ 'custom.json',
207
+ '--json',
208
+ ])
209
+ expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: true })
210
+ })
211
+
212
+ test('--no-config disables earlier config flags, and a later --config wins again', async () => {
213
+ await writeFile(
214
+ join(dir, 'one.json'),
215
+ JSON.stringify({
216
+ commands: { echo: { options: { prefix: 'one' } } },
217
+ }),
218
+ )
219
+ await writeFile(
220
+ join(dir, 'two.json'),
221
+ JSON.stringify({
222
+ commands: { echo: { options: { prefix: 'two' } } },
223
+ }),
224
+ )
225
+
226
+ const first = await serve(createConfigCli('config'), [
227
+ 'echo',
228
+ '--config',
229
+ 'one.json',
230
+ '--no-config',
231
+ '--json',
232
+ ])
233
+ expect(JSON.parse(first.output)).toEqual({ prefix: '', upper: false })
234
+
235
+ const second = await serve(createConfigCli('config'), [
236
+ 'echo',
237
+ '--config',
238
+ 'one.json',
239
+ '--no-config',
240
+ '--config=two.json',
241
+ '--json',
242
+ ])
243
+ expect(JSON.parse(second.output)).toEqual({ prefix: 'two', upper: false })
244
+ })
245
+
246
+ test('fails when an explicit config file is missing', async () => {
247
+ const { exitCode, output } = await serve(createConfigCli('config'), [
248
+ 'echo',
249
+ '--config',
250
+ 'missing.json',
251
+ ])
252
+ expect(exitCode).toBe(1)
253
+ expect(output).toContain('Config file not found')
254
+ })
255
+
256
+ test('fails on invalid JSON config files', async () => {
257
+ await writeFile(join(dir, 'test.json'), '{ invalid')
258
+
259
+ const { exitCode, output } = await serve(createConfigCli(), ['echo'])
260
+ expect(exitCode).toBe(1)
261
+ expect(output).toContain('Invalid JSON config file')
262
+ })
263
+
264
+ test('fails when the config file top level is not an object', async () => {
265
+ await writeFile(join(dir, 'test.json'), JSON.stringify(['bad']))
266
+
267
+ const { exitCode, output } = await serve(createConfigCli(), ['echo'])
268
+ expect(exitCode).toBe(1)
269
+ expect(output).toContain('expected a top-level object')
270
+ })
271
+
272
+ test('fails when the selected config section is not an object', async () => {
273
+ await writeFile(join(dir, 'test.json'), JSON.stringify({ commands: { echo: true } }))
274
+
275
+ const { exitCode, output } = await serve(createConfigCli(), ['echo'])
276
+ expect(exitCode).toBe(1)
277
+ expect(output).toContain("Invalid config section for 'echo'")
278
+ })
279
+
280
+ test('fails validation when config option values are invalid', async () => {
281
+ await writeFile(
282
+ join(dir, 'test.json'),
283
+ JSON.stringify({
284
+ commands: { echo: { options: { upper: 'nope' } } },
285
+ }),
286
+ )
287
+
288
+ const { exitCode, output } = await serve(createConfigCli(), ['echo'])
289
+ expect(exitCode).toBe(1)
290
+ expect(output).toContain('VALIDATION_ERROR')
291
+ })
292
+
293
+ test('argv overrides invalid config values at the CLI layer', async () => {
294
+ await writeFile(
295
+ join(dir, 'test.json'),
296
+ JSON.stringify({
297
+ commands: { echo: { options: { prefix: 123 } } },
298
+ }),
299
+ )
300
+
301
+ const { output } = await serve(createConfigCli(), ['echo', '--prefix', 'cli', '--json'])
302
+ expect(JSON.parse(output)).toEqual({ prefix: 'cli', upper: false })
303
+ })
304
+
305
+ test('built-in commands ignore config loading', async () => {
306
+ await writeFile(join(dir, 'test.json'), '{ invalid')
307
+
308
+ const { output, exitCode } = await serve(createConfigCli(), ['--help'])
309
+ expect(exitCode).toBeUndefined()
310
+ expect(output).toContain('Global Options:')
311
+ })
312
+
313
+ test('config without flag does not reserve --config', async () => {
314
+ const cli = Cli.create('test', { config: {} })
315
+ cli.command('echo', {
316
+ options: z.object({ config: z.string().default('') }),
317
+ run(c) {
318
+ return c.options
319
+ },
320
+ })
321
+
322
+ const { output } = await serve(cli, ['echo', '--config', 'my-value', '--json'])
323
+ expect(JSON.parse(output)).toEqual({ config: 'my-value' })
324
+ })
325
+
326
+ test('--help shows config flags only when flag name is set', async () => {
327
+ const { output } = await serve(createConfigCli('config'), ['--help'])
328
+ expect(output).toContain('--config <path>')
329
+ expect(output).toContain('--no-config')
330
+
331
+ const { output: noFlagOutput } = await serve(createConfigCli(), ['--help'])
332
+ expect(noFlagOutput).not.toContain('--config')
333
+ })
334
+
335
+ test('custom flag name is used for config path override', async () => {
336
+ await writeFile(
337
+ join(dir, 'custom.json'),
338
+ JSON.stringify({
339
+ commands: { echo: { options: { prefix: 'custom' } } },
340
+ }),
341
+ )
342
+
343
+ const { output } = await serve(createConfigCli('settings'), [
344
+ 'echo',
345
+ '--settings',
346
+ 'custom.json',
347
+ '--json',
348
+ ])
349
+ expect(JSON.parse(output)).toEqual({ prefix: 'custom', upper: false })
350
+ })
351
+
352
+ test('searches files list in order, first match wins', async () => {
353
+ await writeFile(
354
+ join(dir, '.testrc.json'),
355
+ JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
356
+ )
357
+
358
+ const cli = Cli.create('test', {
359
+ config: { files: ['test.json', '.testrc.json'] },
360
+ })
361
+ cli.command('echo', {
362
+ options: z.object({ prefix: z.string().default('') }),
363
+ run: (c) => c.options,
364
+ })
365
+
366
+ const { output } = await serve(cli, ['echo', '--json'])
367
+ expect(JSON.parse(output)).toEqual({ prefix: 'rc' })
368
+ })
369
+
370
+ test('files: [] disables auto-discovery', async () => {
371
+ await writeFile(
372
+ join(dir, 'test.json'),
373
+ JSON.stringify({ commands: { echo: { options: { prefix: 'should-not-load' } } } }),
374
+ )
375
+
376
+ const cli = Cli.create('test', {
377
+ config: { files: [] },
378
+ })
379
+ cli.command('echo', {
380
+ options: z.object({ prefix: z.string().default('') }),
381
+ run: (c) => c.options,
382
+ })
383
+
384
+ const { output } = await serve(cli, ['echo', '--json'])
385
+ expect(JSON.parse(output)).toEqual({ prefix: '' })
386
+ })
387
+
388
+ test('files supports ~ for home directory', async () => {
389
+ const configDir = join(homedir(), '.config', 'test-incur-files-tilde')
390
+ await mkdir(configDir, { recursive: true })
391
+ try {
392
+ await writeFile(
393
+ join(configDir, 'config.json'),
394
+ JSON.stringify({ commands: { echo: { options: { prefix: 'home' } } } }),
395
+ )
396
+
397
+ const cli = Cli.create('test', {
398
+ config: { files: ['test.json', '~/.config/test-incur-files-tilde/config.json'] },
399
+ })
400
+ cli.command('echo', {
401
+ options: z.object({ prefix: z.string().default('') }),
402
+ run: (c) => c.options,
403
+ })
404
+
405
+ const { output } = await serve(cli, ['echo', '--json'])
406
+ expect(JSON.parse(output)).toEqual({ prefix: 'home' })
407
+ } finally {
408
+ await rm(configDir, { recursive: true, force: true })
409
+ }
410
+ })
411
+
412
+ test('explicit --flag overrides files list', async () => {
413
+ await writeFile(
414
+ join(dir, '.testrc.json'),
415
+ JSON.stringify({ commands: { echo: { options: { prefix: 'rc' } } } }),
416
+ )
417
+ await writeFile(
418
+ join(dir, 'override.json'),
419
+ JSON.stringify({ commands: { echo: { options: { prefix: 'override' } } } }),
420
+ )
421
+
422
+ const cli = Cli.create('test', {
423
+ config: { flag: 'config', files: ['.testrc.json'] },
424
+ })
425
+ cli.command('echo', {
426
+ options: z.object({ prefix: z.string().default('') }),
427
+ run: (c) => c.options,
428
+ })
429
+
430
+ const { output } = await serve(cli, ['echo', '--config', 'override.json', '--json'])
431
+ expect(JSON.parse(output)).toEqual({ prefix: 'override' })
432
+ })
433
+
434
+ test('custom loader replaces JSON parsing', async () => {
435
+ await writeFile(join(dir, 'test.ini'), 'prefix=ini-value')
436
+
437
+ const cli = Cli.create('test', {
438
+ config: {
439
+ files: ['test.ini'],
440
+ async loader(path) {
441
+ if (!path) return undefined
442
+ const raw = await readFile(path, 'utf8')
443
+ const obj: Record<string, unknown> = {}
444
+ for (const line of raw.split('\n')) {
445
+ const eq = line.indexOf('=')
446
+ if (eq !== -1) obj[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
447
+ }
448
+ return { commands: { echo: { options: obj } } }
449
+ },
450
+ },
451
+ })
452
+ cli.command('echo', {
453
+ options: z.object({ prefix: z.string().default('') }),
454
+ run: (c) => c.options,
455
+ })
456
+
457
+ const { output } = await serve(cli, ['echo', '--json'])
458
+ expect(JSON.parse(output)).toEqual({ prefix: 'ini-value' })
459
+ })
460
+
461
+ test('loader with files: [] receives undefined path', async () => {
462
+ const cli = Cli.create('test', {
463
+ config: {
464
+ files: [],
465
+ loader: async (path) => {
466
+ expect(path).toBeUndefined()
467
+ return { commands: { echo: { options: { prefix: 'from-loader' } } } }
468
+ },
469
+ },
470
+ })
471
+ cli.command('echo', {
472
+ options: z.object({ prefix: z.string().default('') }),
473
+ run: (c) => c.options,
474
+ })
475
+
476
+ const { output } = await serve(cli, ['echo', '--json'])
477
+ expect(JSON.parse(output)).toEqual({ prefix: 'from-loader' })
478
+ })
479
+
480
+ test('loader returning undefined applies no defaults', async () => {
481
+ const cli = Cli.create('test', {
482
+ config: { files: [], loader: async () => undefined },
483
+ })
484
+ cli.command('echo', {
485
+ options: z.object({ prefix: z.string().default('') }),
486
+ run: (c) => c.options,
487
+ })
488
+
489
+ const { output } = await serve(cli, ['echo', '--json'])
490
+ expect(JSON.parse(output)).toEqual({ prefix: '' })
491
+ })
492
+
493
+ test('--no-flag skips loader entirely', async () => {
494
+ let loaderCalled = false
495
+ const cli = Cli.create('test', {
496
+ config: {
497
+ flag: 'config',
498
+ files: [],
499
+ loader: async () => {
500
+ loaderCalled = true
501
+ return { commands: { echo: { options: { prefix: 'should-not-load' } } } }
502
+ },
503
+ },
504
+ })
505
+ cli.command('echo', {
506
+ options: z.object({ prefix: z.string().default('') }),
507
+ run: (c) => c.options,
508
+ })
509
+
510
+ const { output } = await serve(cli, ['echo', '--no-config', '--json'])
511
+ expect(JSON.parse(output)).toEqual({ prefix: '' })
512
+ expect(loaderCalled).toBe(false)
513
+ })
514
+
515
+ test('loader errors propagate', async () => {
516
+ const cli = Cli.create('test', {
517
+ config: {
518
+ files: [],
519
+ loader: async () => {
520
+ throw new Error('Remote config server unreachable')
521
+ },
522
+ },
523
+ })
524
+ cli.command('echo', {
525
+ options: z.object({ prefix: z.string().default('') }),
526
+ run: (c) => c.options,
527
+ })
528
+
529
+ const { exitCode, output } = await serve(cli, ['echo'])
530
+ expect(exitCode).toBe(1)
531
+ expect(output).toContain('Remote config server unreachable')
532
+ })
533
+
534
+ test('--no-flag disables auto-discovery without prior --flag', async () => {
535
+ await writeFile(
536
+ join(dir, 'test.json'),
537
+ JSON.stringify({ commands: { echo: { options: { prefix: 'auto-loaded' } } } }),
538
+ )
539
+
540
+ const { output } = await serve(createConfigCli('config'), ['echo', '--no-config', '--json'])
541
+ expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
542
+ })
543
+
544
+ test('--config without a value produces an error', async () => {
545
+ const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config'])
546
+ expect(exitCode).toBe(1)
547
+ expect(output).toContain('Missing value for flag')
548
+ })
549
+
550
+ test('--config= (empty value) produces an error', async () => {
551
+ const { exitCode, output } = await serve(createConfigCli('config'), ['echo', '--config='])
552
+ expect(exitCode).toBe(1)
553
+ expect(output).toContain('Missing value for flag')
554
+ })
555
+
556
+ test('--no-settings works with custom flag name', async () => {
557
+ await writeFile(
558
+ join(dir, 'test.json'),
559
+ JSON.stringify({ commands: { echo: { options: { prefix: 'auto' } } } }),
560
+ )
561
+
562
+ const { output } = await serve(createConfigCli('settings'), ['echo', '--no-settings', '--json'])
563
+ expect(JSON.parse(output)).toEqual({ prefix: '', upper: false })
564
+ })
565
+
566
+ test('camelCase config keys are accepted at cli level', async () => {
567
+ const cli = Cli.create('test', { config: {} })
568
+ cli.command('echo', {
569
+ options: z.object({ saveDev: z.boolean().default(false) }),
570
+ run: (c) => c.options,
571
+ })
572
+
573
+ await writeFile(
574
+ join(dir, 'test.json'),
575
+ JSON.stringify({ commands: { echo: { options: { 'save-dev': true } } } }),
576
+ )
577
+
578
+ const { output } = await serve(cli, ['echo', '--json'])
579
+ expect(JSON.parse(output)).toEqual({ saveDev: true })
580
+ })
581
+
582
+ test('config defaults with only subcommand namespaces yields no option defaults', async () => {
583
+ await writeFile(
584
+ join(dir, 'test.json'),
585
+ JSON.stringify({
586
+ commands: {
587
+ echo: { options: { prefix: 'child' } },
588
+ project: { commands: { list: { options: { limit: 50 } } } },
589
+ },
590
+ }),
591
+ )
592
+
593
+ const rootResult = await serve(createConfigCli(), ['--json'])
594
+ expect(JSON.parse(rootResult.output)).toEqual({ rootValue: 'root-default' })
595
+ })
596
+
597
+ test('explicit --flag path is forwarded to custom loader', async () => {
598
+ await writeFile(join(dir, 'custom.dat'), 'prefix=custom-loader')
599
+
600
+ const cli = Cli.create('test', {
601
+ config: {
602
+ flag: 'config',
603
+ async loader(path) {
604
+ if (!path) return undefined
605
+ const raw = await readFile(path, 'utf8')
606
+ const [, value] = raw.split('=')
607
+ return { commands: { echo: { options: { prefix: value!.trim() } } } }
608
+ },
609
+ },
610
+ })
611
+ cli.command('echo', {
612
+ options: z.object({ prefix: z.string().default('') }),
613
+ run: (c) => c.options,
614
+ })
615
+
616
+ const { output } = await serve(cli, ['echo', '--config', 'custom.dat', '--json'])
617
+ expect(JSON.parse(output)).toEqual({ prefix: 'custom-loader' })
618
+ })
619
+ })
620
+
65
621
  describe('serve', () => {
66
622
  test('outputs data only by default', async () => {
67
623
  const cli = Cli.create('test')
@@ -79,7 +635,7 @@ describe('serve', () => {
79
635
  `)
80
636
  })
81
637
 
82
- test('--verbose outputs full envelope', async () => {
638
+ test('--full-output outputs full envelope', async () => {
83
639
  const cli = Cli.create('test')
84
640
  cli.command('greet', {
85
641
  args: z.object({ name: z.string() }),
@@ -88,7 +644,7 @@ describe('serve', () => {
88
644
  },
89
645
  })
90
646
 
91
- const { output } = await serve(cli, ['greet', 'world', '--verbose'])
647
+ const { output } = await serve(cli, ['greet', 'world', '--full-output'])
92
648
  expect(output).toMatchInlineSnapshot(`
93
649
  "ok: true
94
650
  data:
@@ -140,9 +696,9 @@ describe('serve', () => {
140
696
  "code: COMMAND_NOT_FOUND
141
697
  message: 'nonexistent' is not a command for 'test'.
142
698
  cta:
143
- description: "See available commands:"
144
- commands[1]{command}:
145
- test --help
699
+ description: "Suggested command:"
700
+ commands[1]{command,description}:
701
+ test --help,see all available commands
146
702
  "
147
703
  `)
148
704
  })
@@ -157,16 +713,16 @@ describe('serve', () => {
157
713
  expect(output).toMatchInlineSnapshot(`
158
714
  "Error: 'nonexistent' is not a command for 'test'.
159
715
 
160
- See available commands:
161
- test --help
716
+ Suggested command:
717
+ test --help # see all available commands
162
718
  "
163
719
  `)
164
720
  })
165
721
 
166
- test('--verbose outputs full error envelope for unknown command', async () => {
722
+ test('--full-output outputs full error envelope for unknown command', async () => {
167
723
  const cli = Cli.create('test')
168
724
 
169
- const { output, exitCode } = await serve(cli, ['nonexistent', '--verbose'])
725
+ const { output, exitCode } = await serve(cli, ['nonexistent', '--full-output'])
170
726
  expect(exitCode).toBe(1)
171
727
  expect(output).toMatchInlineSnapshot(`
172
728
  "ok: false
@@ -176,14 +732,100 @@ describe('serve', () => {
176
732
  meta:
177
733
  command: nonexistent
178
734
  cta:
179
- description: "See available commands:"
180
- commands[1]{command}:
181
- test --help
735
+ description: "Suggested command:"
736
+ commands[1]{command,description}:
737
+ test --help,see all available commands
182
738
  duration: <stripped>
183
739
  "
184
740
  `)
185
741
  })
186
742
 
743
+ test('suggests similar command for typos', async () => {
744
+ const cli = Cli.create('test')
745
+ cli.command('deploy', { run: () => ({}) })
746
+ cli.command('status', { run: () => ({}) })
747
+
748
+ const { output, exitCode } = await serve(cli, ['deplyo'])
749
+ expect(exitCode).toBe(1)
750
+ expect(output).toMatchInlineSnapshot(`
751
+ "code: COMMAND_NOT_FOUND
752
+ message: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
753
+ cta:
754
+ description: "Suggested commands:"
755
+ commands[2]:
756
+ - command: test deploy
757
+ - command: test --help
758
+ description: see all available commands
759
+ "
760
+ `)
761
+ })
762
+
763
+ test('suggests similar command for typos in TTY', async () => {
764
+ ;(process.stdout as any).isTTY = true
765
+ const cli = Cli.create('test')
766
+ cli.command('deploy', { run: () => ({}) })
767
+
768
+ const { output, exitCode } = await serve(cli, ['deplyo'])
769
+ ;(process.stdout as any).isTTY = false
770
+ expect(exitCode).toBe(1)
771
+ expect(output).toMatchInlineSnapshot(`
772
+ "Error: 'deplyo' is not a command for 'test'. Did you mean 'deploy'?
773
+
774
+ Suggested commands:
775
+ test deploy
776
+ test --help # see all available commands
777
+ "
778
+ `)
779
+ })
780
+
781
+ test('suggests builtin commands for typos', async () => {
782
+ const cli = Cli.create('test')
783
+ cli.command('ping', { run: () => ({}) })
784
+
785
+ const { output, exitCode } = await serve(cli, ['mpc'])
786
+ expect(exitCode).toBe(1)
787
+ expect(output).toContain("Did you mean 'mcp'?")
788
+ expect(output).toContain('test mcp')
789
+ })
790
+
791
+ test('preserves flags in suggestion CTA', async () => {
792
+ const cli = Cli.create('test')
793
+ cli.command('deploy', { run: () => ({}) })
794
+
795
+ const { output } = await serve(cli, ['deplyo', '--full-output'])
796
+ expect(output).toContain('test deploy --full-output')
797
+ })
798
+
799
+ test('no suggestion when input is too far from any command', async () => {
800
+ const cli = Cli.create('test')
801
+ cli.command('deploy', { run: () => ({}) })
802
+
803
+ const { output } = await serve(cli, ['xyz'])
804
+ expect(output).not.toContain('Did you mean')
805
+ })
806
+
807
+ test('suggests similar subcommand for typos', async () => {
808
+ const cli = Cli.create('test')
809
+ const pr = Cli.create('pr')
810
+ .command('list', { run: () => ({}) })
811
+ .command('create', { run: () => ({}) })
812
+ cli.command(pr)
813
+
814
+ const { output, exitCode } = await serve(cli, ['pr', 'craete'])
815
+ expect(exitCode).toBe(1)
816
+ expect(output).toMatchInlineSnapshot(`
817
+ "code: COMMAND_NOT_FOUND
818
+ message: 'craete' is not a command for 'test pr'. Did you mean 'create'?
819
+ cta:
820
+ description: "Suggested commands:"
821
+ commands[2]:
822
+ - command: test pr create
823
+ - command: test pr --help
824
+ description: see all available commands
825
+ "
826
+ `)
827
+ })
828
+
187
829
  test('wraps handler errors in error output', async () => {
188
830
  const cli = Cli.create('test')
189
831
  cli.command('fail', {
@@ -353,10 +995,10 @@ describe('serve', () => {
353
995
  expect(JSON.parse(output)).toEqual({ pong: true })
354
996
  })
355
997
 
356
- test('--verbose --format json outputs full envelope as JSON', async () => {
998
+ test('--full-output --format json outputs full envelope as JSON', async () => {
357
999
  const cli = Cli.create('test')
358
1000
  cli.command('ping', { run: () => ({ pong: true }) })
359
- const { output } = await serve(cli, ['ping', '--verbose', '--format', 'json'])
1001
+ const { output } = await serve(cli, ['ping', '--full-output', '--format', 'json'])
360
1002
  const parsed = JSON.parse(output)
361
1003
  expect(parsed.ok).toBe(true)
362
1004
  expect(parsed.data).toEqual({ pong: true })
@@ -641,6 +1283,38 @@ describe('--llms', () => {
641
1283
  expect(output).toContain('test auth auth logout')
642
1284
  expect(output).not.toContain('ping')
643
1285
  })
1286
+
1287
+ test('--llms includes root command', async () => {
1288
+ const cli = Cli.create('my-cli', {
1289
+ description: 'Fetch URLs',
1290
+ args: z.object({ url: z.string().describe('URL to fetch') }),
1291
+ options: z.object({ objective: z.string().optional().describe('Narrow content') }),
1292
+ run: ({ args }) => args.url,
1293
+ })
1294
+ cli.command('auth', { description: 'Auth commands', run: () => ({}) })
1295
+
1296
+ const { output } = await serve(cli, ['--llms'])
1297
+ expect(output).toContain('| `my-cli <url>` | Fetch URLs |')
1298
+ expect(output).toContain('| `my-cli auth` | Auth commands |')
1299
+ })
1300
+
1301
+ test('--llms-full includes root command with args/options', async () => {
1302
+ const cli = Cli.create('my-cli', {
1303
+ description: 'Fetch URLs',
1304
+ args: z.object({ url: z.string().describe('URL to fetch') }),
1305
+ options: z.object({ objective: z.string().optional().describe('Narrow content') }),
1306
+ output: z.string().describe('Page content'),
1307
+ run: ({ args }) => args.url,
1308
+ })
1309
+ cli.command('auth', { description: 'Auth commands', run: () => ({}) })
1310
+
1311
+ const { output } = await serve(cli, ['--llms-full'])
1312
+ expect(output).toContain('# my-cli\n\nFetch URLs')
1313
+ expect(output).toContain('| `url` | `string` | yes | URL to fetch |')
1314
+ expect(output).toContain('| `--objective` | `string` | | Narrow content |')
1315
+ expect(output).toContain('# my-cli auth')
1316
+ expect(output).not.toContain('# my-cli \n')
1317
+ })
644
1318
  })
645
1319
 
646
1320
  describe('--schema', () => {
@@ -738,6 +1412,14 @@ describe('--schema', () => {
738
1412
  expect(exitCode).toBe(1)
739
1413
  })
740
1414
 
1415
+ test('on unknown command suggests similar', async () => {
1416
+ const cli = Cli.create('test')
1417
+ cli.command('greet', { run: () => ({}) })
1418
+ const { output, exitCode } = await serve(cli, ['grete', '--schema'])
1419
+ expect(output).toContain("Did you mean 'greet'?")
1420
+ expect(exitCode).toBe(1)
1421
+ })
1422
+
741
1423
  test('on group shows available commands', async () => {
742
1424
  const cli = Cli.create('test')
743
1425
  const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
@@ -802,14 +1484,14 @@ describe('subcommands', () => {
802
1484
  `)
803
1485
  })
804
1486
 
805
- test('--verbose shows full command path in meta', async () => {
1487
+ test('--full-output shows full command path in meta', async () => {
806
1488
  const cli = Cli.create('test')
807
1489
  const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
808
1490
  run: () => ({ count: 0 }),
809
1491
  })
810
1492
  cli.command(pr)
811
1493
 
812
- const { output } = await serve(cli, ['pr', 'list', '--verbose'])
1494
+ const { output } = await serve(cli, ['pr', 'list', '--full-output'])
813
1495
  expect(output).toMatchInlineSnapshot(`
814
1496
  "ok: true
815
1497
  data:
@@ -837,7 +1519,7 @@ describe('subcommands', () => {
837
1519
  `)
838
1520
  })
839
1521
 
840
- test('nested group shows full path in verbose meta', async () => {
1522
+ test('nested group shows full path in full-output meta', async () => {
841
1523
  const cli = Cli.create('test')
842
1524
  const review = Cli.create('review', { description: 'Reviews' }).command('approve', {
843
1525
  run: () => ({ approved: true }),
@@ -846,7 +1528,7 @@ describe('subcommands', () => {
846
1528
  pr.command(review)
847
1529
  cli.command(pr)
848
1530
 
849
- const { output } = await serve(cli, ['pr', 'review', 'approve', '--verbose'])
1531
+ const { output } = await serve(cli, ['pr', 'review', 'approve', '--full-output'])
850
1532
  expect(output).toMatchInlineSnapshot(`
851
1533
  "ok: true
852
1534
  data:
@@ -871,9 +1553,9 @@ describe('subcommands', () => {
871
1553
  "code: COMMAND_NOT_FOUND
872
1554
  message: 'unknown' is not a command for 'test pr'.
873
1555
  cta:
874
- description: "See available commands:"
875
- commands[1]{command}:
876
- test pr --help
1556
+ description: "Suggested command:"
1557
+ commands[1]{command,description}:
1558
+ test pr --help,see all available commands
877
1559
  "
878
1560
  `)
879
1561
  })
@@ -892,8 +1574,8 @@ describe('subcommands', () => {
892
1574
  expect(output).toMatchInlineSnapshot(`
893
1575
  "Error: 'unknown' is not a command for 'test pr'.
894
1576
 
895
- See available commands:
896
- test pr --help
1577
+ Suggested command:
1578
+ test pr --help # see all available commands
897
1579
  "
898
1580
  `)
899
1581
  })
@@ -919,13 +1601,13 @@ describe('subcommands', () => {
919
1601
  Global Options:
920
1602
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
921
1603
  --format <toon|json|yaml|md|jsonl> Output format
1604
+ --full-output Show full output envelope
922
1605
  --help Show help
923
1606
  --llms, --llms-full Print LLM-readable manifest
924
- --schema Show JSON Schema for a command
1607
+ --schema Show JSON Schema for command
925
1608
  --token-count Print token count of output (instead of output)
926
1609
  --token-limit <n> Limit output to n tokens
927
1610
  --token-offset <n> Skip first n tokens of output
928
- --verbose Show full output envelope
929
1611
  "
930
1612
  `)
931
1613
  })
@@ -1008,7 +1690,7 @@ describe('cta', () => {
1008
1690
  },
1009
1691
  })
1010
1692
 
1011
- const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
1693
+ const { output } = await serve(cli, ['list', '--full-output', '--format', 'json'])
1012
1694
  const parsed = JSON.parse(output)
1013
1695
  expect(parsed.meta.cta).toEqual({
1014
1696
  description: 'Suggested commands:',
@@ -1029,7 +1711,7 @@ describe('cta', () => {
1029
1711
  },
1030
1712
  })
1031
1713
 
1032
- const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
1714
+ const { output } = await serve(cli, ['list', '--full-output', '--format', 'json'])
1033
1715
  const parsed = JSON.parse(output)
1034
1716
  expect(parsed.meta.cta.commands).toEqual([
1035
1717
  { command: 'test get 1', description: 'View item 1' },
@@ -1058,7 +1740,7 @@ describe('cta', () => {
1058
1740
  },
1059
1741
  })
1060
1742
 
1061
- const { output } = await serve(cli, ['create', '--verbose', '--format', 'json'])
1743
+ const { output } = await serve(cli, ['create', '--full-output', '--format', 'json'])
1062
1744
  const parsed = JSON.parse(output)
1063
1745
  expect(parsed.meta.cta.commands).toEqual([
1064
1746
  { command: 'test get 1 --limit 10', description: 'View the item' },
@@ -1078,7 +1760,7 @@ describe('cta', () => {
1078
1760
  },
1079
1761
  })
1080
1762
 
1081
- const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
1763
+ const { output } = await serve(cli, ['list', '--full-output', '--format', 'json'])
1082
1764
  const parsed = JSON.parse(output)
1083
1765
  expect(parsed.meta.cta.commands).toEqual([{ command: 'test get <id> --format <format>' }])
1084
1766
  })
@@ -1096,7 +1778,7 @@ describe('cta', () => {
1096
1778
  },
1097
1779
  })
1098
1780
 
1099
- const { output } = await serve(cli, ['create', '--verbose', '--format', 'json'])
1781
+ const { output } = await serve(cli, ['create', '--full-output', '--format', 'json'])
1100
1782
  const parsed = JSON.parse(output)
1101
1783
  expect(parsed.meta.cta.description).toBe('View the created item:')
1102
1784
  })
@@ -1105,7 +1787,7 @@ describe('cta', () => {
1105
1787
  const cli = Cli.create('test')
1106
1788
  cli.command('ping', { run: () => ({ pong: true }) })
1107
1789
 
1108
- const { output } = await serve(cli, ['ping', '--verbose', '--format', 'json'])
1790
+ const { output } = await serve(cli, ['ping', '--full-output', '--format', 'json'])
1109
1791
  const parsed = JSON.parse(output)
1110
1792
  expect(parsed.meta.cta).toBeUndefined()
1111
1793
  })
@@ -1118,7 +1800,7 @@ describe('cta', () => {
1118
1800
  },
1119
1801
  })
1120
1802
 
1121
- const { output } = await serve(cli, ['noop', '--verbose', '--format', 'json'])
1803
+ const { output } = await serve(cli, ['noop', '--full-output', '--format', 'json'])
1122
1804
  const parsed = JSON.parse(output)
1123
1805
  expect(parsed.meta.cta).toBeUndefined()
1124
1806
  })
@@ -1138,7 +1820,7 @@ describe('cta', () => {
1138
1820
  },
1139
1821
  })
1140
1822
 
1141
- const { output, exitCode } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
1823
+ const { output, exitCode } = await serve(cli, ['fail', '--full-output', '--format', 'json'])
1142
1824
  expect(exitCode).toBe(1)
1143
1825
  const parsed = JSON.parse(output)
1144
1826
  expect(parsed.ok).toBe(false)
@@ -1156,7 +1838,7 @@ describe('cta', () => {
1156
1838
  },
1157
1839
  })
1158
1840
 
1159
- const { output, exitCode } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
1841
+ const { output, exitCode } = await serve(cli, ['fail', '--full-output', '--format', 'json'])
1160
1842
  expect(exitCode).toBe(1)
1161
1843
  const parsed = JSON.parse(output)
1162
1844
  expect(parsed.meta.cta).toBeUndefined()
@@ -1170,7 +1852,7 @@ describe('cta', () => {
1170
1852
  },
1171
1853
  })
1172
1854
 
1173
- const { output } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
1855
+ const { output } = await serve(cli, ['fail', '--full-output', '--format', 'json'])
1174
1856
  const parsed = JSON.parse(output)
1175
1857
  expect(parsed.ok).toBe(false)
1176
1858
  expect(parsed.meta.cta).toBeUndefined()
@@ -1192,10 +1874,17 @@ describe('cta', () => {
1192
1874
  })
1193
1875
  cli.command(pr)
1194
1876
 
1195
- const { output } = await serve(cli, ['pr', 'create', 'my-pr', '--verbose', '--format', 'json'])
1877
+ const { output } = await serve(cli, [
1878
+ 'pr',
1879
+ 'create',
1880
+ 'my-pr',
1881
+ '--full-output',
1882
+ '--format',
1883
+ 'json',
1884
+ ])
1196
1885
  const parsed = JSON.parse(output)
1197
1886
  expect(parsed.meta.cta).toEqual({
1198
- description: 'Suggested commands:',
1887
+ description: 'Suggested command:',
1199
1888
  commands: [{ command: 'test pr get 42', description: 'View the PR' }],
1200
1889
  })
1201
1890
  })
@@ -1232,9 +1921,25 @@ describe('leaf cli', () => {
1232
1921
  `)
1233
1922
  })
1234
1923
 
1235
- test('--verbose outputs full envelope', async () => {
1236
- const cli = Cli.create('ping', { run: () => ({ pong: true }) })
1924
+ test('command option named verbose is parsed by the command', async () => {
1925
+ const cli = Cli.create('ping', {
1926
+ options: z.object({ verbose: z.boolean().default(false) }),
1927
+ run({ options }) {
1928
+ return options
1929
+ },
1930
+ })
1931
+
1237
1932
  const { output } = await serve(cli, ['--verbose'])
1933
+
1934
+ expect(output).toMatchInlineSnapshot(`
1935
+ "verbose: true
1936
+ "
1937
+ `)
1938
+ })
1939
+
1940
+ test('--full-output outputs full envelope', async () => {
1941
+ const cli = Cli.create('ping', { run: () => ({ pong: true }) })
1942
+ const { output } = await serve(cli, ['--full-output'])
1238
1943
  expect(output).toMatchInlineSnapshot(`
1239
1944
  "ok: true
1240
1945
  data:
@@ -1350,22 +2055,22 @@ describe('help', () => {
1350
2055
  Commands:
1351
2056
  ping Health check
1352
2057
 
1353
- Built-in Commands:
2058
+ Integrations:
1354
2059
  completions Generate shell completion script
1355
- mcp add Register as an MCP server
1356
- skills add Sync skill files to your agent
2060
+ mcp add Register as MCP server
2061
+ skills Sync skill files to agents (add, list)
1357
2062
 
1358
2063
  Global Options:
1359
2064
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1360
2065
  --format <toon|json|yaml|md|jsonl> Output format
2066
+ --full-output Show full output envelope
1361
2067
  --help Show help
1362
2068
  --llms, --llms-full Print LLM-readable manifest
1363
2069
  --mcp Start as MCP stdio server
1364
- --schema Show JSON Schema for a command
2070
+ --schema Show JSON Schema for command
1365
2071
  --token-count Print token count of output (instead of output)
1366
2072
  --token-limit <n> Limit output to n tokens
1367
2073
  --token-offset <n> Skip first n tokens of output
1368
- --verbose Show full output envelope
1369
2074
  --version Show version
1370
2075
  "
1371
2076
  `)
@@ -1388,22 +2093,22 @@ describe('help', () => {
1388
2093
  Commands:
1389
2094
  ping Health check
1390
2095
 
1391
- Built-in Commands:
2096
+ Integrations:
1392
2097
  completions Generate shell completion script
1393
- mcp add Register as an MCP server
1394
- skills add Sync skill files to your agent
2098
+ mcp add Register as MCP server
2099
+ skills Sync skill files to agents (add, list)
1395
2100
 
1396
2101
  Global Options:
1397
2102
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1398
2103
  --format <toon|json|yaml|md|jsonl> Output format
2104
+ --full-output Show full output envelope
1399
2105
  --help Show help
1400
2106
  --llms, --llms-full Print LLM-readable manifest
1401
2107
  --mcp Start as MCP stdio server
1402
- --schema Show JSON Schema for a command
2108
+ --schema Show JSON Schema for command
1403
2109
  --token-count Print token count of output (instead of output)
1404
2110
  --token-limit <n> Limit output to n tokens
1405
2111
  --token-offset <n> Skip first n tokens of output
1406
- --verbose Show full output envelope
1407
2112
  --version Show version
1408
2113
  "
1409
2114
  `)
@@ -1430,13 +2135,13 @@ describe('help', () => {
1430
2135
  Global Options:
1431
2136
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1432
2137
  --format <toon|json|yaml|md|jsonl> Output format
2138
+ --full-output Show full output envelope
1433
2139
  --help Show help
1434
2140
  --llms, --llms-full Print LLM-readable manifest
1435
- --schema Show JSON Schema for a command
2141
+ --schema Show JSON Schema for command
1436
2142
  --token-count Print token count of output (instead of output)
1437
2143
  --token-limit <n> Limit output to n tokens
1438
2144
  --token-offset <n> Skip first n tokens of output
1439
- --verbose Show full output envelope
1440
2145
  "
1441
2146
  `)
1442
2147
  })
@@ -1464,13 +2169,13 @@ describe('help', () => {
1464
2169
  Global Options:
1465
2170
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1466
2171
  --format <toon|json|yaml|md|jsonl> Output format
2172
+ --full-output Show full output envelope
1467
2173
  --help Show help
1468
2174
  --llms, --llms-full Print LLM-readable manifest
1469
- --schema Show JSON Schema for a command
2175
+ --schema Show JSON Schema for command
1470
2176
  --token-count Print token count of output (instead of output)
1471
2177
  --token-limit <n> Limit output to n tokens
1472
2178
  --token-offset <n> Skip first n tokens of output
1473
- --verbose Show full output envelope
1474
2179
  "
1475
2180
  `)
1476
2181
  })
@@ -1551,22 +2256,22 @@ describe('help', () => {
1551
2256
  Commands:
1552
2257
  ping Ping
1553
2258
 
1554
- Built-in Commands:
2259
+ Integrations:
1555
2260
  completions Generate shell completion script
1556
- mcp add Register as an MCP server
1557
- skills add Sync skill files to your agent
2261
+ mcp add Register as MCP server
2262
+ skills Sync skill files to agents (add, list)
1558
2263
 
1559
2264
  Global Options:
1560
2265
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1561
2266
  --format <toon|json|yaml|md|jsonl> Output format
2267
+ --full-output Show full output envelope
1562
2268
  --help Show help
1563
2269
  --llms, --llms-full Print LLM-readable manifest
1564
2270
  --mcp Start as MCP stdio server
1565
- --schema Show JSON Schema for a command
2271
+ --schema Show JSON Schema for command
1566
2272
  --token-count Print token count of output (instead of output)
1567
2273
  --token-limit <n> Limit output to n tokens
1568
2274
  --token-offset <n> Skip first n tokens of output
1569
- --verbose Show full output envelope
1570
2275
  --version Show version
1571
2276
  "
1572
2277
  `)
@@ -1591,13 +2296,13 @@ describe('help', () => {
1591
2296
  Global Options:
1592
2297
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1593
2298
  --format <toon|json|yaml|md|jsonl> Output format
2299
+ --full-output Show full output envelope
1594
2300
  --help Show help
1595
2301
  --llms, --llms-full Print LLM-readable manifest
1596
- --schema Show JSON Schema for a command
2302
+ --schema Show JSON Schema for command
1597
2303
  --token-count Print token count of output (instead of output)
1598
2304
  --token-limit <n> Limit output to n tokens
1599
2305
  --token-offset <n> Skip first n tokens of output
1600
- --verbose Show full output envelope
1601
2306
  "
1602
2307
  `)
1603
2308
  })
@@ -1686,13 +2391,13 @@ describe('env', () => {
1686
2391
  Global Options:
1687
2392
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1688
2393
  --format <toon|json|yaml|md|jsonl> Output format
2394
+ --full-output Show full output envelope
1689
2395
  --help Show help
1690
2396
  --llms, --llms-full Print LLM-readable manifest
1691
- --schema Show JSON Schema for a command
2397
+ --schema Show JSON Schema for command
1692
2398
  --token-count Print token count of output (instead of output)
1693
2399
  --token-limit <n> Limit output to n tokens
1694
2400
  --token-offset <n> Skip first n tokens of output
1695
- --verbose Show full output envelope
1696
2401
 
1697
2402
  Environment Variables:
1698
2403
  API_TOKEN Auth token
@@ -1724,16 +2429,16 @@ describe('env', () => {
1724
2429
  Global Options:
1725
2430
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1726
2431
  --format <toon|json|yaml|md|jsonl> Output format
2432
+ --full-output Show full output envelope
1727
2433
  --help Show help
1728
2434
  --llms, --llms-full Print LLM-readable manifest
1729
- --schema Show JSON Schema for a command
2435
+ --schema Show JSON Schema for command
1730
2436
  --token-count Print token count of output (instead of output)
1731
2437
  --token-limit <n> Limit output to n tokens
1732
2438
  --token-offset <n> Skip first n tokens of output
1733
- --verbose Show full output envelope
1734
2439
 
1735
2440
  Environment Variables:
1736
- API_TOKEN Auth token (set: ••••ret)
2441
+ API_TOKEN Auth token (set: ****cret)
1737
2442
  API_URL API URL (default: https://api.example.com)
1738
2443
  "
1739
2444
  `)
@@ -1742,7 +2447,7 @@ describe('env', () => {
1742
2447
  process.env.API_URL = 'https://custom.example.com'
1743
2448
  const { output: output2 } = await serve(cli, ['deploy', '--help'])
1744
2449
  expect(output2).toContain(
1745
- 'API_URL API URL (set: ••••com, default: https://api.example.com)',
2450
+ 'API_URL API URL (set: ****.com, default: https://api.example.com)',
1746
2451
  )
1747
2452
  } finally {
1748
2453
  delete process.env.API_TOKEN
@@ -1770,7 +2475,7 @@ describe('env', () => {
1770
2475
  const { output: output2 } = await serve(cli, ['deploy', '--help'], {
1771
2476
  env: { API_TOKEN: 'secret' },
1772
2477
  })
1773
- expect(output2).toContain('set: ••••ret')
2478
+ expect(output2).toContain('set: ****cret')
1774
2479
  })
1775
2480
 
1776
2481
  test('--llms json includes schema.env', async () => {
@@ -1838,25 +2543,177 @@ describe('env', () => {
1838
2543
  })
1839
2544
  })
1840
2545
 
2546
+ describe('built-in commands', () => {
2547
+ test('bare completions shows help', async () => {
2548
+ const cli = Cli.create('test')
2549
+ cli.command('ping', { run: () => ({ pong: true }) })
2550
+ const { output } = await serve(cli, ['completions'])
2551
+ expect(output).toContain('Generate shell completion script')
2552
+ })
2553
+
2554
+ test('completions --help shows help', async () => {
2555
+ const cli = Cli.create('test')
2556
+ cli.command('ping', { run: () => ({ pong: true }) })
2557
+ const { output } = await serve(cli, ['completions', '--help'])
2558
+ expect(output).toContain('test completions')
2559
+ expect(output).toContain('Generate shell completion script')
2560
+ })
2561
+
2562
+ test('bare mcp shows help with subcommands', async () => {
2563
+ const cli = Cli.create('test')
2564
+ cli.command('ping', { run: () => ({ pong: true }) })
2565
+ const { output } = await serve(cli, ['mcp'])
2566
+ expect(output).toContain('test mcp')
2567
+ expect(output).toContain('Register as MCP server')
2568
+ expect(output).toContain('add')
2569
+ })
2570
+
2571
+ test('mcp --help shows help with subcommands', async () => {
2572
+ const cli = Cli.create('test')
2573
+ cli.command('ping', { run: () => ({ pong: true }) })
2574
+ const { output } = await serve(cli, ['mcp', '--help'])
2575
+ expect(output).toContain('test mcp')
2576
+ expect(output).toContain('add')
2577
+ })
2578
+
2579
+ test('mcp add --help shows options', async () => {
2580
+ const cli = Cli.create('test')
2581
+ cli.command('ping', { run: () => ({ pong: true }) })
2582
+ const { output } = await serve(cli, ['mcp', 'add', '--help'])
2583
+ expect(output).toContain('test mcp add')
2584
+ expect(output).toContain('--command')
2585
+ expect(output).toContain('--no-global')
2586
+ expect(output).toContain('--agent')
2587
+ })
2588
+
2589
+ test('bare skills shows help with subcommands', async () => {
2590
+ const cli = Cli.create('test')
2591
+ cli.command('ping', { run: () => ({ pong: true }) })
2592
+ const { output } = await serve(cli, ['skills'])
2593
+ expect(output).toContain('test skills')
2594
+ expect(output).toContain('Sync skill files to agents')
2595
+ expect(output).toContain('add')
2596
+ })
2597
+
2598
+ test('skills --help shows help with subcommands', async () => {
2599
+ const cli = Cli.create('test')
2600
+ cli.command('ping', { run: () => ({ pong: true }) })
2601
+ const { output } = await serve(cli, ['skills', '--help'])
2602
+ expect(output).toContain('test skills')
2603
+ expect(output).toContain('add')
2604
+ })
2605
+
2606
+ test('skills typo suggests add', async () => {
2607
+ const cli = Cli.create('test')
2608
+ cli.command('ping', { run: () => ({}) })
2609
+ const { output, exitCode } = await serve(cli, ['skills', 'addd'])
2610
+ expect(exitCode).toBe(1)
2611
+ expect(output).toContain("Did you mean 'add'?")
2612
+ expect(output).toContain('test skills add')
2613
+ expect(output).toContain('test skills --help')
2614
+ })
2615
+
2616
+ test('mcp typo suggests add', async () => {
2617
+ const cli = Cli.create('test')
2618
+ cli.command('ping', { run: () => ({}) })
2619
+ const { output, exitCode } = await serve(cli, ['mcp', 'addd'])
2620
+ expect(exitCode).toBe(1)
2621
+ expect(output).toContain("Did you mean 'add'?")
2622
+ expect(output).toContain('test mcp add')
2623
+ })
2624
+
2625
+ test('skills add --help shows options', async () => {
2626
+ const cli = Cli.create('test')
2627
+ cli.command('ping', { run: () => ({ pong: true }) })
2628
+ const { output } = await serve(cli, ['skills', 'add', '--help'])
2629
+ expect(output).toContain('test skills add')
2630
+ expect(output).toContain('--depth')
2631
+ expect(output).toContain('--no-global')
2632
+ })
2633
+
2634
+ test('skills list --help shows description', async () => {
2635
+ const cli = Cli.create('test')
2636
+ cli.command('ping', { run: () => ({ pong: true }) })
2637
+ const { output } = await serve(cli, ['skills', 'list', '--help'])
2638
+ expect(output).toContain('test skills list')
2639
+ expect(output).toContain('List skills')
2640
+ })
2641
+
2642
+ test('skills list shows skills with install status', async () => {
2643
+ const cli = Cli.create('test')
2644
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2645
+ cli.command('greet', { description: 'Say hello', run: () => ({ hi: true }) })
2646
+ const { output } = await serve(cli, ['skills', 'list'])
2647
+ expect(output).toContain('✗')
2648
+ expect(output).toContain('test-ping')
2649
+ expect(output).toContain('test-greet')
2650
+ expect(output).toContain('installed')
2651
+ })
2652
+ })
2653
+
1841
2654
  describe('skills staleness', () => {
1842
2655
  let stderrSpy: ReturnType<typeof vi.spyOn>
1843
2656
 
1844
2657
  beforeEach(() => {
1845
2658
  stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
1846
2659
  __mockSkillsHash = undefined
2660
+ __mockSkillsInstalled = true
1847
2661
  })
1848
2662
 
1849
2663
  afterEach(() => {
1850
2664
  stderrSpy.mockRestore()
2665
+ __mockSkillsHash = undefined
2666
+ __mockSkillsInstalled = true
1851
2667
  })
1852
2668
 
1853
- test('warns on stderr when skills are stale', async () => {
2669
+ test('includes skills CTA when stale', async () => {
1854
2670
  __mockSkillsHash = '0000000000000000'
1855
2671
  const cli = Cli.create('test')
1856
2672
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1857
2673
 
1858
- await serve(cli, ['ping'])
1859
- expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skills are out of date. Run '"))
2674
+ const { output } = await serve(cli, ['ping'])
2675
+ expect(output).toContain('Skills are out of date:')
2676
+ expect(output).toContain('skills add')
2677
+ })
2678
+
2679
+ test('uses displayName for stale skills CTA when invoked directly', async () => {
2680
+ const savedArgv1 = process.argv[1]
2681
+ const savedAgent = process.env.npm_config_user_agent
2682
+ const savedExec = process.env.npm_execpath
2683
+ try {
2684
+ process.argv[1] = '/usr/local/bin/mc'
2685
+ delete process.env.npm_config_user_agent
2686
+ delete process.env.npm_execpath
2687
+
2688
+ __mockSkillsHash = '0000000000000000'
2689
+ const cli = Cli.create({ name: 'my-cli', aliases: ['mc'] })
2690
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2691
+
2692
+ const { output } = await serve(cli, ['ping'])
2693
+
2694
+ expect(output).toContain('mc skills add')
2695
+ expect(output).not.toContain('npx my-cli skills add')
2696
+ } finally {
2697
+ if (savedArgv1 === undefined) process.argv[1] = undefined as any
2698
+ else process.argv[1] = savedArgv1
2699
+ process.env.npm_config_user_agent = savedAgent
2700
+ process.env.npm_execpath = savedExec
2701
+ }
2702
+ })
2703
+
2704
+ test('merges skills CTA with command CTA', async () => {
2705
+ __mockSkillsHash = '0000000000000000'
2706
+ ;(process.stdout as any).isTTY = true
2707
+ const cli = Cli.create('test')
2708
+ cli.command('ping', {
2709
+ description: 'Health check',
2710
+ run: (c) => c.ok({ pong: true }, { cta: { commands: ['status'] } }),
2711
+ })
2712
+
2713
+ const { output } = await serve(cli, ['ping'])
2714
+ ;(process.stdout as any).isTTY = false
2715
+ expect(output).toContain('status')
2716
+ expect(output).toContain('skills add')
1860
2717
  })
1861
2718
 
1862
2719
  test('does not warn when hash matches', async () => {
@@ -1865,8 +2722,8 @@ describe('skills staleness', () => {
1865
2722
  const cli = Cli.create('test')
1866
2723
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1867
2724
 
1868
- await serve(cli, ['ping'])
1869
- expect(stderrSpy).not.toHaveBeenCalled()
2725
+ const { output } = await serve(cli, ['ping'])
2726
+ expect(output).not.toContain('Skills are out of date')
1870
2727
  })
1871
2728
 
1872
2729
  test('does not warn when no hash stored', async () => {
@@ -1874,8 +2731,18 @@ describe('skills staleness', () => {
1874
2731
  const cli = Cli.create('test')
1875
2732
  cli.command('ping', { run: () => ({ pong: true }) })
1876
2733
 
1877
- await serve(cli, ['ping'])
1878
- expect(stderrSpy).not.toHaveBeenCalled()
2734
+ const { output } = await serve(cli, ['ping'])
2735
+ expect(output).not.toContain('Skills are out of date')
2736
+ })
2737
+
2738
+ test('does not warn when skills are not installed', async () => {
2739
+ __mockSkillsHash = '0000000000000000'
2740
+ __mockSkillsInstalled = false
2741
+ const cli = Cli.create('test')
2742
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2743
+
2744
+ const { output } = await serve(cli, ['ping'])
2745
+ expect(output).not.toContain('Skills are out of date')
1879
2746
  })
1880
2747
 
1881
2748
  test('does not warn for skills add', async () => {
@@ -1892,8 +2759,8 @@ describe('skills staleness', () => {
1892
2759
  const cli = Cli.create('test')
1893
2760
  cli.command('ping', { run: () => ({ pong: true }) })
1894
2761
 
1895
- await serve(cli, ['--help'])
1896
- expect(stderrSpy).not.toHaveBeenCalled()
2762
+ const { output } = await serve(cli, ['--help'])
2763
+ expect(output).not.toContain('Skills are out of date')
1897
2764
  })
1898
2765
  })
1899
2766
 
@@ -2114,10 +2981,10 @@ describe('outputPolicy', () => {
2114
2981
  expect(deploy.output).not.toContain('deploy-123')
2115
2982
  expect(deploy.output).toContain('Check status')
2116
2983
 
2117
- // deploy --verbose: agent mode shows everything
2118
- const deployVerbose = await serve(cli, ['deploy', 'staging', '--verbose'])
2119
- expect(deployVerbose.output).toContain('deploy-123')
2120
- expect(deployVerbose.output).toContain('staging.example.com')
2984
+ // deploy --full-output: agent mode shows everything
2985
+ const deployFullOutput = await serve(cli, ['deploy', 'staging', '--full-output'])
2986
+ expect(deployFullOutput.output).toContain('deploy-123')
2987
+ expect(deployFullOutput.output).toContain('staging.example.com')
2121
2988
 
2122
2989
  // deploy --json: agent mode shows data
2123
2990
  const deployJson = await serve(cli, ['deploy', 'staging', '--json'])
@@ -2270,24 +3137,6 @@ describe('outputPolicy', () => {
2270
3137
  expect(capturedEnv).toEqual({ API_TOKEN: 'secret-123', API_URL: 'https://api.example.com' })
2271
3138
  })
2272
3139
 
2273
- test('e2e: middleware receives parsed CLI-level options', async () => {
2274
- let capturedOptions: any
2275
- const cli = Cli.create('test', {
2276
- options: z.object({
2277
- token: z.string().default(''),
2278
- dry: z.boolean().default(false),
2279
- }),
2280
- })
2281
- .use(async (c, next) => {
2282
- capturedOptions = c.options
2283
- await next()
2284
- })
2285
- .command('deploy', { run: () => ({ ok: true }) })
2286
-
2287
- await serve(cli, ['deploy', '--token', 'abc123', '--dry'])
2288
- expect(capturedOptions).toEqual({ token: 'abc123', dry: true })
2289
- })
2290
-
2291
3140
  test('e2e: CLI-level env validation error before middleware runs', async () => {
2292
3141
  const cli = Cli.create('test', {
2293
3142
  env: z.object({ API_TOKEN: z.string() }),
@@ -2891,11 +3740,11 @@ describe('fetch', async () => {
2891
3740
  expect(JSON.parse(output)).toEqual({ ok: true })
2892
3741
  })
2893
3742
 
2894
- test('--verbose includes request/response meta', async () => {
3743
+ test('--full-output includes request/response meta', async () => {
2895
3744
  const cli = Cli.create('test', { description: 'test' }).command('api', {
2896
3745
  fetch: app.fetch,
2897
3746
  })
2898
- const { output } = await serve(cli, ['api', 'health', '--verbose', '--format', 'json'])
3747
+ const { output } = await serve(cli, ['api', 'health', '--full-output', '--format', 'json'])
2899
3748
  const parsed = JSON.parse(output)
2900
3749
  expect(parsed.ok).toBe(true)
2901
3750
  expect(parsed.data).toEqual({ ok: true })
@@ -2923,6 +3772,15 @@ describe('fetch', async () => {
2923
3772
  `)
2924
3773
  })
2925
3774
 
3775
+ test('root-level fetch with typo of known command → did you mean', async () => {
3776
+ const cli = Cli.create('api', { description: 'API', fetch: app.fetch }).command('upgrade', {
3777
+ run: () => ({ upgraded: true }),
3778
+ })
3779
+ const { output, exitCode } = await serve(cli, ['upgra'])
3780
+ expect(exitCode).toBe(1)
3781
+ expect(output).toContain("Did you mean 'upgrade'?")
3782
+ })
3783
+
2926
3784
  test('root-level fetch with no args → root path', async () => {
2927
3785
  const cli = Cli.create('api', { description: 'API', fetch: app.fetch })
2928
3786
  // Hono returns 404 for / since we don't have a root route
@@ -3105,6 +3963,14 @@ describe('fetch', () => {
3105
3963
  `)
3106
3964
  })
3107
3965
 
3966
+ test('GET /helath → 404 with suggestion', async () => {
3967
+ const cli = Cli.create('test')
3968
+ cli.command('health', { run: () => ({}) })
3969
+ const res = await fetchJson(cli, new Request('http://localhost/helath'))
3970
+ expect(res.status).toBe(404)
3971
+ expect(res.body.error.message).toContain("Did you mean 'health'?")
3972
+ })
3973
+
3108
3974
  test('GET / with root command → 200', async () => {
3109
3975
  const cli = Cli.create('test', { run: () => ({ root: true }) })
3110
3976
  expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
@@ -3393,6 +4259,84 @@ describe('fetch', () => {
3393
4259
  `)
3394
4260
  })
3395
4261
 
4262
+ test('group middleware runs for nested commands', async () => {
4263
+ const sub = Cli.create('admin', {
4264
+ vars: z.object({ role: z.string().default('none') }),
4265
+ })
4266
+ sub.use(async (c, next) => {
4267
+ c.set('role', 'admin')
4268
+ await next()
4269
+ })
4270
+ sub.command('status', {
4271
+ run: (c) => ({ role: c.var.role }),
4272
+ })
4273
+ const cli = Cli.create('test', {
4274
+ vars: z.object({ role: z.string().default('none') }),
4275
+ })
4276
+ cli.command(sub)
4277
+ expect(await fetchJson(cli, new Request('http://localhost/admin/status')))
4278
+ .toMatchInlineSnapshot(`
4279
+ {
4280
+ "body": {
4281
+ "data": {
4282
+ "role": "admin",
4283
+ },
4284
+ "meta": {
4285
+ "command": "admin status",
4286
+ "duration": "<stripped>",
4287
+ },
4288
+ "ok": true,
4289
+ },
4290
+ "status": 200,
4291
+ }
4292
+ `)
4293
+ })
4294
+
4295
+ test('cli-level env schema is parsed', async () => {
4296
+ const cli = Cli.create('test', {
4297
+ env: z.object({ APP_TOKEN: z.string().default('fallback') }),
4298
+ })
4299
+ cli.use(async (c, next) => {
4300
+ // env should be parsed from envSchema
4301
+ ;(globalThis as any).__testEnv = c.env
4302
+ await next()
4303
+ })
4304
+ cli.command('check', { run: () => ({ ok: true }) })
4305
+ await cli.fetch(new Request('http://localhost/check'))
4306
+ expect((globalThis as any).__testEnv).toEqual({ APP_TOKEN: 'fallback' })
4307
+ delete (globalThis as any).__testEnv
4308
+ })
4309
+
4310
+ test('retryable error is propagated', async () => {
4311
+ const cli = Cli.create('test')
4312
+ cli.command('rate-limit', {
4313
+ run: (c) => c.error({ code: 'RATE_LIMITED', message: 'slow down', retryable: true }),
4314
+ })
4315
+ const { body } = await fetchJson(cli, new Request('http://localhost/rate-limit'))
4316
+ expect(body.ok).toBe(false)
4317
+ expect(body.error.retryable).toBe(true)
4318
+ })
4319
+
4320
+ test('cta block is propagated', async () => {
4321
+ const cli = Cli.create('test')
4322
+ cli.command('done', {
4323
+ run: (c) =>
4324
+ c.ok({ id: 1 }, { cta: { commands: ['list'], description: 'Suggested commands:' } }),
4325
+ })
4326
+ const { body } = await fetchJson(cli, new Request('http://localhost/done'))
4327
+ expect(body.ok).toBe(true)
4328
+ expect(body.meta.cta).toMatchInlineSnapshot(`
4329
+ {
4330
+ "commands": [
4331
+ {
4332
+ "command": "test list",
4333
+ },
4334
+ ],
4335
+ "description": "Suggested commands:",
4336
+ }
4337
+ `)
4338
+ })
4339
+
3396
4340
  describe('mcp over http', () => {
3397
4341
  function mcpCli() {
3398
4342
  const cli = Cli.create('test', { version: '1.0.0' })
@@ -3539,3 +4483,189 @@ describe('fetch', () => {
3539
4483
  })
3540
4484
  })
3541
4485
  })
4486
+
4487
+ describe('displayName', () => {
4488
+ beforeEach(() => {
4489
+ const savedArgv1 = process.argv[1]
4490
+ return () => {
4491
+ process.argv[1] = savedArgv1!
4492
+ }
4493
+ })
4494
+
4495
+ test('defaults to name when argv[1] is not an alias', async () => {
4496
+ process.argv[1] = '/usr/local/bin/my-cli'
4497
+ const cli = Cli.create({
4498
+ name: 'my-cli',
4499
+ aliases: ['mc'],
4500
+ }).command('ping', {
4501
+ run: (c) => c.ok({ displayName: c.displayName }),
4502
+ })
4503
+ const { output } = await serve(cli, ['ping', '--json'])
4504
+ expect(JSON.parse(output).displayName).toBe('my-cli')
4505
+ })
4506
+
4507
+ test('resolves alias from argv[1]', async () => {
4508
+ process.argv[1] = '/usr/local/bin/mc'
4509
+ const cli = Cli.create({
4510
+ name: 'my-cli',
4511
+ aliases: ['mc'],
4512
+ }).command('ping', {
4513
+ run: (c) => c.ok({ displayName: c.displayName }),
4514
+ })
4515
+ const { output } = await serve(cli, ['ping', '--json'])
4516
+ expect(JSON.parse(output).displayName).toBe('mc')
4517
+ })
4518
+
4519
+ test('falls back to name when argv[1] is undefined', async () => {
4520
+ process.argv[1] = undefined as any
4521
+ const cli = Cli.create({
4522
+ name: 'my-cli',
4523
+ aliases: ['mc'],
4524
+ }).command('ping', {
4525
+ run: (c) => c.ok({ displayName: c.displayName }),
4526
+ })
4527
+ const { output } = await serve(cli, ['ping', '--json'])
4528
+ expect(JSON.parse(output).displayName).toBe('my-cli')
4529
+ })
4530
+
4531
+ test('available in middleware context', async () => {
4532
+ process.argv[1] = '/usr/local/bin/mc'
4533
+ let middlewareDisplayName: string | undefined
4534
+ const cli = Cli.create({
4535
+ name: 'my-cli',
4536
+ aliases: ['mc'],
4537
+ })
4538
+ .use((c, next) => {
4539
+ middlewareDisplayName = c.displayName
4540
+ return next()
4541
+ })
4542
+ .command('ping', {
4543
+ run: (c) => c.ok({ ok: true }),
4544
+ })
4545
+ await serve(cli, ['ping', '--json'])
4546
+ expect(middlewareDisplayName).toBe('mc')
4547
+ })
4548
+
4549
+ test('available in root run context', async () => {
4550
+ process.argv[1] = '/usr/local/bin/mc'
4551
+ const cli = Cli.create({
4552
+ name: 'my-cli',
4553
+ aliases: ['mc'],
4554
+ run: (c) => c.ok({ displayName: c.displayName }),
4555
+ })
4556
+ const { output } = await serve(cli, ['--json'])
4557
+ expect(JSON.parse(output).displayName).toBe('mc')
4558
+ })
4559
+
4560
+ test('cta commands use displayName', async () => {
4561
+ process.argv[1] = '/usr/local/bin/mc'
4562
+ const cli = Cli.create({
4563
+ name: 'my-cli',
4564
+ aliases: ['mc'],
4565
+ }).command('ping', {
4566
+ run: (c) => c.ok({ ok: true }, { cta: { commands: ['login'] } }),
4567
+ })
4568
+ const { output } = await serve(cli, ['ping', '--json', '--full-output'])
4569
+ const parsed = JSON.parse(output)
4570
+ expect(parsed.meta.cta.commands[0].command).toBe('mc login')
4571
+ })
4572
+ })
4573
+
4574
+ test('--format rejects invalid format values', async () => {
4575
+ const cli = Cli.create('test').command('hello', {
4576
+ run: (c) => c.ok({ message: 'hi' }),
4577
+ })
4578
+
4579
+ const { exitCode, output } = await serve(cli, ['hello', '--format', 'xml'])
4580
+ expect(exitCode).toBe(1)
4581
+ expect(output).toMatch(/invalid|unsupported|unknown.*format/i)
4582
+ })
4583
+
4584
+ test('--token-limit with non-numeric value errors', async () => {
4585
+ const cli = Cli.create('test').command('hello', {
4586
+ run: (c) => c.ok({ message: 'hello world' }),
4587
+ })
4588
+
4589
+ const { exitCode, output } = await serve(cli, ['hello', '--token-limit', 'foo', '--json'])
4590
+ expect(exitCode).toBe(1)
4591
+ expect(output).not.toContain('NaN')
4592
+ })
4593
+
4594
+ test('--token-offset with non-numeric value errors', async () => {
4595
+ const cli = Cli.create('test').command('hello', {
4596
+ run: (c) => c.ok({ message: 'hello world' }),
4597
+ })
4598
+
4599
+ const { exitCode, output } = await serve(cli, ['hello', '--token-offset', 'foo', '--json'])
4600
+ expect(exitCode).toBe(1)
4601
+ expect(output).not.toContain('NaN')
4602
+ })
4603
+
4604
+ describe('command aliases', () => {
4605
+ function makeAliasedCli() {
4606
+ return Cli.create('gh').command('extension', {
4607
+ aliases: ['extensions', 'ext'],
4608
+ description: 'Manage extensions',
4609
+ run: () => ({ result: 'ok' }),
4610
+ })
4611
+ }
4612
+
4613
+ test('resolves canonical command name', async () => {
4614
+ const { output } = await serve(makeAliasedCli(), ['extension'])
4615
+ expect(output).toContain('ok')
4616
+ })
4617
+
4618
+ test('resolves alias name', async () => {
4619
+ const { output } = await serve(makeAliasedCli(), ['extensions'])
4620
+ expect(output).toContain('ok')
4621
+ })
4622
+
4623
+ test('resolves short alias name', async () => {
4624
+ const { output } = await serve(makeAliasedCli(), ['ext'])
4625
+ expect(output).toContain('ok')
4626
+ })
4627
+
4628
+ test('root help does not show aliases', async () => {
4629
+ const { output } = await serve(makeAliasedCli(), ['--help'])
4630
+ const commandsSection = output.split('Commands:')[1]!.split('Integrations:')[0]!
4631
+ const names = commandsSection
4632
+ .trim()
4633
+ .split('\n')
4634
+ .map((l) => l.trim().split(/\s{2,}/)[0]!)
4635
+ expect(names).toContain('extension')
4636
+ expect(names).not.toContain('extensions')
4637
+ expect(names).not.toContain('ext')
4638
+ })
4639
+
4640
+ test('command help shows aliases line', async () => {
4641
+ const { output } = await serve(makeAliasedCli(), ['extension', '--help'])
4642
+ expect(output).toContain('Aliases: extensions, ext')
4643
+ })
4644
+
4645
+ test('aliases work inside command groups', async () => {
4646
+ const sub = Cli.create('repo', { description: 'Manage repos' }).command('list', {
4647
+ aliases: ['ls'],
4648
+ description: 'List repos',
4649
+ run: () => ({ repos: [] }),
4650
+ })
4651
+ const cli = Cli.create('gh').command(sub)
4652
+ const { output } = await serve(cli, ['repo', 'ls'])
4653
+ expect(output).toContain('repos')
4654
+ })
4655
+
4656
+ test('did-you-mean suggests aliases', async () => {
4657
+ const { output } = await serve(makeAliasedCli(), ['exten'])
4658
+ expect(output).toMatch(/did you mean.*extension/i)
4659
+ })
4660
+
4661
+ test('root CLI aliases register as command aliases', async () => {
4662
+ const update = Cli.create('update', {
4663
+ aliases: ['upgrade'],
4664
+ description: 'Update packages',
4665
+ run: () => ({ result: 'updated' }),
4666
+ })
4667
+ const cli = Cli.create('pkg').command(update)
4668
+ const { output } = await serve(cli, ['upgrade'])
4669
+ expect(output).toContain('updated')
4670
+ })
4671
+ })