incur 0.4.0 → 0.4.1

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