incur 0.3.4 → 0.3.6

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 (68) hide show
  1. package/README.md +62 -1
  2. package/dist/Cli.d.ts +17 -7
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +435 -365
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Completions.d.ts +1 -2
  7. package/dist/Completions.d.ts.map +1 -1
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Filter.js +0 -18
  10. package/dist/Filter.js.map +1 -1
  11. package/dist/Help.d.ts +6 -0
  12. package/dist/Help.d.ts.map +1 -1
  13. package/dist/Help.js +35 -22
  14. package/dist/Help.js.map +1 -1
  15. package/dist/Mcp.d.ts +25 -5
  16. package/dist/Mcp.d.ts.map +1 -1
  17. package/dist/Mcp.js +61 -69
  18. package/dist/Mcp.js.map +1 -1
  19. package/dist/Parser.d.ts +2 -0
  20. package/dist/Parser.d.ts.map +1 -1
  21. package/dist/Parser.js +69 -37
  22. package/dist/Parser.js.map +1 -1
  23. package/dist/Skill.d.ts.map +1 -1
  24. package/dist/Skill.js +5 -1
  25. package/dist/Skill.js.map +1 -1
  26. package/dist/SyncSkills.d.ts.map +1 -1
  27. package/dist/SyncSkills.js +10 -1
  28. package/dist/SyncSkills.js.map +1 -1
  29. package/dist/bin.d.ts +1 -0
  30. package/dist/bin.d.ts.map +1 -1
  31. package/dist/bin.js +17 -2
  32. package/dist/bin.js.map +1 -1
  33. package/dist/internal/command.d.ts +118 -0
  34. package/dist/internal/command.d.ts.map +1 -0
  35. package/dist/internal/command.js +276 -0
  36. package/dist/internal/command.js.map +1 -0
  37. package/dist/internal/configSchema.d.ts +8 -0
  38. package/dist/internal/configSchema.d.ts.map +1 -0
  39. package/dist/internal/configSchema.js +57 -0
  40. package/dist/internal/configSchema.js.map +1 -0
  41. package/dist/internal/helpers.d.ts +5 -0
  42. package/dist/internal/helpers.d.ts.map +1 -0
  43. package/dist/internal/helpers.js +9 -0
  44. package/dist/internal/helpers.js.map +1 -0
  45. package/examples/npm/.npmrc.json +21 -0
  46. package/examples/npm/config.schema.json +137 -0
  47. package/package.json +1 -1
  48. package/src/Cli.test-d.ts +39 -0
  49. package/src/Cli.test.ts +704 -6
  50. package/src/Cli.ts +551 -448
  51. package/src/Completions.test.ts +35 -9
  52. package/src/Completions.ts +1 -2
  53. package/src/Filter.ts +0 -17
  54. package/src/Help.test.ts +77 -0
  55. package/src/Help.ts +39 -21
  56. package/src/Mcp.test.ts +143 -0
  57. package/src/Mcp.ts +92 -84
  58. package/src/Parser.test-d.ts +22 -0
  59. package/src/Parser.test.ts +89 -0
  60. package/src/Parser.ts +86 -35
  61. package/src/Skill.ts +5 -1
  62. package/src/SyncSkills.ts +11 -1
  63. package/src/bin.ts +21 -2
  64. package/src/e2e.test.ts +30 -17
  65. package/src/internal/command.ts +428 -0
  66. package/src/internal/configSchema.test.ts +193 -0
  67. package/src/internal/configSchema.ts +66 -0
  68. package/src/internal/helpers.ts +9 -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')
@@ -1350,7 +1901,7 @@ describe('help', () => {
1350
1901
  Commands:
1351
1902
  ping Health check
1352
1903
 
1353
- Built-in Commands:
1904
+ Integrations:
1354
1905
  completions Generate shell completion script
1355
1906
  mcp add Register as MCP server
1356
1907
  skills add Sync skill files to agents
@@ -1388,7 +1939,7 @@ describe('help', () => {
1388
1939
  Commands:
1389
1940
  ping Health check
1390
1941
 
1391
- Built-in Commands:
1942
+ Integrations:
1392
1943
  completions Generate shell completion script
1393
1944
  mcp add Register as MCP server
1394
1945
  skills add Sync skill files to agents
@@ -1551,7 +2102,7 @@ describe('help', () => {
1551
2102
  Commands:
1552
2103
  ping Ping
1553
2104
 
1554
- Built-in Commands:
2105
+ Integrations:
1555
2106
  completions Generate shell completion script
1556
2107
  mcp add Register as MCP server
1557
2108
  skills add Sync skill files to agents
@@ -1733,7 +2284,7 @@ describe('env', () => {
1733
2284
  --verbose Show full output envelope
1734
2285
 
1735
2286
  Environment Variables:
1736
- API_TOKEN Auth token (set: ••••ret)
2287
+ API_TOKEN Auth token (set: ****cret)
1737
2288
  API_URL API URL (default: https://api.example.com)
1738
2289
  "
1739
2290
  `)
@@ -1742,7 +2293,7 @@ describe('env', () => {
1742
2293
  process.env.API_URL = 'https://custom.example.com'
1743
2294
  const { output: output2 } = await serve(cli, ['deploy', '--help'])
1744
2295
  expect(output2).toContain(
1745
- 'API_URL API URL (set: ••••com, default: https://api.example.com)',
2296
+ 'API_URL API URL (set: ****.com, default: https://api.example.com)',
1746
2297
  )
1747
2298
  } finally {
1748
2299
  delete process.env.API_TOKEN
@@ -1770,7 +2321,7 @@ describe('env', () => {
1770
2321
  const { output: output2 } = await serve(cli, ['deploy', '--help'], {
1771
2322
  env: { API_TOKEN: 'secret' },
1772
2323
  })
1773
- expect(output2).toContain('set: ••••ret')
2324
+ expect(output2).toContain('set: ****cret')
1774
2325
  })
1775
2326
 
1776
2327
  test('--llms json includes schema.env', async () => {
@@ -1838,6 +2389,76 @@ describe('env', () => {
1838
2389
  })
1839
2390
  })
1840
2391
 
2392
+ describe('built-in commands', () => {
2393
+ test('bare completions shows help', async () => {
2394
+ const cli = Cli.create('test')
2395
+ cli.command('ping', { run: () => ({ pong: true }) })
2396
+ const { output } = await serve(cli, ['completions'])
2397
+ expect(output).toContain('Generate shell completion script')
2398
+ })
2399
+
2400
+ test('completions --help shows help', async () => {
2401
+ const cli = Cli.create('test')
2402
+ cli.command('ping', { run: () => ({ pong: true }) })
2403
+ const { output } = await serve(cli, ['completions', '--help'])
2404
+ expect(output).toContain('test completions')
2405
+ expect(output).toContain('Generate shell completion script')
2406
+ })
2407
+
2408
+ test('bare mcp shows help with subcommands', async () => {
2409
+ const cli = Cli.create('test')
2410
+ cli.command('ping', { run: () => ({ pong: true }) })
2411
+ const { output } = await serve(cli, ['mcp'])
2412
+ expect(output).toContain('test mcp')
2413
+ expect(output).toContain('Register as MCP server')
2414
+ expect(output).toContain('add')
2415
+ })
2416
+
2417
+ test('mcp --help shows help with subcommands', async () => {
2418
+ const cli = Cli.create('test')
2419
+ cli.command('ping', { run: () => ({ pong: true }) })
2420
+ const { output } = await serve(cli, ['mcp', '--help'])
2421
+ expect(output).toContain('test mcp')
2422
+ expect(output).toContain('add')
2423
+ })
2424
+
2425
+ test('mcp add --help shows options', async () => {
2426
+ const cli = Cli.create('test')
2427
+ cli.command('ping', { run: () => ({ pong: true }) })
2428
+ const { output } = await serve(cli, ['mcp', 'add', '--help'])
2429
+ expect(output).toContain('test mcp add')
2430
+ expect(output).toContain('--command')
2431
+ expect(output).toContain('--no-global')
2432
+ expect(output).toContain('--agent')
2433
+ })
2434
+
2435
+ test('bare skills shows help with subcommands', async () => {
2436
+ const cli = Cli.create('test')
2437
+ cli.command('ping', { run: () => ({ pong: true }) })
2438
+ const { output } = await serve(cli, ['skills'])
2439
+ expect(output).toContain('test skills')
2440
+ expect(output).toContain('Sync skill files to agents')
2441
+ expect(output).toContain('add')
2442
+ })
2443
+
2444
+ test('skills --help shows help with subcommands', async () => {
2445
+ const cli = Cli.create('test')
2446
+ cli.command('ping', { run: () => ({ pong: true }) })
2447
+ const { output } = await serve(cli, ['skills', '--help'])
2448
+ expect(output).toContain('test skills')
2449
+ expect(output).toContain('add')
2450
+ })
2451
+
2452
+ test('skills add --help shows options', async () => {
2453
+ const cli = Cli.create('test')
2454
+ cli.command('ping', { run: () => ({ pong: true }) })
2455
+ const { output } = await serve(cli, ['skills', 'add', '--help'])
2456
+ expect(output).toContain('test skills add')
2457
+ expect(output).toContain('--depth')
2458
+ expect(output).toContain('--no-global')
2459
+ })
2460
+ })
2461
+
1841
2462
  describe('skills staleness', () => {
1842
2463
  let stderrSpy: ReturnType<typeof vi.spyOn>
1843
2464
 
@@ -3375,6 +3996,83 @@ describe('fetch', () => {
3375
3996
  `)
3376
3997
  })
3377
3998
 
3999
+ test('group middleware runs for nested commands', async () => {
4000
+ const sub = Cli.create('admin', {
4001
+ vars: z.object({ role: z.string().default('none') }),
4002
+ })
4003
+ sub.use(async (c, next) => {
4004
+ c.set('role', 'admin')
4005
+ await next()
4006
+ })
4007
+ sub.command('status', {
4008
+ run: (c) => ({ role: c.var.role }),
4009
+ })
4010
+ const cli = Cli.create('test', {
4011
+ vars: z.object({ role: z.string().default('none') }),
4012
+ })
4013
+ cli.command(sub)
4014
+ expect(await fetchJson(cli, new Request('http://localhost/admin/status')))
4015
+ .toMatchInlineSnapshot(`
4016
+ {
4017
+ "body": {
4018
+ "data": {
4019
+ "role": "admin",
4020
+ },
4021
+ "meta": {
4022
+ "command": "admin status",
4023
+ "duration": "<stripped>",
4024
+ },
4025
+ "ok": true,
4026
+ },
4027
+ "status": 200,
4028
+ }
4029
+ `)
4030
+ })
4031
+
4032
+ test('cli-level env schema is parsed', async () => {
4033
+ const cli = Cli.create('test', {
4034
+ env: z.object({ APP_TOKEN: z.string().default('fallback') }),
4035
+ })
4036
+ cli.use(async (c, next) => {
4037
+ // env should be parsed from envSchema
4038
+ ;(globalThis as any).__testEnv = c.env
4039
+ await next()
4040
+ })
4041
+ cli.command('check', { run: () => ({ ok: true }) })
4042
+ await cli.fetch(new Request('http://localhost/check'))
4043
+ expect((globalThis as any).__testEnv).toEqual({ APP_TOKEN: 'fallback' })
4044
+ delete (globalThis as any).__testEnv
4045
+ })
4046
+
4047
+ test('retryable error is propagated', async () => {
4048
+ const cli = Cli.create('test')
4049
+ cli.command('rate-limit', {
4050
+ run: (c) => c.error({ code: 'RATE_LIMITED', message: 'slow down', retryable: true }),
4051
+ })
4052
+ const { body } = await fetchJson(cli, new Request('http://localhost/rate-limit'))
4053
+ expect(body.ok).toBe(false)
4054
+ expect(body.error.retryable).toBe(true)
4055
+ })
4056
+
4057
+ test('cta block is propagated', async () => {
4058
+ const cli = Cli.create('test')
4059
+ cli.command('done', {
4060
+ run: (c) => c.ok({ id: 1 }, { cta: { commands: ['list'], description: 'Next steps:' } }),
4061
+ })
4062
+ const { body } = await fetchJson(cli, new Request('http://localhost/done'))
4063
+ expect(body.ok).toBe(true)
4064
+ expect(body.meta.cta).toMatchInlineSnapshot(`
4065
+ {
4066
+ "commands": [
4067
+ {
4068
+ "command": "test list",
4069
+ },
4070
+ ],
4071
+ "description": "Next steps:",
4072
+ }
4073
+ `)
4074
+ })
4075
+
3378
4076
  describe('mcp over http', () => {
3379
4077
  function mcpCli() {
3380
4078
  const cli = Cli.create('test', { version: '1.0.0' })