incur 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/SKILL.md +664 -0
  4. package/dist/Cli.d.ts +255 -0
  5. package/dist/Cli.d.ts.map +1 -0
  6. package/dist/Cli.js +900 -0
  7. package/dist/Cli.js.map +1 -0
  8. package/dist/Errors.d.ts +92 -0
  9. package/dist/Errors.d.ts.map +1 -0
  10. package/dist/Errors.js +75 -0
  11. package/dist/Errors.js.map +1 -0
  12. package/dist/Formatter.d.ts +5 -0
  13. package/dist/Formatter.d.ts.map +1 -0
  14. package/dist/Formatter.js +91 -0
  15. package/dist/Formatter.js.map +1 -0
  16. package/dist/Help.d.ts +53 -0
  17. package/dist/Help.d.ts.map +1 -0
  18. package/dist/Help.js +231 -0
  19. package/dist/Help.js.map +1 -0
  20. package/dist/Mcp.d.ts +13 -0
  21. package/dist/Mcp.d.ts.map +1 -0
  22. package/dist/Mcp.js +140 -0
  23. package/dist/Mcp.js.map +1 -0
  24. package/dist/Parser.d.ts +24 -0
  25. package/dist/Parser.d.ts.map +1 -0
  26. package/dist/Parser.js +215 -0
  27. package/dist/Parser.js.map +1 -0
  28. package/dist/Register.d.ts +19 -0
  29. package/dist/Register.d.ts.map +1 -0
  30. package/dist/Register.js +2 -0
  31. package/dist/Register.js.map +1 -0
  32. package/dist/Schema.d.ts +4 -0
  33. package/dist/Schema.d.ts.map +1 -0
  34. package/dist/Schema.js +8 -0
  35. package/dist/Schema.js.map +1 -0
  36. package/dist/Skill.d.ts +29 -0
  37. package/dist/Skill.d.ts.map +1 -0
  38. package/dist/Skill.js +196 -0
  39. package/dist/Skill.js.map +1 -0
  40. package/dist/Skillgen.d.ts +3 -0
  41. package/dist/Skillgen.d.ts.map +1 -0
  42. package/dist/Skillgen.js +67 -0
  43. package/dist/Skillgen.js.map +1 -0
  44. package/dist/SyncMcp.d.ts +23 -0
  45. package/dist/SyncMcp.d.ts.map +1 -0
  46. package/dist/SyncMcp.js +100 -0
  47. package/dist/SyncMcp.js.map +1 -0
  48. package/dist/SyncSkills.d.ts +38 -0
  49. package/dist/SyncSkills.d.ts.map +1 -0
  50. package/dist/SyncSkills.js +163 -0
  51. package/dist/SyncSkills.js.map +1 -0
  52. package/dist/Typegen.d.ts +6 -0
  53. package/dist/Typegen.d.ts.map +1 -0
  54. package/dist/Typegen.js +92 -0
  55. package/dist/Typegen.js.map +1 -0
  56. package/dist/bin.d.ts +14 -0
  57. package/dist/bin.d.ts.map +1 -0
  58. package/dist/bin.js +30 -0
  59. package/dist/bin.js.map +1 -0
  60. package/dist/index.d.ts +15 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +14 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal/pm.d.ts +3 -0
  65. package/dist/internal/pm.d.ts.map +1 -0
  66. package/dist/internal/pm.js +11 -0
  67. package/dist/internal/pm.js.map +1 -0
  68. package/dist/internal/types.d.ts +11 -0
  69. package/dist/internal/types.d.ts.map +1 -0
  70. package/dist/internal/types.js +2 -0
  71. package/dist/internal/types.js.map +1 -0
  72. package/dist/internal/utils.d.ts +8 -0
  73. package/dist/internal/utils.d.ts.map +1 -0
  74. package/dist/internal/utils.js +51 -0
  75. package/dist/internal/utils.js.map +1 -0
  76. package/examples/npm/cli.ts +180 -0
  77. package/examples/npm/node_modules/.bin/incur.src +21 -0
  78. package/examples/npm/node_modules/.bin/tsx +21 -0
  79. package/examples/npm/package.json +14 -0
  80. package/examples/npm/tsconfig.json +9 -0
  81. package/examples/presto/cli.ts +246 -0
  82. package/examples/presto/node_modules/.bin/incur.src +21 -0
  83. package/examples/presto/node_modules/.bin/tsx +21 -0
  84. package/examples/presto/package.json +14 -0
  85. package/examples/presto/tsconfig.json +9 -0
  86. package/package.json +53 -2
  87. package/src/Cli.test-d.ts +135 -0
  88. package/src/Cli.test.ts +1373 -0
  89. package/src/Cli.ts +1470 -0
  90. package/src/Errors.test.ts +96 -0
  91. package/src/Errors.ts +139 -0
  92. package/src/Formatter.test.ts +245 -0
  93. package/src/Formatter.ts +106 -0
  94. package/src/Help.test.ts +124 -0
  95. package/src/Help.ts +302 -0
  96. package/src/Mcp.test.ts +254 -0
  97. package/src/Mcp.ts +195 -0
  98. package/src/Parser.test-d.ts +45 -0
  99. package/src/Parser.test.ts +118 -0
  100. package/src/Parser.ts +247 -0
  101. package/src/Register.ts +18 -0
  102. package/src/Schema.test.ts +125 -0
  103. package/src/Schema.ts +8 -0
  104. package/src/Skill.test.ts +293 -0
  105. package/src/Skill.ts +253 -0
  106. package/src/Skillgen.ts +66 -0
  107. package/src/SyncMcp.test.ts +75 -0
  108. package/src/SyncMcp.ts +132 -0
  109. package/src/SyncSkills.test.ts +92 -0
  110. package/src/SyncSkills.ts +205 -0
  111. package/src/Typegen.test.ts +150 -0
  112. package/src/Typegen.ts +107 -0
  113. package/src/bin.ts +33 -0
  114. package/src/e2e.test.ts +1710 -0
  115. package/src/index.ts +14 -0
  116. package/src/internal/pm.test.ts +38 -0
  117. package/src/internal/pm.ts +8 -0
  118. package/src/internal/types.ts +22 -0
  119. package/src/internal/utils.ts +50 -0
  120. package/src/tsconfig.json +8 -0
@@ -0,0 +1,1710 @@
1
+ import { Cli, Errors, Skill, Typegen, z } from 'incur'
2
+
3
+ let __mockSkillsHash: string | undefined
4
+
5
+ vi.mock('./SyncSkills.js', async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import('./SyncSkills.js')>()
7
+ return { ...actual, readHash: () => __mockSkillsHash }
8
+ })
9
+
10
+ describe('routing', () => {
11
+ test('top-level command', async () => {
12
+ const { output } = await serve(createApp(), ['ping'])
13
+ expect(output).toMatchInlineSnapshot(`
14
+ "pong: true
15
+ "
16
+ `)
17
+ })
18
+
19
+ test('group command', async () => {
20
+ const { output } = await serve(createApp(), ['auth', 'logout'])
21
+ expect(output).toMatchInlineSnapshot(`
22
+ "loggedOut: true
23
+ "
24
+ `)
25
+ })
26
+
27
+ test('nested group command (3 levels deep)', async () => {
28
+ const { output } = await serve(createApp(), ['project', 'deploy', 'status', 'd-456'])
29
+ expect(output).toMatchInlineSnapshot(`
30
+ "deployId: d-456
31
+ status: running
32
+ progress: 75
33
+ "
34
+ `)
35
+ })
36
+
37
+ test('mounted leaf CLI as single command', async () => {
38
+ const { output } = await serve(createApp(), ['config'])
39
+ expect(output).toMatchInlineSnapshot(`
40
+ "apiUrl: "https://api.example.com"
41
+ timeout: 30
42
+ debug: false
43
+ "
44
+ `)
45
+ })
46
+
47
+ test('mounted leaf CLI with args', async () => {
48
+ const { output } = await serve(createApp(), ['config', 'apiUrl'])
49
+ expect(output).toMatchInlineSnapshot(`
50
+ "key: apiUrl
51
+ value: some-value
52
+ "
53
+ `)
54
+ })
55
+
56
+ test('unknown top-level command', async () => {
57
+ const { output, exitCode } = await serve(createApp(), ['nonexistent'])
58
+ expect(exitCode).toBe(1)
59
+ expect(output).toMatchInlineSnapshot(`
60
+ "Error: 'nonexistent' is not a command. See 'app --help' for a list of available commands.
61
+ "
62
+ `)
63
+ })
64
+
65
+ test('unknown subcommand lists available', async () => {
66
+ const { output, exitCode } = await serve(createApp(), ['auth', 'whoami'])
67
+ expect(exitCode).toBe(1)
68
+ expect(output).toMatchInlineSnapshot(`
69
+ "Error: 'whoami' is not a command. See 'app auth --help' for a list of available commands.
70
+ "
71
+ `)
72
+ })
73
+
74
+ test('unknown nested subcommand', async () => {
75
+ const { output, exitCode } = await serve(createApp(), ['project', 'deploy', 'nope'])
76
+ expect(exitCode).toBe(1)
77
+ expect(output).toMatchInlineSnapshot(`
78
+ "Error: 'nope' is not a command. See 'app project deploy --help' for a list of available commands.
79
+ "
80
+ `)
81
+ })
82
+ })
83
+
84
+ describe('args and options', () => {
85
+ test('positional args in order', async () => {
86
+ const { output } = await serve(createApp(), ['echo', 'hello'])
87
+ expect(output).toMatchInlineSnapshot(`
88
+ "result[1]: hello
89
+ "
90
+ `)
91
+ })
92
+
93
+ test('--flag value form', async () => {
94
+ const { output } = await serve(createApp(), ['echo', 'hello', '--upper', '--prefix', '>>'])
95
+ expect(output).toMatchInlineSnapshot(`
96
+ "result[1]: >> HELLO
97
+ "
98
+ `)
99
+ })
100
+
101
+ test('multiple options combined', async () => {
102
+ const { output } = await serve(createApp(), ['echo', 'hi', '--upper', '--prefix', '!'])
103
+ expect(output).toMatchInlineSnapshot(`
104
+ "result[1]: ! HI
105
+ "
106
+ `)
107
+ })
108
+
109
+ test('--no-flag negation for booleans', async () => {
110
+ const { output } = await serve(createApp(), [
111
+ 'project',
112
+ 'list',
113
+ '--no-archived',
114
+ '--format',
115
+ 'json',
116
+ ])
117
+ const parsed = json(output)
118
+ expect(parsed.items.every((i: any) => !i.archived)).toBe(true)
119
+ })
120
+
121
+ test('array option collects multiple values', async () => {
122
+ const { output } = await serve(createApp(), [
123
+ 'auth',
124
+ 'login',
125
+ '--scopes',
126
+ 'read',
127
+ '--scopes',
128
+ 'write',
129
+ '--verbose',
130
+ '--format',
131
+ 'json',
132
+ ])
133
+ const parsed = json(output)
134
+ expect(parsed.data.scopes).toMatchInlineSnapshot(`
135
+ [
136
+ "read",
137
+ "write",
138
+ ]
139
+ `)
140
+ })
141
+
142
+ test('number coercion from argv strings', async () => {
143
+ const { output } = await serve(createApp(), [
144
+ 'project',
145
+ 'list',
146
+ '--limit',
147
+ '5',
148
+ '--verbose',
149
+ '--format',
150
+ 'json',
151
+ ])
152
+ const parsed = json(output)
153
+ expect(parsed.data.total).toBe(1)
154
+ })
155
+
156
+ test('enum validation fails for invalid value', async () => {
157
+ const { output, exitCode } = await serve(createApp(), ['project', 'list', '--sort', 'invalid'])
158
+ expect(exitCode).toBe(1)
159
+ expect(output).toContain('Error')
160
+ expect(output).toContain('sort')
161
+ })
162
+
163
+ test('missing required arg fails validation', async () => {
164
+ const { output, exitCode } = await serve(createApp(), ['project', 'get'])
165
+ expect(exitCode).toBe(1)
166
+ expect(output).toContain('Error: missing required argument <id>')
167
+ })
168
+
169
+ test('unknown flag returns error', async () => {
170
+ const { output, exitCode } = await serve(createApp(), ['ping', '--unknown-flag'])
171
+ expect(exitCode).toBe(1)
172
+ expect(output).toMatchInlineSnapshot(`
173
+ "Error: Unknown flag: --unknown-flag
174
+ "
175
+ `)
176
+ })
177
+ })
178
+
179
+ describe('output formats', () => {
180
+ test('default TOON format', async () => {
181
+ const { output } = await serve(createApp(), ['ping'])
182
+ expect(output).toMatchInlineSnapshot(`
183
+ "pong: true
184
+ "
185
+ `)
186
+ expect(() => json(output)).toThrow()
187
+ })
188
+
189
+ test('--format json', async () => {
190
+ const { output } = await serve(createApp(), ['ping', '--format', 'json'])
191
+ expect(output).toMatchInlineSnapshot(`
192
+ "{
193
+ "pong": true
194
+ }
195
+ "
196
+ `)
197
+ })
198
+
199
+ test('--json shorthand', async () => {
200
+ const { output } = await serve(createApp(), ['ping', '--json'])
201
+ expect(output).toMatchInlineSnapshot(`
202
+ "{
203
+ "pong": true
204
+ }
205
+ "
206
+ `)
207
+ })
208
+
209
+ test('--format yaml', async () => {
210
+ const { output } = await serve(createApp(), ['ping', '--format', 'yaml'])
211
+ expect(output).toMatchInlineSnapshot(`
212
+ "pong: true
213
+ "
214
+ `)
215
+ })
216
+
217
+ test('CLI-level default format', async () => {
218
+ const cli = Cli.create('test', { format: 'json' })
219
+ cli.command('ping', { run: () => ({ pong: true }) })
220
+ const { output } = await serve(cli, ['ping'])
221
+ expect(output).toMatchInlineSnapshot(`
222
+ "{
223
+ "pong": true
224
+ }
225
+ "
226
+ `)
227
+ })
228
+
229
+ test('command-level default format', async () => {
230
+ const cli = Cli.create('test')
231
+ cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
232
+ const { output } = await serve(cli, ['ping'])
233
+ expect(output).toMatchInlineSnapshot(`
234
+ "{
235
+ "pong": true
236
+ }
237
+ "
238
+ `)
239
+ })
240
+
241
+ test('command-level format overrides CLI-level', async () => {
242
+ const cli = Cli.create('test', { format: 'yaml' })
243
+ cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
244
+ const { output } = await serve(cli, ['ping'])
245
+ expect(output).toMatchInlineSnapshot(`
246
+ "{
247
+ "pong": true
248
+ }
249
+ "
250
+ `)
251
+ })
252
+
253
+ test('--format flag overrides command-level default', async () => {
254
+ const cli = Cli.create('test')
255
+ cli.command('ping', { format: 'json', run: () => ({ pong: true }) })
256
+ const { output } = await serve(cli, ['ping', '--format', 'yaml'])
257
+ expect(output).toMatchInlineSnapshot(`
258
+ "pong: true
259
+ "
260
+ `)
261
+ })
262
+
263
+ test('--verbose full envelope', async () => {
264
+ const { output } = await serve(createApp(), ['ping', '--verbose'])
265
+ expect(output).toMatchInlineSnapshot(`
266
+ "ok: true
267
+ data:
268
+ pong: true
269
+ meta:
270
+ command: ping
271
+ duration: <stripped>
272
+ "
273
+ `)
274
+ })
275
+
276
+ test('--verbose --format json full envelope', async () => {
277
+ const { output } = await serve(createApp(), ['ping', '--verbose', '--format', 'json'])
278
+ expect(json(output)).toMatchInlineSnapshot(`
279
+ {
280
+ "data": {
281
+ "pong": true,
282
+ },
283
+ "meta": {
284
+ "command": "ping",
285
+ "duration": "0ms",
286
+ },
287
+ "ok": true,
288
+ }
289
+ `)
290
+ })
291
+
292
+ test('nested command path in verbose meta', async () => {
293
+ const { output } = await serve(createApp(), [
294
+ 'project',
295
+ 'deploy',
296
+ 'status',
297
+ 'd-1',
298
+ '--verbose',
299
+ '--format',
300
+ 'json',
301
+ ])
302
+ expect(json(output)).toMatchInlineSnapshot(`
303
+ {
304
+ "data": {
305
+ "deployId": "d-1",
306
+ "progress": 75,
307
+ "status": "running",
308
+ },
309
+ "meta": {
310
+ "command": "project deploy status",
311
+ "duration": "0ms",
312
+ },
313
+ "ok": true,
314
+ }
315
+ `)
316
+ })
317
+ })
318
+
319
+ describe('error handling', () => {
320
+ test('thrown Error shows human-readable message', async () => {
321
+ const { output, exitCode } = await serve(createApp(), ['explode'])
322
+ expect(exitCode).toBe(1)
323
+ expect(output).toMatchInlineSnapshot(`
324
+ "Error: kaboom
325
+ "
326
+ `)
327
+ })
328
+
329
+ test('IncurError preserves code and retryable', async () => {
330
+ const { output, exitCode } = await serve(createApp(), ['explode-clac', '--format', 'json'])
331
+ expect(exitCode).toBe(1)
332
+ expect(json(output)).toMatchInlineSnapshot(`
333
+ {
334
+ "code": "QUOTA_EXCEEDED",
335
+ "message": "Rate limit exceeded",
336
+ "retryable": true,
337
+ }
338
+ `)
339
+ })
340
+
341
+ test('error() sentinel returns error envelope', async () => {
342
+ const { output, exitCode } = await serve(createApp(), [
343
+ 'auth',
344
+ 'status',
345
+ '--verbose',
346
+ '--format',
347
+ 'json',
348
+ ])
349
+ expect(exitCode).toBe(1)
350
+ expect(json(output)).toMatchInlineSnapshot(`
351
+ {
352
+ "error": {
353
+ "code": "NOT_AUTHENTICATED",
354
+ "message": "Not logged in",
355
+ "retryable": false,
356
+ },
357
+ "meta": {
358
+ "command": "auth status",
359
+ "cta": {
360
+ "commands": [
361
+ {
362
+ "command": "app auth login",
363
+ },
364
+ ],
365
+ "description": "Suggested commands:",
366
+ },
367
+ "duration": "0ms",
368
+ },
369
+ "ok": false,
370
+ }
371
+ `)
372
+ })
373
+
374
+ test('IncurError in nested command', async () => {
375
+ const { output, exitCode } = await serve(createApp(), [
376
+ 'project',
377
+ 'delete',
378
+ 'p1',
379
+ '--format',
380
+ 'json',
381
+ ])
382
+ expect(exitCode).toBe(1)
383
+ expect(json(output)).toMatchInlineSnapshot(`
384
+ {
385
+ "code": "CONFIRMATION_REQUIRED",
386
+ "message": "Use --force to delete project p1",
387
+ "retryable": true,
388
+ }
389
+ `)
390
+ })
391
+
392
+ test('validation error includes field errors', async () => {
393
+ const { output, exitCode } = await serve(createApp(), ['validate-fail', '--format', 'json'])
394
+ expect(exitCode).toBe(1)
395
+ const parsed = json(output)
396
+ expect(parsed.fieldErrors.length).toBeGreaterThan(0)
397
+ expect(parsed.fieldErrors[0].path).toBe('email')
398
+ })
399
+
400
+ test('command not found returns error envelope', async () => {
401
+ const { output, exitCode } = await serve(createApp(), [
402
+ 'nonexistent',
403
+ '--verbose',
404
+ '--format',
405
+ 'json',
406
+ ])
407
+ expect(exitCode).toBe(1)
408
+ expect(json(output)).toMatchInlineSnapshot(`
409
+ {
410
+ "error": {
411
+ "code": "COMMAND_NOT_FOUND",
412
+ "message": "'nonexistent' is not a command. See 'app --help' for a list of available commands.",
413
+ },
414
+ "meta": {
415
+ "command": "nonexistent",
416
+ "duration": "0ms",
417
+ },
418
+ "ok": false,
419
+ }
420
+ `)
421
+ })
422
+
423
+ test('error envelope respects --format json', async () => {
424
+ const { output, exitCode } = await serve(createApp(), ['explode', '--format', 'json'])
425
+ expect(exitCode).toBe(1)
426
+ expect(json(output)).toMatchInlineSnapshot(`
427
+ {
428
+ "code": "UNKNOWN",
429
+ "message": "kaboom",
430
+ }
431
+ `)
432
+ })
433
+ })
434
+
435
+ describe('cta', () => {
436
+ test('ok() with string CTAs', async () => {
437
+ const { output } = await serve(createApp(), ['auth', 'login', '--verbose', '--format', 'json'])
438
+ expect(json(output).meta.cta).toMatchInlineSnapshot(`
439
+ {
440
+ "commands": [
441
+ {
442
+ "command": "app auth status",
443
+ },
444
+ ],
445
+ "description": "Verify your session:",
446
+ }
447
+ `)
448
+ })
449
+
450
+ test('ok() with object CTAs including descriptions', async () => {
451
+ const { output } = await serve(createApp(), [
452
+ 'project',
453
+ 'create',
454
+ 'MyProject',
455
+ '--verbose',
456
+ '--format',
457
+ 'json',
458
+ ])
459
+ expect(json(output).meta.cta).toMatchInlineSnapshot(`
460
+ {
461
+ "commands": [
462
+ {
463
+ "command": "app project get p-new",
464
+ "description": "View "MyProject"",
465
+ },
466
+ {
467
+ "command": "app project list",
468
+ },
469
+ ],
470
+ "description": "Suggested commands:",
471
+ }
472
+ `)
473
+ })
474
+
475
+ test('error() with CTA', async () => {
476
+ const { output } = await serve(createApp(), ['auth', 'status', '--verbose', '--format', 'json'])
477
+ expect(json(output).meta.cta).toMatchInlineSnapshot(`
478
+ {
479
+ "commands": [
480
+ {
481
+ "command": "app auth login",
482
+ },
483
+ ],
484
+ "description": "Suggested commands:",
485
+ }
486
+ `)
487
+ })
488
+
489
+ test('plain return omits CTA', async () => {
490
+ const { output } = await serve(createApp(), ['ping', '--verbose', '--format', 'json'])
491
+ expect(json(output).meta.cta).toBeUndefined()
492
+ })
493
+
494
+ test('dynamic CTA list from data', async () => {
495
+ const { output } = await serve(createApp(), [
496
+ 'project',
497
+ 'list',
498
+ '--archived',
499
+ '--verbose',
500
+ '--format',
501
+ 'json',
502
+ ])
503
+ expect(json(output).meta.cta).toMatchInlineSnapshot(`
504
+ {
505
+ "commands": [
506
+ {
507
+ "command": "app project get p1",
508
+ "description": "View "Alpha"",
509
+ },
510
+ {
511
+ "command": "app project get p2",
512
+ "description": "View "Beta"",
513
+ },
514
+ ],
515
+ "description": "Suggested commands:",
516
+ }
517
+ `)
518
+ })
519
+ })
520
+
521
+ describe('async', () => {
522
+ test('async handler resolves', async () => {
523
+ const { output } = await serve(createApp(), ['slow'])
524
+ expect(output).toMatchInlineSnapshot(`
525
+ "done: true
526
+ "
527
+ `)
528
+ })
529
+ })
530
+
531
+ describe('streaming', () => {
532
+ test('default streams toon per chunk (human)', async () => {
533
+ const { output } = await serve(createApp(), ['stream'])
534
+ expect(output).toMatchInlineSnapshot(`
535
+ "content: hello
536
+ content: world
537
+ "
538
+ `)
539
+ })
540
+
541
+ test('default streams toon per chunk (--verbose)', async () => {
542
+ const { output } = await serve(createApp(), ['stream', '--verbose'])
543
+ expect(output).toMatchInlineSnapshot(`
544
+ "content: hello
545
+ content: world
546
+ "
547
+ `)
548
+ })
549
+
550
+ test('--format json buffers all chunks', async () => {
551
+ const { output } = await serve(createApp(), ['stream', '--format', 'json'])
552
+ expect(json(output)).toMatchInlineSnapshot(`
553
+ [
554
+ {
555
+ "content": "hello",
556
+ },
557
+ {
558
+ "content": "world",
559
+ },
560
+ ]
561
+ `)
562
+ })
563
+
564
+ test('--format json --verbose buffers with envelope', async () => {
565
+ const { output } = await serve(createApp(), ['stream', '--verbose', '--format', 'json'])
566
+ expect(json(output)).toMatchInlineSnapshot(`
567
+ {
568
+ "data": [
569
+ {
570
+ "content": "hello",
571
+ },
572
+ {
573
+ "content": "world",
574
+ },
575
+ ],
576
+ "meta": {
577
+ "command": "stream",
578
+ "duration": "0ms",
579
+ },
580
+ "ok": true,
581
+ }
582
+ `)
583
+ })
584
+
585
+ test('--format jsonl explicit', async () => {
586
+ const { output } = await serve(createApp(), ['stream', '--format', 'jsonl'])
587
+ const lines = output
588
+ .trim()
589
+ .split('\n')
590
+ .map((l) => JSON.parse(l))
591
+ expect(lines[0]).toEqual({ type: 'chunk', data: { content: 'hello' } })
592
+ expect(lines[1]).toEqual({ type: 'chunk', data: { content: 'world' } })
593
+ expect(lines[2].type).toBe('done')
594
+ })
595
+
596
+ test('ok() CTA in jsonl done record', async () => {
597
+ const { output } = await serve(createApp(), ['stream-ok', '--format', 'jsonl'])
598
+ const lines = output
599
+ .trim()
600
+ .split('\n')
601
+ .map((l) => JSON.parse(l))
602
+ const done = lines.find((l: any) => l.type === 'done')
603
+ expect(done.meta.cta).toMatchInlineSnapshot(`
604
+ {
605
+ "commands": [
606
+ {
607
+ "command": "app ping",
608
+ },
609
+ ],
610
+ "description": "Suggested commands:",
611
+ }
612
+ `)
613
+ })
614
+
615
+ test('ok() CTA shows after toon stream', async () => {
616
+ const { output } = await serve(createApp(), ['stream-ok'])
617
+ expect(output).toContain('n: 1')
618
+ expect(output).toContain('n: 2')
619
+ expect(output).toContain('Suggested commands:')
620
+ expect(output).toContain('app ping')
621
+ })
622
+
623
+ test('error() mid-stream in jsonl', async () => {
624
+ const { output, exitCode } = await serve(createApp(), ['stream-error', '--format', 'jsonl'])
625
+ const lines = output
626
+ .trim()
627
+ .split('\n')
628
+ .map((l) => JSON.parse(l))
629
+ expect(lines[0]).toEqual({ type: 'chunk', data: { n: 1 } })
630
+ expect(lines[1]).toMatchInlineSnapshot(`
631
+ {
632
+ "error": {
633
+ "code": "STREAM_FAIL",
634
+ "message": "broke mid-stream",
635
+ },
636
+ "ok": false,
637
+ "type": "error",
638
+ }
639
+ `)
640
+ expect(exitCode).toBe(1)
641
+ })
642
+
643
+ test('error() mid-stream in toon', async () => {
644
+ const { output, exitCode } = await serve(createApp(), ['stream-error'])
645
+ expect(output).toContain('n: 1')
646
+ expect(output).toContain('Error (STREAM_FAIL): broke mid-stream')
647
+ expect(exitCode).toBe(1)
648
+ })
649
+
650
+ test('thrown error mid-stream in jsonl', async () => {
651
+ const { output, exitCode } = await serve(createApp(), ['stream-throw', '--format', 'jsonl'])
652
+ const lines = output
653
+ .trim()
654
+ .split('\n')
655
+ .map((l) => JSON.parse(l))
656
+ expect(lines[0]).toEqual({ type: 'chunk', data: { n: 1 } })
657
+ expect(lines[1]).toMatchInlineSnapshot(`
658
+ {
659
+ "error": {
660
+ "code": "UNKNOWN",
661
+ "message": "stream kaboom",
662
+ },
663
+ "ok": false,
664
+ "type": "error",
665
+ }
666
+ `)
667
+ expect(exitCode).toBe(1)
668
+ })
669
+
670
+ test('thrown error mid-stream in toon', async () => {
671
+ const { output, exitCode } = await serve(createApp(), ['stream-throw'])
672
+ expect(output).toContain('n: 1')
673
+ expect(output).toContain('Error: stream kaboom')
674
+ expect(exitCode).toBe(1)
675
+ })
676
+ })
677
+
678
+ describe('help', () => {
679
+ test('root help (no args)', async () => {
680
+ const { output, exitCode } = await serve(createApp(), [])
681
+ expect(exitCode).toBeUndefined()
682
+ expect(output).toMatchInlineSnapshot(`
683
+ "app — A comprehensive CLI application for testing.
684
+ v3.5.0
685
+
686
+ Usage: app <command>
687
+
688
+ Commands:
689
+ auth Authentication commands
690
+ config Show current configuration
691
+ echo Echo back arguments
692
+ explode Always fails
693
+ explode-clac Fails with IncurError
694
+ ping Health check
695
+ project Manage projects
696
+ slow Async command
697
+ stream Stream chunks
698
+ stream-error Stream with mid-stream error
699
+ stream-ok Stream with ok() return
700
+ stream-throw Stream that throws
701
+ validate-fail Fails validation
702
+
703
+ Built-in Commands:
704
+ mcp add Register as an MCP server
705
+ skills add Sync skill files to your agent
706
+
707
+ Global Options:
708
+ --format <toon|json|yaml|md|jsonl> Output format
709
+ --help Show help
710
+ --llms Print LLM-readable manifest
711
+ --mcp Start as MCP stdio server
712
+ --verbose Show full output envelope
713
+ --version Show version
714
+ "
715
+ `)
716
+ })
717
+
718
+ test('--help on root', async () => {
719
+ const { output } = await serve(createApp(), ['--help'])
720
+ expect(output).toContain('Usage: app <command>')
721
+ })
722
+
723
+ test('group help (no subcommand)', async () => {
724
+ const { output, exitCode } = await serve(createApp(), ['auth'])
725
+ expect(exitCode).toBeUndefined()
726
+ expect(output).toMatchInlineSnapshot(`
727
+ "app auth — Authentication commands
728
+
729
+ Usage: app auth <command>
730
+
731
+ Commands:
732
+ login Log in to the service
733
+ logout Log out of the service
734
+ status Show authentication status
735
+
736
+ Global Options:
737
+ --format <toon|json|yaml|md|jsonl> Output format
738
+ --help Show help
739
+ --llms Print LLM-readable manifest
740
+ --verbose Show full output envelope
741
+ "
742
+ `)
743
+ })
744
+
745
+ test('nested group help', async () => {
746
+ const { output, exitCode } = await serve(createApp(), ['project', 'deploy'])
747
+ expect(exitCode).toBeUndefined()
748
+ expect(output).toMatchInlineSnapshot(`
749
+ "app project deploy — Deployment commands
750
+
751
+ Usage: app project deploy <command>
752
+
753
+ Commands:
754
+ create Create a deployment
755
+ rollback Rollback a deployment
756
+ status Check deployment status
757
+
758
+ Global Options:
759
+ --format <toon|json|yaml|md|jsonl> Output format
760
+ --help Show help
761
+ --llms Print LLM-readable manifest
762
+ --verbose Show full output envelope
763
+ "
764
+ `)
765
+ })
766
+
767
+ test('--help on leaf command', async () => {
768
+ const { output } = await serve(createApp(), ['project', 'list', '--help'])
769
+ expect(output).toMatchInlineSnapshot(`
770
+ "app project list — List projects
771
+
772
+ Usage: app project list [options]
773
+
774
+ Options:
775
+ --limit, -l <number> Max results (default: 20)
776
+ --sort, -s <value> Sort field (default: name)
777
+ --archived <boolean> Include archived (default: false)
778
+
779
+ Global Options:
780
+ --format <toon|json|yaml|md|jsonl> Output format
781
+ --help Show help
782
+ --llms Print LLM-readable manifest
783
+ --verbose Show full output envelope
784
+ "
785
+ `)
786
+ })
787
+
788
+ test('--help includes examples', async () => {
789
+ const { output } = await serve(createApp(), ['project', 'deploy', 'create', '--help'])
790
+ expect(output).toMatchInlineSnapshot(`
791
+ "app project deploy create — Create a deployment
792
+
793
+ Usage: app project deploy create <env> [options]
794
+
795
+ Arguments:
796
+ env Target environment
797
+
798
+ Options:
799
+ --branch, -b <string> Branch to deploy (default: main)
800
+ --dry-run <boolean> Dry run mode (default: false)
801
+
802
+ Examples:
803
+ $ app project deploy create staging Deploy staging from main
804
+ $ app project deploy create production --branch release --dryRun true Dry run a production deploy
805
+
806
+ Global Options:
807
+ --format <toon|json|yaml|md|jsonl> Output format
808
+ --help Show help
809
+ --llms Print LLM-readable manifest
810
+ --verbose Show full output envelope
811
+ "
812
+ `)
813
+ })
814
+
815
+ test('--help on group shows group help', async () => {
816
+ const { output } = await serve(createApp(), ['project', '--help'])
817
+ expect(output).toContain('app project')
818
+ expect(output).toContain('deploy')
819
+ expect(output).toContain('list')
820
+ })
821
+
822
+ test('--version', async () => {
823
+ const { output } = await serve(createApp(), ['--version'])
824
+ expect(output).toMatchInlineSnapshot(`
825
+ "3.5.0
826
+ "
827
+ `)
828
+ })
829
+
830
+ test('--help takes precedence over --version', async () => {
831
+ const { output } = await serve(createApp(), ['--help', '--version'])
832
+ expect(output).toContain('Usage: app <command>')
833
+ expect(output).toContain('v3.5.0')
834
+ })
835
+ })
836
+
837
+ describe('--llms', () => {
838
+ test('json manifest lists all leaf commands sorted', async () => {
839
+ const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
840
+ const manifest = json(output)
841
+ expect(manifest.version).toBe('incur.v1')
842
+ const names = manifest.commands.map((c: any) => c.name)
843
+ expect(names).toMatchInlineSnapshot(`
844
+ [
845
+ "auth login",
846
+ "auth logout",
847
+ "auth status",
848
+ "config",
849
+ "echo",
850
+ "explode",
851
+ "explode-clac",
852
+ "ping",
853
+ "project create",
854
+ "project delete",
855
+ "project deploy create",
856
+ "project deploy rollback",
857
+ "project deploy status",
858
+ "project get",
859
+ "project list",
860
+ "slow",
861
+ "stream",
862
+ "stream-error",
863
+ "stream-ok",
864
+ "stream-throw",
865
+ "validate-fail",
866
+ ]
867
+ `)
868
+ })
869
+
870
+ test('manifest includes schema.args and schema.options separately', async () => {
871
+ const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
872
+ const projectList = json(output).commands.find((c: any) => c.name === 'project list')
873
+ expect(projectList.schema.options.properties).toMatchInlineSnapshot(`
874
+ {
875
+ "archived": {
876
+ "default": false,
877
+ "description": "Include archived",
878
+ "type": "boolean",
879
+ },
880
+ "limit": {
881
+ "default": 20,
882
+ "description": "Max results",
883
+ "type": "number",
884
+ },
885
+ "sort": {
886
+ "default": "name",
887
+ "description": "Sort field",
888
+ "enum": [
889
+ "name",
890
+ "created",
891
+ "updated",
892
+ ],
893
+ "type": "string",
894
+ },
895
+ }
896
+ `)
897
+ expect(projectList.schema.args).toBeUndefined()
898
+ })
899
+
900
+ test('manifest includes schema.output', async () => {
901
+ const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
902
+ const projectGet = json(output).commands.find((c: any) => c.name === 'project get')
903
+ expect(projectGet.schema.output).toMatchInlineSnapshot(`
904
+ {
905
+ "additionalProperties": false,
906
+ "properties": {
907
+ "description": {
908
+ "type": "string",
909
+ },
910
+ "id": {
911
+ "type": "string",
912
+ },
913
+ "members": {
914
+ "items": {
915
+ "additionalProperties": false,
916
+ "properties": {
917
+ "role": {
918
+ "type": "string",
919
+ },
920
+ "userId": {
921
+ "type": "string",
922
+ },
923
+ },
924
+ "required": [
925
+ "userId",
926
+ "role",
927
+ ],
928
+ "type": "object",
929
+ },
930
+ "type": "array",
931
+ },
932
+ "name": {
933
+ "type": "string",
934
+ },
935
+ },
936
+ "required": [
937
+ "id",
938
+ "name",
939
+ "description",
940
+ "members",
941
+ ],
942
+ "type": "object",
943
+ }
944
+ `)
945
+ })
946
+
947
+ test('manifest omits schema when no schemas defined', async () => {
948
+ const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
949
+ const ping = json(output).commands.find((c: any) => c.name === 'ping')
950
+ expect(ping.schema).toBeUndefined()
951
+ })
952
+
953
+ test('scoped --llms to group', async () => {
954
+ const { output } = await serve(createApp(), ['auth', '--llms', '--format', 'json'])
955
+ const names = json(output).commands.map((c: any) => c.name)
956
+ expect(names).toMatchInlineSnapshot(`
957
+ [
958
+ "auth login",
959
+ "auth logout",
960
+ "auth status",
961
+ ]
962
+ `)
963
+ })
964
+
965
+ test('scoped --llms to nested group', async () => {
966
+ const { output } = await serve(createApp(), ['project', 'deploy', '--llms', '--format', 'json'])
967
+ const names = json(output).commands.map((c: any) => c.name)
968
+ expect(names).toMatchInlineSnapshot(`
969
+ [
970
+ "project deploy create",
971
+ "project deploy rollback",
972
+ "project deploy status",
973
+ ]
974
+ `)
975
+ })
976
+
977
+ test('default --llms outputs markdown', async () => {
978
+ const { output } = await serve(createApp(), ['--llms'])
979
+ expect(output).toContain('# app')
980
+ expect(output).toContain('auth login')
981
+ expect(output).toContain('project list')
982
+ })
983
+
984
+ test('--llms markdown includes argument tables', async () => {
985
+ const { output } = await serve(createApp(), ['project', '--llms'])
986
+ expect(output).toContain('Arguments')
987
+ expect(output).toContain('`id`')
988
+ })
989
+
990
+ test('--llms markdown includes options tables', async () => {
991
+ const { output } = await serve(createApp(), ['project', '--llms'])
992
+ expect(output).toContain('Options')
993
+ expect(output).toContain('`--limit`')
994
+ })
995
+
996
+ test('--llms json includes examples on commands', async () => {
997
+ const { output } = await serve(createApp(), ['project', 'deploy', '--llms', '--format', 'json'])
998
+ const deployCreate = json(output).commands.find((c: any) => c.name === 'project deploy create')
999
+ expect(deployCreate.examples).toMatchInlineSnapshot(`
1000
+ [
1001
+ {
1002
+ "command": "project deploy create staging",
1003
+ "description": "Deploy staging from main",
1004
+ },
1005
+ {
1006
+ "command": "project deploy create production --branch release --dryRun true",
1007
+ "description": "Dry run a production deploy",
1008
+ },
1009
+ ]
1010
+ `)
1011
+ })
1012
+
1013
+ test('--llms json omits examples when not defined', async () => {
1014
+ const { output } = await serve(createApp(), ['--llms', '--format', 'json'])
1015
+ const ping = json(output).commands.find((c: any) => c.name === 'ping')
1016
+ expect(ping.examples).toBeUndefined()
1017
+ })
1018
+
1019
+ test('--llms markdown includes examples section', async () => {
1020
+ const { output } = await serve(createApp(), ['--llms'])
1021
+ expect(output).toContain('Examples')
1022
+ expect(output).toContain('Deploy staging from main')
1023
+ expect(output).toContain('app project deploy create staging')
1024
+ })
1025
+
1026
+ test('--llms markdown includes output tables', async () => {
1027
+ const { output } = await serve(createApp(), ['project', '--llms'])
1028
+ expect(output).toContain('Output')
1029
+ })
1030
+
1031
+ test('--llms --format yaml', async () => {
1032
+ const { output } = await serve(createApp(), ['--llms', '--format', 'yaml'])
1033
+ expect(output).toContain('version: incur.v1')
1034
+ })
1035
+ })
1036
+
1037
+ describe('typegen', () => {
1038
+ test('generates correct .d.ts for entire CLI', () => {
1039
+ expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(`
1040
+ "declare module 'incur' {
1041
+ interface Register {
1042
+ commands: {
1043
+ 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } }
1044
+ 'auth logout': { args: {}; options: {} }
1045
+ 'auth status': { args: {}; options: {} }
1046
+ 'config': { args: { key: string }; options: {} }
1047
+ 'echo': { args: { message: string; repeat: number }; options: { upper: boolean; prefix: string } }
1048
+ 'explode': { args: {}; options: {} }
1049
+ 'explode-clac': { args: {}; options: {} }
1050
+ 'ping': { args: {}; options: {} }
1051
+ 'project create': { args: { name: string }; options: { description: string; private: boolean } }
1052
+ 'project delete': { args: { id: string }; options: { force: boolean } }
1053
+ 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } }
1054
+ 'project deploy rollback': { args: { deployId: string }; options: {} }
1055
+ 'project deploy status': { args: { deployId: string }; options: {} }
1056
+ 'project get': { args: { id: string }; options: {} }
1057
+ 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } }
1058
+ 'slow': { args: {}; options: {} }
1059
+ 'stream': { args: {}; options: {} }
1060
+ 'stream-error': { args: {}; options: {} }
1061
+ 'stream-ok': { args: {}; options: {} }
1062
+ 'stream-throw': { args: {}; options: {} }
1063
+ 'validate-fail': { args: { email: string; age: number }; options: {} }
1064
+ }
1065
+ }
1066
+ }
1067
+ "
1068
+ `)
1069
+ })
1070
+ })
1071
+
1072
+ describe('composition', () => {
1073
+ test('multiple groups on same parent', async () => {
1074
+ const cli = createApp()
1075
+ const { output: o1 } = await serve(cli, ['auth', 'logout'])
1076
+ expect(o1).toMatchInlineSnapshot(`
1077
+ "loggedOut: true
1078
+ "
1079
+ `)
1080
+ const { output: o2 } = await serve(cli, ['project', 'list', '--format', 'json'])
1081
+ expect(json(o2).items).toBeDefined()
1082
+ const { output: o3 } = await serve(cli, ['ping'])
1083
+ expect(o3).toMatchInlineSnapshot(`
1084
+ "pong: true
1085
+ "
1086
+ `)
1087
+ })
1088
+
1089
+ test('deeply nested deploy commands work alongside siblings', async () => {
1090
+ const cli = createApp()
1091
+ const { output: o1 } = await serve(cli, ['project', 'deploy', 'create', 'staging'])
1092
+ expect(o1).toMatchInlineSnapshot(`
1093
+ "deployId: d-123
1094
+ url: "https://staging.example.com"
1095
+ status: pending
1096
+ "
1097
+ `)
1098
+ const { output: o2 } = await serve(cli, ['project', 'list', '--format', 'json'])
1099
+ expect(json(o2).items).toBeDefined()
1100
+ })
1101
+
1102
+ test('leaf CLI mounted alongside groups', async () => {
1103
+ const cli = createApp()
1104
+ const { output: o1 } = await serve(cli, ['config'])
1105
+ expect(o1).toMatchInlineSnapshot(`
1106
+ "apiUrl: "https://api.example.com"
1107
+ timeout: 30
1108
+ debug: false
1109
+ "
1110
+ `)
1111
+ const { output: o2 } = await serve(cli, ['auth', 'logout'])
1112
+ expect(o2).toMatchInlineSnapshot(`
1113
+ "loggedOut: true
1114
+ "
1115
+ `)
1116
+ })
1117
+
1118
+ test('create with single options object', async () => {
1119
+ const cli = Cli.create({
1120
+ name: 'one-shot',
1121
+ description: 'Single object form',
1122
+ run: () => ({ result: 42 }),
1123
+ })
1124
+ expect(cli.name).toBe('one-shot')
1125
+ const { output } = await serve(cli, [])
1126
+ expect(output).toMatchInlineSnapshot(`
1127
+ "result: 42
1128
+ "
1129
+ `)
1130
+ })
1131
+ })
1132
+
1133
+ describe('edge cases', () => {
1134
+ test('command with only options (no args)', async () => {
1135
+ const { output } = await serve(createApp(), [
1136
+ 'project',
1137
+ 'list',
1138
+ '--limit',
1139
+ '1',
1140
+ '--format',
1141
+ 'json',
1142
+ ])
1143
+ expect(json(output)).toMatchInlineSnapshot(`
1144
+ {
1145
+ "cta": {
1146
+ "commands": [
1147
+ {
1148
+ "command": "app project get p1",
1149
+ "description": "View "Alpha"",
1150
+ },
1151
+ ],
1152
+ "description": "Suggested commands:",
1153
+ },
1154
+ "items": [
1155
+ {
1156
+ "archived": false,
1157
+ "id": "p1",
1158
+ "name": "Alpha",
1159
+ },
1160
+ ],
1161
+ "total": 1,
1162
+ }
1163
+ `)
1164
+ })
1165
+
1166
+ test('command with only args (no options)', async () => {
1167
+ const { output } = await serve(createApp(), ['project', 'get', 'p1', '--format', 'json'])
1168
+ expect(json(output)).toMatchInlineSnapshot(`
1169
+ {
1170
+ "description": "Main project",
1171
+ "id": "p1",
1172
+ "members": [
1173
+ {
1174
+ "role": "admin",
1175
+ "userId": "u1",
1176
+ },
1177
+ ],
1178
+ "name": "Alpha",
1179
+ }
1180
+ `)
1181
+ })
1182
+
1183
+ test('command with no schemas at all', async () => {
1184
+ const { output } = await serve(createApp(), ['ping', '--format', 'json'])
1185
+ expect(json(output)).toMatchInlineSnapshot(`
1186
+ {
1187
+ "pong": true,
1188
+ }
1189
+ `)
1190
+ })
1191
+
1192
+ test('optional arg can be omitted', async () => {
1193
+ const { output } = await serve(createApp(), ['config', '--format', 'json'])
1194
+ expect(json(output)).toMatchInlineSnapshot(`
1195
+ {
1196
+ "apiUrl": "https://api.example.com",
1197
+ "debug": false,
1198
+ "timeout": 30,
1199
+ }
1200
+ `)
1201
+ })
1202
+
1203
+ test('--force passes through to handler', async () => {
1204
+ const { output } = await serve(createApp(), [
1205
+ 'project',
1206
+ 'delete',
1207
+ 'p1',
1208
+ '--force',
1209
+ '--format',
1210
+ 'json',
1211
+ ])
1212
+ expect(json(output)).toMatchInlineSnapshot(`
1213
+ {
1214
+ "deleted": true,
1215
+ "id": "p1",
1216
+ }
1217
+ `)
1218
+ })
1219
+
1220
+ test('flag order does not matter', async () => {
1221
+ const { output } = await serve(createApp(), [
1222
+ '--format',
1223
+ 'json',
1224
+ 'project',
1225
+ 'deploy',
1226
+ 'create',
1227
+ 'prod',
1228
+ '--branch',
1229
+ 'release',
1230
+ '--verbose',
1231
+ ])
1232
+ expect(json(output)).toMatchInlineSnapshot(`
1233
+ {
1234
+ "data": {
1235
+ "deployId": "d-123",
1236
+ "status": "pending",
1237
+ "url": "https://prod.example.com",
1238
+ },
1239
+ "meta": {
1240
+ "command": "project deploy create",
1241
+ "duration": "0ms",
1242
+ },
1243
+ "ok": true,
1244
+ }
1245
+ `)
1246
+ })
1247
+
1248
+ test('empty argv on router shows help', async () => {
1249
+ const { output, exitCode } = await serve(createApp(), [])
1250
+ expect(exitCode).toBeUndefined()
1251
+ expect(output).toContain('Usage: app <command>')
1252
+ })
1253
+ })
1254
+
1255
+ describe('env', () => {
1256
+ test('env vars passed to handler', async () => {
1257
+ const { output } = await serve(
1258
+ createApp(),
1259
+ ['auth', 'login', '--verbose', '--format', 'json'],
1260
+ { env: { AUTH_HOST: 'custom.example.com' } },
1261
+ )
1262
+ expect(json(output).data.hostname).toBe('custom.example.com')
1263
+ })
1264
+
1265
+ test('env defaults applied when var is unset', async () => {
1266
+ const { output } = await serve(
1267
+ createApp(),
1268
+ ['auth', 'login', '--verbose', '--format', 'json'],
1269
+ { env: {} },
1270
+ )
1271
+ expect(json(output).data.hostname).toBe('api.example.com')
1272
+ })
1273
+
1274
+ test('--help shows env vars section', async () => {
1275
+ const { output } = await serve(createApp(), ['auth', 'login', '--help'])
1276
+ expect(output).toMatchInlineSnapshot(`
1277
+ "app auth login — Log in to the service
1278
+
1279
+ Usage: app auth login [options]
1280
+
1281
+ Options:
1282
+ --hostname, -h <string> API hostname (default: api.example.com)
1283
+ --web, -w <boolean> Open browser (default: false)
1284
+ --scopes <array> OAuth scopes
1285
+
1286
+ Environment Variables:
1287
+ AUTH_TOKEN Pre-existing auth token
1288
+ AUTH_HOST Auth server hostname (default: api.example.com)
1289
+
1290
+ Global Options:
1291
+ --format <toon|json|yaml|md|jsonl> Output format
1292
+ --help Show help
1293
+ --llms Print LLM-readable manifest
1294
+ --verbose Show full output envelope
1295
+ "
1296
+ `)
1297
+ })
1298
+
1299
+ test('--llms json includes schema.env', async () => {
1300
+ const { output } = await serve(createApp(), ['auth', '--llms', '--format', 'json'])
1301
+ const login = json(output).commands.find((c: any) => c.name === 'auth login')
1302
+ expect(login.schema.env.properties).toMatchInlineSnapshot(`
1303
+ {
1304
+ "AUTH_HOST": {
1305
+ "default": "api.example.com",
1306
+ "description": "Auth server hostname",
1307
+ "type": "string",
1308
+ },
1309
+ "AUTH_TOKEN": {
1310
+ "description": "Pre-existing auth token",
1311
+ "type": "string",
1312
+ },
1313
+ }
1314
+ `)
1315
+ })
1316
+
1317
+ test('--llms markdown includes env vars table', async () => {
1318
+ const { output } = await serve(createApp(), ['auth', '--llms'])
1319
+ expect(output).toContain('Environment Variables')
1320
+ expect(output).toContain('`AUTH_TOKEN`')
1321
+ expect(output).toContain('`AUTH_HOST`')
1322
+ })
1323
+ })
1324
+
1325
+ describe('skills staleness', () => {
1326
+ let stderrSpy: ReturnType<typeof vi.spyOn>
1327
+
1328
+ beforeEach(() => {
1329
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
1330
+ __mockSkillsHash = undefined
1331
+ })
1332
+
1333
+ afterEach(() => {
1334
+ stderrSpy.mockRestore()
1335
+ })
1336
+
1337
+ test('warns when running a command with stale skills', async () => {
1338
+ __mockSkillsHash = '0000000000000000'
1339
+ const { output } = await serve(createApp(), ['ping'])
1340
+ expect(output).toContain('pong: true')
1341
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skills are out of date.'))
1342
+ })
1343
+
1344
+ test('no warning when skills hash matches', async () => {
1345
+ // Use a simple CLI where we can compute the exact hash
1346
+ const cli = Cli.create('tool', { version: '1.0.0' })
1347
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1348
+ __mockSkillsHash = Skill.hash([{ name: 'ping', description: 'Health check' }])
1349
+
1350
+ const { output } = await serve(cli, ['ping'])
1351
+ expect(output).toContain('pong: true')
1352
+ expect(stderrSpy).not.toHaveBeenCalled()
1353
+ })
1354
+
1355
+ test('no warning on first use (no hash stored)', async () => {
1356
+ __mockSkillsHash = undefined
1357
+ const { output } = await serve(createApp(), ['ping'])
1358
+ expect(output).toContain('pong: true')
1359
+ expect(stderrSpy).not.toHaveBeenCalled()
1360
+ })
1361
+
1362
+ test('no warning for --llms', async () => {
1363
+ __mockSkillsHash = '0000000000000000'
1364
+ await serve(createApp(), ['--llms'])
1365
+ expect(stderrSpy).not.toHaveBeenCalled()
1366
+ })
1367
+
1368
+ test('no warning for --mcp', async () => {
1369
+ __mockSkillsHash = '0000000000000000'
1370
+ // --mcp starts a server that reads stdin, so we can't easily test it here.
1371
+ // Instead verify it doesn't reach the staleness check by checking --version
1372
+ await serve(createApp(), ['--version'])
1373
+ expect(stderrSpy).not.toHaveBeenCalled()
1374
+ })
1375
+ })
1376
+
1377
+ async function serve(
1378
+ cli: { serve: Cli.Cli['serve'] },
1379
+ argv: string[],
1380
+ options: Cli.serve.Options = {},
1381
+ ) {
1382
+ let output = ''
1383
+ let exitCode: number | undefined
1384
+ await cli.serve(argv, {
1385
+ stdout(s) {
1386
+ output += s
1387
+ },
1388
+ exit(code) {
1389
+ exitCode = code
1390
+ },
1391
+ ...options,
1392
+ })
1393
+ return {
1394
+ output: output.replace(/duration: \d+ms/g, 'duration: <stripped>'),
1395
+ exitCode,
1396
+ }
1397
+ }
1398
+
1399
+ function json(raw: string) {
1400
+ return JSON.parse(raw)
1401
+ }
1402
+
1403
+ function createApp() {
1404
+ const auth = Cli.create('auth', { description: 'Authentication commands' })
1405
+ .command('login', {
1406
+ description: 'Log in to the service',
1407
+ env: z.object({
1408
+ AUTH_TOKEN: z.string().optional().describe('Pre-existing auth token'),
1409
+ AUTH_HOST: z.string().default('api.example.com').describe('Auth server hostname'),
1410
+ }),
1411
+ options: z.object({
1412
+ hostname: z.string().default('api.example.com').describe('API hostname'),
1413
+ web: z.boolean().default(false).describe('Open browser'),
1414
+ scopes: z.array(z.string()).default([]).describe('OAuth scopes'),
1415
+ }),
1416
+ alias: { hostname: 'h', web: 'w' },
1417
+ run({ env, options, ok }) {
1418
+ return ok(
1419
+ { hostname: env.AUTH_HOST, scopes: options.scopes },
1420
+ {
1421
+ cta: {
1422
+ description: 'Verify your session:',
1423
+ commands: ['auth status'],
1424
+ },
1425
+ },
1426
+ )
1427
+ },
1428
+ })
1429
+ .command('logout', {
1430
+ description: 'Log out of the service',
1431
+ run({ ok }) {
1432
+ return ok({ loggedOut: true })
1433
+ },
1434
+ })
1435
+ .command('status', {
1436
+ description: 'Show authentication status',
1437
+ output: z.object({ loggedIn: z.boolean(), hostname: z.string(), user: z.string() }),
1438
+ run({ error }) {
1439
+ return error({
1440
+ code: 'NOT_AUTHENTICATED',
1441
+ message: 'Not logged in',
1442
+ retryable: false,
1443
+ cta: { commands: ['auth login'] },
1444
+ })
1445
+ },
1446
+ })
1447
+
1448
+ const project = Cli.create('project', { description: 'Manage projects' })
1449
+ .command('list', {
1450
+ description: 'List projects',
1451
+ options: z.object({
1452
+ limit: z.number().default(20).describe('Max results'),
1453
+ sort: z.enum(['name', 'created', 'updated']).default('name').describe('Sort field'),
1454
+ archived: z.boolean().default(false).describe('Include archived'),
1455
+ }),
1456
+ alias: { limit: 'l', sort: 's' },
1457
+
1458
+ output: z.object({
1459
+ items: z.array(
1460
+ z.object({
1461
+ id: z.string(),
1462
+ name: z.string(),
1463
+ archived: z.boolean(),
1464
+ }),
1465
+ ),
1466
+ total: z.number(),
1467
+ }),
1468
+ run({ options, ok }) {
1469
+ const items = [
1470
+ { id: 'p1', name: 'Alpha', archived: false },
1471
+ { id: 'p2', name: 'Beta', archived: true },
1472
+ ].filter((p) => options.archived || !p.archived)
1473
+ return ok(
1474
+ { items, total: items.length },
1475
+ {
1476
+ cta: {
1477
+ commands: items.map((p) => ({
1478
+ command: `project get ${p.id}`,
1479
+ description: `View "${p.name}"`,
1480
+ })),
1481
+ },
1482
+ },
1483
+ )
1484
+ },
1485
+ })
1486
+ .command('get', {
1487
+ description: 'Get a project by ID',
1488
+ args: z.object({ id: z.string().describe('Project ID') }),
1489
+ output: z.object({
1490
+ id: z.string(),
1491
+ name: z.string(),
1492
+ description: z.string(),
1493
+ members: z.array(z.object({ userId: z.string(), role: z.string() })),
1494
+ }),
1495
+ run({ args, ok }) {
1496
+ return ok({
1497
+ id: args.id,
1498
+ name: 'Alpha',
1499
+ description: 'Main project',
1500
+ members: [{ userId: 'u1', role: 'admin' }],
1501
+ })
1502
+ },
1503
+ })
1504
+ .command('create', {
1505
+ description: 'Create a new project',
1506
+ args: z.object({ name: z.string().describe('Project name') }),
1507
+ options: z.object({
1508
+ description: z.string().default('').describe('Project description'),
1509
+ private: z.boolean().default(false).describe('Private project'),
1510
+ }),
1511
+ alias: { description: 'd' },
1512
+
1513
+ output: z.object({ id: z.string(), url: z.string() }),
1514
+ run({ args, ok }) {
1515
+ return ok(
1516
+ { id: 'p-new', url: 'https://example.com/projects/p-new' },
1517
+ {
1518
+ cta: {
1519
+ commands: [
1520
+ { command: 'project get p-new', description: `View "${args.name}"` },
1521
+ 'project list',
1522
+ ],
1523
+ },
1524
+ },
1525
+ )
1526
+ },
1527
+ })
1528
+ .command('delete', {
1529
+ description: 'Delete a project',
1530
+ args: z.object({ id: z.string().describe('Project ID') }),
1531
+ options: z.object({
1532
+ force: z.boolean().default(false).describe('Skip confirmation'),
1533
+ }),
1534
+ alias: { force: 'f' },
1535
+
1536
+ run({ args, options }) {
1537
+ if (!options.force)
1538
+ throw new Errors.IncurError({
1539
+ code: 'CONFIRMATION_REQUIRED',
1540
+ message: `Use --force to delete project ${args.id}`,
1541
+ retryable: true,
1542
+ })
1543
+ return { deleted: true, id: args.id }
1544
+ },
1545
+ })
1546
+
1547
+ const deploy = Cli.create('deploy', { description: 'Deployment commands' })
1548
+ .command('create', {
1549
+ description: 'Create a deployment',
1550
+ args: z.object({ env: z.string().describe('Target environment') }),
1551
+ options: z.object({
1552
+ branch: z.string().default('main').describe('Branch to deploy'),
1553
+ dryRun: z.boolean().default(false).describe('Dry run mode'),
1554
+ }),
1555
+ alias: { branch: 'b' },
1556
+
1557
+ output: z.object({ deployId: z.string(), url: z.string(), status: z.string() }),
1558
+ examples: [
1559
+ { description: 'Deploy staging from main', args: { env: 'staging' } },
1560
+ {
1561
+ description: 'Dry run a production deploy',
1562
+ args: { env: 'production' },
1563
+ options: { branch: 'release', dryRun: true },
1564
+ },
1565
+ ],
1566
+ run({ args, options, ok }) {
1567
+ return ok({
1568
+ deployId: 'd-123',
1569
+ url: `https://${args.env}.example.com`,
1570
+ status: options.dryRun ? 'dry-run' : 'pending',
1571
+ })
1572
+ },
1573
+ })
1574
+ .command('status', {
1575
+ description: 'Check deployment status',
1576
+ args: z.object({ deployId: z.string().describe('Deployment ID') }),
1577
+
1578
+ output: z.object({ deployId: z.string(), status: z.string(), progress: z.number() }),
1579
+ run({ args }) {
1580
+ return { deployId: args.deployId, status: 'running', progress: 75 }
1581
+ },
1582
+ })
1583
+ .command('rollback', {
1584
+ description: 'Rollback a deployment',
1585
+ args: z.object({ deployId: z.string().describe('Deployment ID') }),
1586
+
1587
+ run({ args }) {
1588
+ return { rolledBack: true, deployId: args.deployId }
1589
+ },
1590
+ })
1591
+
1592
+ project.command(deploy)
1593
+
1594
+ const config = Cli.create('config', {
1595
+ description: 'Show current configuration',
1596
+ args: z.object({ key: z.string().optional().describe('Config key to show') }),
1597
+ run({ args }) {
1598
+ if (args.key) return { key: args.key, value: 'some-value' }
1599
+ return { apiUrl: 'https://api.example.com', timeout: 30, debug: false }
1600
+ },
1601
+ })
1602
+
1603
+ const cli = Cli.create('app', {
1604
+ version: '3.5.0',
1605
+ description: 'A comprehensive CLI application for testing.',
1606
+ })
1607
+
1608
+ cli.command('ping', {
1609
+ description: 'Health check',
1610
+ run() {
1611
+ return { pong: true }
1612
+ },
1613
+ })
1614
+
1615
+ cli.command('echo', {
1616
+ description: 'Echo back arguments',
1617
+ args: z.object({
1618
+ message: z.string().describe('Message to echo'),
1619
+ repeat: z.number().optional().describe('Times to repeat'),
1620
+ }),
1621
+ options: z.object({
1622
+ upper: z.boolean().default(false).describe('Uppercase output'),
1623
+ prefix: z.string().default('').describe('Prefix string'),
1624
+ }),
1625
+ alias: { upper: 'u', prefix: 'p' },
1626
+ run({ args, options }) {
1627
+ const count = args.repeat ?? 1
1628
+ let msg = options.prefix ? `${options.prefix} ${args.message}` : args.message
1629
+ if (options.upper) msg = msg.toUpperCase()
1630
+ return { result: Array(count).fill(msg) }
1631
+ },
1632
+ })
1633
+
1634
+ cli.command('slow', {
1635
+ description: 'Async command',
1636
+ async run() {
1637
+ await new Promise((r) => setTimeout(r, 5))
1638
+ return { done: true }
1639
+ },
1640
+ })
1641
+
1642
+ cli.command('explode', {
1643
+ description: 'Always fails',
1644
+ run() {
1645
+ throw new Error('kaboom')
1646
+ },
1647
+ })
1648
+
1649
+ cli.command('explode-clac', {
1650
+ description: 'Fails with IncurError',
1651
+ run() {
1652
+ throw new Errors.IncurError({
1653
+ code: 'QUOTA_EXCEEDED',
1654
+ message: 'Rate limit exceeded',
1655
+ retryable: true,
1656
+ hint: 'Wait 60 seconds',
1657
+ })
1658
+ },
1659
+ })
1660
+
1661
+ cli.command('validate-fail', {
1662
+ description: 'Fails validation',
1663
+ args: z.object({
1664
+ email: z.string().email().describe('Email address'),
1665
+ age: z.number().min(0).max(150).describe('Age'),
1666
+ }),
1667
+ run({ args }) {
1668
+ return args
1669
+ },
1670
+ })
1671
+
1672
+ cli.command('stream', {
1673
+ description: 'Stream chunks',
1674
+ async *run() {
1675
+ yield { content: 'hello' }
1676
+ yield { content: 'world' }
1677
+ },
1678
+ })
1679
+
1680
+ cli.command('stream-ok', {
1681
+ description: 'Stream with ok() return',
1682
+ async *run({ ok }) {
1683
+ yield { n: 1 }
1684
+ yield { n: 2 }
1685
+ return ok(undefined as any, { cta: { commands: ['ping'] } })
1686
+ },
1687
+ })
1688
+
1689
+ cli.command('stream-error', {
1690
+ description: 'Stream with mid-stream error',
1691
+ async *run({ error }) {
1692
+ yield { n: 1 }
1693
+ return error({ code: 'STREAM_FAIL', message: 'broke mid-stream' })
1694
+ },
1695
+ })
1696
+
1697
+ cli.command('stream-throw', {
1698
+ description: 'Stream that throws',
1699
+ async *run() {
1700
+ yield { n: 1 }
1701
+ throw new Error('stream kaboom')
1702
+ },
1703
+ })
1704
+
1705
+ cli.command(auth)
1706
+ cli.command(project)
1707
+ cli.command(config)
1708
+
1709
+ return cli
1710
+ }