incur 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +61 -0
  2. package/dist/Cli.d.ts +15 -0
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +300 -25
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Filter.js +0 -18
  7. package/dist/Filter.js.map +1 -1
  8. package/dist/Help.d.ts +4 -0
  9. package/dist/Help.d.ts.map +1 -1
  10. package/dist/Help.js +17 -14
  11. package/dist/Help.js.map +1 -1
  12. package/dist/Parser.d.ts +2 -0
  13. package/dist/Parser.d.ts.map +1 -1
  14. package/dist/Parser.js +69 -37
  15. package/dist/Parser.js.map +1 -1
  16. package/dist/bin.d.ts +1 -0
  17. package/dist/bin.d.ts.map +1 -1
  18. package/dist/bin.js +17 -2
  19. package/dist/bin.js.map +1 -1
  20. package/dist/internal/command.d.ts +2 -0
  21. package/dist/internal/command.d.ts.map +1 -1
  22. package/dist/internal/command.js +1 -0
  23. package/dist/internal/command.js.map +1 -1
  24. package/dist/internal/configSchema.d.ts +8 -0
  25. package/dist/internal/configSchema.d.ts.map +1 -0
  26. package/dist/internal/configSchema.js +57 -0
  27. package/dist/internal/configSchema.js.map +1 -0
  28. package/dist/internal/helpers.d.ts +9 -0
  29. package/dist/internal/helpers.d.ts.map +1 -0
  30. package/dist/internal/helpers.js +39 -0
  31. package/dist/internal/helpers.js.map +1 -0
  32. package/examples/npm/.npmrc.json +21 -0
  33. package/examples/npm/config.schema.json +137 -0
  34. package/package.json +1 -1
  35. package/src/Cli.test-d.ts +39 -0
  36. package/src/Cli.test.ts +714 -25
  37. package/src/Cli.ts +353 -27
  38. package/src/Filter.ts +0 -17
  39. package/src/Help.test.ts +66 -0
  40. package/src/Help.ts +20 -13
  41. package/src/Openapi.test.ts +6 -1
  42. package/src/Parser.test-d.ts +22 -0
  43. package/src/Parser.test.ts +89 -0
  44. package/src/Parser.ts +86 -35
  45. package/src/bin.ts +21 -2
  46. package/src/e2e.test.ts +22 -19
  47. package/src/internal/command.ts +3 -0
  48. package/src/internal/configSchema.test.ts +193 -0
  49. package/src/internal/configSchema.ts +66 -0
  50. package/src/internal/helpers.test.ts +54 -0
  51. package/src/internal/helpers.ts +41 -0
package/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')
@@ -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: "Next steps:"
695
+ commands[1]{command,description}:
696
+ test --help,see all available commands
146
697
  "
147
698
  `)
148
699
  })
@@ -157,8 +708,8 @@ 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
+ Next steps:
712
+ test --help # see all available commands
162
713
  "
163
714
  `)
164
715
  })
@@ -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: "Next steps:"
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: "Next steps:"
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
+ Next steps:
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', '--verbose'])
791
+ expect(output).toContain('test deploy --verbose')
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: "Next steps:"
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', {
@@ -738,6 +1375,14 @@ describe('--schema', () => {
738
1375
  expect(exitCode).toBe(1)
739
1376
  })
740
1377
 
1378
+ test('on unknown command suggests similar', async () => {
1379
+ const cli = Cli.create('test')
1380
+ cli.command('greet', { run: () => ({}) })
1381
+ const { output, exitCode } = await serve(cli, ['grete', '--schema'])
1382
+ expect(output).toContain("Did you mean 'greet'?")
1383
+ expect(exitCode).toBe(1)
1384
+ })
1385
+
741
1386
  test('on group shows available commands', async () => {
742
1387
  const cli = Cli.create('test')
743
1388
  const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
@@ -871,9 +1516,9 @@ describe('subcommands', () => {
871
1516
  "code: COMMAND_NOT_FOUND
872
1517
  message: 'unknown' is not a command for 'test pr'.
873
1518
  cta:
874
- description: "See available commands:"
875
- commands[1]{command}:
876
- test pr --help
1519
+ description: "Next steps:"
1520
+ commands[1]{command,description}:
1521
+ test pr --help,see all available commands
877
1522
  "
878
1523
  `)
879
1524
  })
@@ -892,8 +1537,8 @@ describe('subcommands', () => {
892
1537
  expect(output).toMatchInlineSnapshot(`
893
1538
  "Error: 'unknown' is not a command for 'test pr'.
894
1539
 
895
- See available commands:
896
- test pr --help
1540
+ Next steps:
1541
+ test pr --help # see all available commands
897
1542
  "
898
1543
  `)
899
1544
  })
@@ -1733,7 +2378,7 @@ describe('env', () => {
1733
2378
  --verbose Show full output envelope
1734
2379
 
1735
2380
  Environment Variables:
1736
- API_TOKEN Auth token (set: ••••ret)
2381
+ API_TOKEN Auth token (set: ****cret)
1737
2382
  API_URL API URL (default: https://api.example.com)
1738
2383
  "
1739
2384
  `)
@@ -1742,7 +2387,7 @@ describe('env', () => {
1742
2387
  process.env.API_URL = 'https://custom.example.com'
1743
2388
  const { output: output2 } = await serve(cli, ['deploy', '--help'])
1744
2389
  expect(output2).toContain(
1745
- 'API_URL API URL (set: ••••com, default: https://api.example.com)',
2390
+ 'API_URL API URL (set: ****.com, default: https://api.example.com)',
1746
2391
  )
1747
2392
  } finally {
1748
2393
  delete process.env.API_TOKEN
@@ -1770,7 +2415,7 @@ describe('env', () => {
1770
2415
  const { output: output2 } = await serve(cli, ['deploy', '--help'], {
1771
2416
  env: { API_TOKEN: 'secret' },
1772
2417
  })
1773
- expect(output2).toContain('set: ••••ret')
2418
+ expect(output2).toContain('set: ****cret')
1774
2419
  })
1775
2420
 
1776
2421
  test('--llms json includes schema.env', async () => {
@@ -1898,6 +2543,25 @@ describe('built-in commands', () => {
1898
2543
  expect(output).toContain('add')
1899
2544
  })
1900
2545
 
2546
+ test('skills typo suggests add', async () => {
2547
+ const cli = Cli.create('test')
2548
+ cli.command('ping', { run: () => ({}) })
2549
+ const { output, exitCode } = await serve(cli, ['skills', 'addd'])
2550
+ expect(exitCode).toBe(1)
2551
+ expect(output).toContain("Did you mean 'add'?")
2552
+ expect(output).toContain('test skills add')
2553
+ expect(output).toContain('test skills --help')
2554
+ })
2555
+
2556
+ test('mcp typo suggests add', async () => {
2557
+ const cli = Cli.create('test')
2558
+ cli.command('ping', { run: () => ({}) })
2559
+ const { output, exitCode } = await serve(cli, ['mcp', 'addd'])
2560
+ expect(exitCode).toBe(1)
2561
+ expect(output).toContain("Did you mean 'add'?")
2562
+ expect(output).toContain('test mcp add')
2563
+ })
2564
+
1901
2565
  test('skills add --help shows options', async () => {
1902
2566
  const cli = Cli.create('test')
1903
2567
  cli.command('ping', { run: () => ({ pong: true }) })
@@ -1918,15 +2582,32 @@ describe('skills staleness', () => {
1918
2582
 
1919
2583
  afterEach(() => {
1920
2584
  stderrSpy.mockRestore()
2585
+ __mockSkillsHash = undefined
1921
2586
  })
1922
2587
 
1923
- test('warns on stderr when skills are stale', async () => {
2588
+ test('includes skills CTA when stale', async () => {
1924
2589
  __mockSkillsHash = '0000000000000000'
1925
2590
  const cli = Cli.create('test')
1926
2591
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1927
2592
 
1928
- await serve(cli, ['ping'])
1929
- expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skills are out of date. Run '"))
2593
+ const { output } = await serve(cli, ['ping'])
2594
+ expect(output).toContain('Skills are out of date:')
2595
+ expect(output).toContain('skills add')
2596
+ })
2597
+
2598
+ test('merges skills CTA with command CTA', async () => {
2599
+ __mockSkillsHash = '0000000000000000'
2600
+ ;(process.stdout as any).isTTY = true
2601
+ const cli = Cli.create('test')
2602
+ cli.command('ping', {
2603
+ description: 'Health check',
2604
+ run: (c) => c.ok({ pong: true }, { cta: { commands: ['status'] } }),
2605
+ })
2606
+
2607
+ const { output } = await serve(cli, ['ping'])
2608
+ ;(process.stdout as any).isTTY = false
2609
+ expect(output).toContain('status')
2610
+ expect(output).toContain('skills add')
1930
2611
  })
1931
2612
 
1932
2613
  test('does not warn when hash matches', async () => {
@@ -1935,8 +2616,8 @@ describe('skills staleness', () => {
1935
2616
  const cli = Cli.create('test')
1936
2617
  cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1937
2618
 
1938
- await serve(cli, ['ping'])
1939
- expect(stderrSpy).not.toHaveBeenCalled()
2619
+ const { output } = await serve(cli, ['ping'])
2620
+ expect(output).not.toContain('Skills are out of date')
1940
2621
  })
1941
2622
 
1942
2623
  test('does not warn when no hash stored', async () => {
@@ -1944,8 +2625,8 @@ describe('skills staleness', () => {
1944
2625
  const cli = Cli.create('test')
1945
2626
  cli.command('ping', { run: () => ({ pong: true }) })
1946
2627
 
1947
- await serve(cli, ['ping'])
1948
- expect(stderrSpy).not.toHaveBeenCalled()
2628
+ const { output } = await serve(cli, ['ping'])
2629
+ expect(output).not.toContain('Skills are out of date')
1949
2630
  })
1950
2631
 
1951
2632
  test('does not warn for skills add', async () => {
@@ -1962,8 +2643,8 @@ describe('skills staleness', () => {
1962
2643
  const cli = Cli.create('test')
1963
2644
  cli.command('ping', { run: () => ({ pong: true }) })
1964
2645
 
1965
- await serve(cli, ['--help'])
1966
- expect(stderrSpy).not.toHaveBeenCalled()
2646
+ const { output } = await serve(cli, ['--help'])
2647
+ expect(output).not.toContain('Skills are out of date')
1967
2648
  })
1968
2649
  })
1969
2650
 
@@ -3157,6 +3838,14 @@ describe('fetch', () => {
3157
3838
  `)
3158
3839
  })
3159
3840
 
3841
+ test('GET /helath → 404 with suggestion', async () => {
3842
+ const cli = Cli.create('test')
3843
+ cli.command('health', { run: () => ({}) })
3844
+ const res = await fetchJson(cli, new Request('http://localhost/helath'))
3845
+ expect(res.status).toBe(404)
3846
+ expect(res.body.error.message).toContain("Did you mean 'health'?")
3847
+ })
3848
+
3160
3849
  test('GET / with root command → 200', async () => {
3161
3850
  const cli = Cli.create('test', { run: () => ({ root: true }) })
3162
3851
  expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`