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,1373 @@
1
+ import { Cli, Errors, 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
+ async function serve(
11
+ cli: { serve: Cli.Cli['serve'] },
12
+ argv: string[],
13
+ options: Cli.serve.Options = {},
14
+ ) {
15
+ let output = ''
16
+ let exitCode: number | undefined
17
+ await cli.serve(argv, {
18
+ stdout(s) {
19
+ output += s
20
+ },
21
+ exit(code) {
22
+ exitCode = code
23
+ },
24
+ ...options,
25
+ })
26
+ return {
27
+ output: output.replace(/duration: \d+ms/, 'duration: <stripped>'),
28
+ exitCode,
29
+ }
30
+ }
31
+
32
+ describe('create', () => {
33
+ test('returns cli instance with name', () => {
34
+ const cli = Cli.create('test')
35
+ expect(cli.name).toBe('test')
36
+ })
37
+
38
+ test('accepts version and description options', () => {
39
+ const cli = Cli.create('test', { version: '1.0.0', description: 'A test CLI' })
40
+ expect(cli.name).toBe('test')
41
+ })
42
+ })
43
+
44
+ describe('command', () => {
45
+ test('registers a command and is chainable', () => {
46
+ const cli = Cli.create('test')
47
+ const result = cli.command('greet', {
48
+ args: z.object({ name: z.string() }),
49
+ run({ args }) {
50
+ return { message: `hello ${args.name}` }
51
+ },
52
+ })
53
+ expect(result).toBe(cli)
54
+ })
55
+ })
56
+
57
+ describe('serve', () => {
58
+ test('outputs data only by default', async () => {
59
+ const cli = Cli.create('test')
60
+ cli.command('greet', {
61
+ args: z.object({ name: z.string() }),
62
+ run({ args }) {
63
+ return { message: `hello ${args.name}` }
64
+ },
65
+ })
66
+
67
+ const { output } = await serve(cli, ['greet', 'world'])
68
+ expect(output).toMatchInlineSnapshot(`
69
+ "message: hello world
70
+ "
71
+ `)
72
+ })
73
+
74
+ test('--verbose outputs full envelope', async () => {
75
+ const cli = Cli.create('test')
76
+ cli.command('greet', {
77
+ args: z.object({ name: z.string() }),
78
+ run({ args }) {
79
+ return { message: `hello ${args.name}` }
80
+ },
81
+ })
82
+
83
+ const { output } = await serve(cli, ['greet', 'world', '--verbose'])
84
+ expect(output).toMatchInlineSnapshot(`
85
+ "ok: true
86
+ data:
87
+ message: hello world
88
+ meta:
89
+ command: greet
90
+ duration: <stripped>
91
+ "
92
+ `)
93
+ })
94
+
95
+ test('parses positional args by schema key order', async () => {
96
+ const cli = Cli.create('test')
97
+ let receivedArgs: any
98
+ cli.command('add', {
99
+ args: z.object({ a: z.string(), b: z.string() }),
100
+ run({ args }) {
101
+ receivedArgs = args
102
+ return {}
103
+ },
104
+ })
105
+
106
+ await serve(cli, ['add', 'foo', 'bar'])
107
+ expect(receivedArgs).toEqual({ a: 'foo', b: 'bar' })
108
+ })
109
+
110
+ test('serializes output as TOON', async () => {
111
+ const cli = Cli.create('test')
112
+ cli.command('ping', {
113
+ run() {
114
+ return { pong: true }
115
+ },
116
+ })
117
+
118
+ const { output } = await serve(cli, ['ping'])
119
+ expect(() => JSON.parse(output)).toThrow()
120
+ expect(output).toMatchInlineSnapshot(`
121
+ "pong: true
122
+ "
123
+ `)
124
+ })
125
+
126
+ test('outputs error details for unknown command', async () => {
127
+ const cli = Cli.create('test')
128
+
129
+ const { output, exitCode } = await serve(cli, ['nonexistent'])
130
+ expect(exitCode).toBe(1)
131
+ expect(output).toMatchInlineSnapshot(`
132
+ "Error: 'nonexistent' is not a command. See 'test --help' for a list of available commands.
133
+ "
134
+ `)
135
+ })
136
+
137
+ test('--verbose outputs full error envelope for unknown command', async () => {
138
+ const cli = Cli.create('test')
139
+
140
+ const { output, exitCode } = await serve(cli, ['nonexistent', '--verbose'])
141
+ expect(exitCode).toBe(1)
142
+ expect(output).toMatchInlineSnapshot(`
143
+ "ok: false
144
+ error:
145
+ code: COMMAND_NOT_FOUND
146
+ message: 'nonexistent' is not a command. See 'test --help' for a list of available commands.
147
+ meta:
148
+ command: nonexistent
149
+ duration: <stripped>
150
+ "
151
+ `)
152
+ })
153
+
154
+ test('wraps handler errors in error output', async () => {
155
+ const cli = Cli.create('test')
156
+ cli.command('fail', {
157
+ run() {
158
+ throw new Error('boom')
159
+ },
160
+ })
161
+
162
+ const { output, exitCode } = await serve(cli, ['fail'])
163
+ expect(exitCode).toBe(1)
164
+ expect(output).toMatchInlineSnapshot(`
165
+ "Error: boom
166
+ "
167
+ `)
168
+ })
169
+
170
+ test('IncurError in run() populates code/retryable', async () => {
171
+ const cli = Cli.create('test')
172
+ cli.command('fail', {
173
+ run() {
174
+ throw new Errors.IncurError({
175
+ code: 'NOT_AUTHENTICATED',
176
+ message: 'Token not found',
177
+ retryable: false,
178
+ })
179
+ },
180
+ })
181
+
182
+ const { output, exitCode } = await serve(cli, ['fail'])
183
+ expect(exitCode).toBe(1)
184
+ expect(output).toMatchInlineSnapshot(`
185
+ "Error (NOT_AUTHENTICATED): Token not found
186
+ "
187
+ `)
188
+ })
189
+
190
+ test('ValidationError includes fieldErrors', async () => {
191
+ const cli = Cli.create('test')
192
+ cli.command('greet', {
193
+ args: z.object({ name: z.string() }),
194
+ run({ args }) {
195
+ return { message: `hello ${args.name}` }
196
+ },
197
+ })
198
+
199
+ const { output, exitCode } = await serve(cli, ['greet'])
200
+ expect(exitCode).toBe(1)
201
+ expect(output).toContain('Error: missing required argument <name>')
202
+ })
203
+
204
+ test('supports async handlers', async () => {
205
+ const cli = Cli.create('test')
206
+ cli.command('async', {
207
+ async run() {
208
+ await new Promise((r) => setTimeout(r, 10))
209
+ return { done: true }
210
+ },
211
+ })
212
+
213
+ const { output } = await serve(cli, ['async'])
214
+ expect(output).toMatchInlineSnapshot(`
215
+ "done: true
216
+ "
217
+ `)
218
+ })
219
+
220
+ test('--format json outputs JSON data', async () => {
221
+ const cli = Cli.create('test')
222
+ cli.command('ping', { run: () => ({ pong: true }) })
223
+ const { output } = await serve(cli, ['ping', '--format', 'json'])
224
+ expect(JSON.parse(output)).toEqual({ pong: true })
225
+ })
226
+
227
+ test('--json is shorthand for --format json', async () => {
228
+ const cli = Cli.create('test')
229
+ cli.command('ping', { run: () => ({ pong: true }) })
230
+ const { output } = await serve(cli, ['ping', '--json'])
231
+ expect(JSON.parse(output)).toEqual({ pong: true })
232
+ })
233
+
234
+ test('--verbose --format json outputs full envelope as JSON', async () => {
235
+ const cli = Cli.create('test')
236
+ cli.command('ping', { run: () => ({ pong: true }) })
237
+ const { output } = await serve(cli, ['ping', '--verbose', '--format', 'json'])
238
+ const parsed = JSON.parse(output)
239
+ expect(parsed.ok).toBe(true)
240
+ expect(parsed.data).toEqual({ pong: true })
241
+ expect(parsed.meta.command).toBe('ping')
242
+ })
243
+
244
+ test('error output respects --format json', async () => {
245
+ const cli = Cli.create('test')
246
+ cli.command('fail', {
247
+ run() {
248
+ throw new Error('boom')
249
+ },
250
+ })
251
+ const { output, exitCode } = await serve(cli, ['fail', '--format', 'json'])
252
+ expect(exitCode).toBe(1)
253
+ const parsed = JSON.parse(output)
254
+ expect(parsed.code).toBe('UNKNOWN')
255
+ expect(parsed.message).toBe('boom')
256
+ })
257
+ })
258
+
259
+ describe('--llms', () => {
260
+ test('outputs manifest with version and commands', async () => {
261
+ const cli = Cli.create('test')
262
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
263
+
264
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
265
+ const manifest = JSON.parse(output)
266
+ expect(manifest.version).toBe('incur.v1')
267
+ expect(manifest.commands).toHaveLength(1)
268
+ expect(manifest.commands[0].name).toBe('ping')
269
+ expect(manifest.commands[0].description).toBe('Health check')
270
+ })
271
+
272
+ test('manifest includes schema.input from args and options', async () => {
273
+ const cli = Cli.create('test')
274
+ cli.command('greet', {
275
+ args: z.object({ name: z.string() }),
276
+ options: z.object({ loud: z.boolean().default(false) }),
277
+ run: ({ args }) => ({ message: `hello ${args.name}` }),
278
+ })
279
+
280
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
281
+ const manifest = JSON.parse(output)
282
+ expect(manifest.commands[0].schema.args).toEqual({
283
+ type: 'object',
284
+ properties: { name: { type: 'string' } },
285
+ required: ['name'],
286
+ additionalProperties: false,
287
+ })
288
+ expect(manifest.commands[0].schema.options).toEqual({
289
+ type: 'object',
290
+ properties: { loud: { type: 'boolean', default: false } },
291
+ required: ['loud'],
292
+ additionalProperties: false,
293
+ })
294
+ })
295
+
296
+ test('manifest includes schema.output when defined', async () => {
297
+ const cli = Cli.create('test')
298
+ cli.command('greet', {
299
+ args: z.object({ name: z.string() }),
300
+ output: z.object({ message: z.string() }),
301
+ run: ({ args }) => ({ message: `hello ${args.name}` }),
302
+ })
303
+
304
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
305
+ const manifest = JSON.parse(output)
306
+ expect(manifest.commands[0].schema.output).toEqual({
307
+ type: 'object',
308
+ properties: { message: { type: 'string' } },
309
+ required: ['message'],
310
+ additionalProperties: false,
311
+ })
312
+ })
313
+
314
+ test('manifest omits schema when no schemas defined', async () => {
315
+ const cli = Cli.create('test')
316
+ cli.command('ping', { run: () => ({ pong: true }) })
317
+
318
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
319
+ const manifest = JSON.parse(output)
320
+ expect(manifest.commands[0].schema).toBeUndefined()
321
+ })
322
+
323
+ test('nested commands appear with full path in manifest', async () => {
324
+ const cli = Cli.create('test')
325
+ const pr = Cli.create('pr', { description: 'PR management' })
326
+ .command('list', {
327
+ description: 'List PRs',
328
+ options: z.object({ state: z.enum(['open', 'closed']).default('open') }),
329
+ run: () => ({ items: [] }),
330
+ })
331
+ .command('create', {
332
+ description: 'Create PR',
333
+ args: z.object({ title: z.string() }),
334
+ run: ({ args }) => ({ title: args.title }),
335
+ })
336
+ cli.command(pr)
337
+
338
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
339
+ const manifest = JSON.parse(output)
340
+ expect(manifest.commands).toHaveLength(2)
341
+ expect(manifest.commands[0].name).toBe('pr create')
342
+ expect(manifest.commands[1].name).toBe('pr list')
343
+ })
344
+
345
+ test('deeply nested commands in manifest', async () => {
346
+ const cli = Cli.create('test')
347
+ const review = Cli.create('review', { description: 'Reviews' }).command('approve', {
348
+ description: 'Approve a review',
349
+ run: () => ({ approved: true }),
350
+ })
351
+ const pr = Cli.create('pr', { description: 'PR management' })
352
+ pr.command(review)
353
+ cli.command(pr)
354
+
355
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
356
+ const manifest = JSON.parse(output)
357
+ expect(manifest.commands[0].name).toBe('pr review approve')
358
+ expect(manifest.commands[0].description).toBe('Approve a review')
359
+ })
360
+
361
+ test('defaults to markdown format', async () => {
362
+ const cli = Cli.create('test')
363
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
364
+
365
+ const { output } = await serve(cli, ['--llms'])
366
+ expect(output).toContain('# test ping')
367
+ expect(output).toContain('Health check')
368
+ })
369
+
370
+ test('respects --format yaml', async () => {
371
+ const cli = Cli.create('test')
372
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
373
+
374
+ const { output } = await serve(cli, ['--llms', '--format', 'yaml'])
375
+ expect(output).toContain('version: incur.v1')
376
+ expect(output).toContain('name: ping')
377
+ })
378
+
379
+ test('full manifest snapshot', async () => {
380
+ const cli = Cli.create('test')
381
+ cli.command('greet', {
382
+ description: 'Greet someone',
383
+ args: z.object({ name: z.string().describe('Name to greet') }),
384
+ options: z.object({ loud: z.boolean().default(false).describe('Shout it') }),
385
+ output: z.object({ message: z.string() }),
386
+ run: ({ args }) => ({ message: `hello ${args.name}` }),
387
+ })
388
+
389
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
390
+ expect(JSON.parse(output)).toMatchInlineSnapshot(`
391
+ {
392
+ "commands": [
393
+ {
394
+ "description": "Greet someone",
395
+ "name": "greet",
396
+ "schema": {
397
+ "args": {
398
+ "additionalProperties": false,
399
+ "properties": {
400
+ "name": {
401
+ "description": "Name to greet",
402
+ "type": "string",
403
+ },
404
+ },
405
+ "required": [
406
+ "name",
407
+ ],
408
+ "type": "object",
409
+ },
410
+ "options": {
411
+ "additionalProperties": false,
412
+ "properties": {
413
+ "loud": {
414
+ "default": false,
415
+ "description": "Shout it",
416
+ "type": "boolean",
417
+ },
418
+ },
419
+ "required": [
420
+ "loud",
421
+ ],
422
+ "type": "object",
423
+ },
424
+ "output": {
425
+ "additionalProperties": false,
426
+ "properties": {
427
+ "message": {
428
+ "type": "string",
429
+ },
430
+ },
431
+ "required": [
432
+ "message",
433
+ ],
434
+ "type": "object",
435
+ },
436
+ },
437
+ },
438
+ ],
439
+ "version": "incur.v1",
440
+ }
441
+ `)
442
+ })
443
+
444
+ test('--llms --format md outputs skill files', async () => {
445
+ const cli = Cli.create('test')
446
+ cli.command('greet', {
447
+ description: 'Greet someone',
448
+ args: z.object({ name: z.string().describe('Name to greet') }),
449
+ output: z.object({ message: z.string() }),
450
+ run: ({ args }) => ({ message: `hello ${args.name}` }),
451
+ })
452
+
453
+ const { output } = await serve(cli, ['--llms', '--format', 'md'])
454
+ expect(output).toContain('# test greet')
455
+ expect(output).toContain('## Arguments')
456
+ expect(output).toContain('## Output')
457
+ expect(output).not.toMatch(/^---$/m)
458
+ })
459
+ })
460
+
461
+ describe('subcommands', () => {
462
+ test('creates a command group with name and description', () => {
463
+ const pr = Cli.create('pr', { description: 'PR management' })
464
+ expect(pr.name).toBe('pr')
465
+ expect(pr.description).toBe('PR management')
466
+ })
467
+
468
+ test('group registers sub-commands and is chainable', () => {
469
+ const pr = Cli.create('pr', { description: 'PR management' })
470
+ const result = pr.command('list', { run: () => ({ count: 0 }) })
471
+ expect(result).toBe(pr)
472
+ })
473
+
474
+ test('routes to sub-command', async () => {
475
+ const cli = Cli.create('test')
476
+ const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
477
+ run: () => ({ count: 0 }),
478
+ })
479
+ cli.command(pr)
480
+
481
+ const { output } = await serve(cli, ['pr', 'list'])
482
+ expect(output).toMatchInlineSnapshot(`
483
+ "count: 0
484
+ "
485
+ `)
486
+ })
487
+
488
+ test('sub-command receives parsed args and options', async () => {
489
+ const cli = Cli.create('test')
490
+ const pr = Cli.create('pr', { description: 'PR management' }).command('get', {
491
+ args: z.object({ id: z.string() }),
492
+ options: z.object({ draft: z.boolean().default(false) }),
493
+ run: ({ args, options }) => ({ id: args.id, draft: options.draft }),
494
+ })
495
+ cli.command(pr)
496
+
497
+ const { output } = await serve(cli, ['pr', 'get', '42', '--draft'])
498
+ expect(output).toMatchInlineSnapshot(`
499
+ "id: "42"
500
+ draft: true
501
+ "
502
+ `)
503
+ })
504
+
505
+ test('--verbose shows full command path in meta', async () => {
506
+ const cli = Cli.create('test')
507
+ const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
508
+ run: () => ({ count: 0 }),
509
+ })
510
+ cli.command(pr)
511
+
512
+ const { output } = await serve(cli, ['pr', 'list', '--verbose'])
513
+ expect(output).toMatchInlineSnapshot(`
514
+ "ok: true
515
+ data:
516
+ count: 0
517
+ meta:
518
+ command: pr list
519
+ duration: <stripped>
520
+ "
521
+ `)
522
+ })
523
+
524
+ test('routes to deeply nested sub-commands', async () => {
525
+ const cli = Cli.create('test')
526
+ const review = Cli.create('review', { description: 'Reviews' }).command('approve', {
527
+ run: () => ({ approved: true }),
528
+ })
529
+ const pr = Cli.create('pr', { description: 'PR management' })
530
+ pr.command(review)
531
+ cli.command(pr)
532
+
533
+ const { output } = await serve(cli, ['pr', 'review', 'approve'])
534
+ expect(output).toMatchInlineSnapshot(`
535
+ "approved: true
536
+ "
537
+ `)
538
+ })
539
+
540
+ test('nested group shows full path in verbose meta', async () => {
541
+ const cli = Cli.create('test')
542
+ const review = Cli.create('review', { description: 'Reviews' }).command('approve', {
543
+ run: () => ({ approved: true }),
544
+ })
545
+ const pr = Cli.create('pr', { description: 'PR management' })
546
+ pr.command(review)
547
+ cli.command(pr)
548
+
549
+ const { output } = await serve(cli, ['pr', 'review', 'approve', '--verbose'])
550
+ expect(output).toMatchInlineSnapshot(`
551
+ "ok: true
552
+ data:
553
+ approved: true
554
+ meta:
555
+ command: pr review approve
556
+ duration: <stripped>
557
+ "
558
+ `)
559
+ })
560
+
561
+ test('unknown subcommand lists available commands', async () => {
562
+ const cli = Cli.create('test')
563
+ const pr = Cli.create('pr', { description: 'PR management' })
564
+ .command('list', { run: () => ({}) })
565
+ .command('create', { run: () => ({}) })
566
+ cli.command(pr)
567
+
568
+ const { output, exitCode } = await serve(cli, ['pr', 'unknown'])
569
+ expect(exitCode).toBe(1)
570
+ expect(output).toMatchInlineSnapshot(`
571
+ "Error: 'unknown' is not a command. See 'test pr --help' for a list of available commands.
572
+ "
573
+ `)
574
+ })
575
+
576
+ test('group without subcommand shows help', async () => {
577
+ const cli = Cli.create('test')
578
+ const pr = Cli.create('pr', { description: 'PR management' })
579
+ .command('list', { run: () => ({}) })
580
+ .command('create', { run: () => ({}) })
581
+ cli.command(pr)
582
+
583
+ const { output, exitCode } = await serve(cli, ['pr'])
584
+ expect(exitCode).toBeUndefined()
585
+ expect(output).toMatchInlineSnapshot(`
586
+ "test pr — PR management
587
+
588
+ Usage: test pr <command>
589
+
590
+ Commands:
591
+ create
592
+ list
593
+
594
+ Global Options:
595
+ --format <toon|json|yaml|md|jsonl> Output format
596
+ --help Show help
597
+ --llms Print LLM-readable manifest
598
+ --verbose Show full output envelope
599
+ "
600
+ `)
601
+ })
602
+
603
+ test('sub-commands from separate module can be mounted', async () => {
604
+ function createPrCommands() {
605
+ return Cli.create('pr', { description: 'PR management' }).command('list', {
606
+ run: () => ({ count: 0 }),
607
+ })
608
+ }
609
+
610
+ const cli = Cli.create('test')
611
+ cli.command(createPrCommands())
612
+
613
+ const { output } = await serve(cli, ['pr', 'list'])
614
+ expect(output).toMatchInlineSnapshot(`
615
+ "count: 0
616
+ "
617
+ `)
618
+ })
619
+
620
+ test('error in sub-command wraps in error envelope', async () => {
621
+ const cli = Cli.create('test')
622
+ const pr = Cli.create('pr', { description: 'PR management' }).command('fail', {
623
+ run() {
624
+ throw new Error('sub-boom')
625
+ },
626
+ })
627
+ cli.command(pr)
628
+
629
+ const { output, exitCode } = await serve(cli, ['pr', 'fail'])
630
+ expect(exitCode).toBe(1)
631
+ expect(output).toMatchInlineSnapshot(`
632
+ "Error: sub-boom
633
+ "
634
+ `)
635
+ })
636
+
637
+ test('group error respects --format json', async () => {
638
+ const cli = Cli.create('test')
639
+ const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
640
+ run: () => ({}),
641
+ })
642
+ cli.command(pr)
643
+
644
+ const { output, exitCode } = await serve(cli, ['pr', 'unknown', '--format', 'json'])
645
+ expect(exitCode).toBe(1)
646
+ const parsed = JSON.parse(output)
647
+ expect(parsed.code).toBe('COMMAND_NOT_FOUND')
648
+ expect(parsed.message).toContain('unknown')
649
+ })
650
+ })
651
+
652
+ describe('cta', () => {
653
+ test('string shorthand for cta commands', async () => {
654
+ const cli = Cli.create('test')
655
+ cli.command('list', {
656
+ run({ ok }) {
657
+ return ok({ items: [] }, { cta: { commands: ['get 1', 'get 2'] } })
658
+ },
659
+ })
660
+
661
+ const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
662
+ const parsed = JSON.parse(output)
663
+ expect(parsed.meta.cta).toEqual({
664
+ description: 'Suggested commands:',
665
+ commands: [{ command: 'test get 1' }, { command: 'test get 2' }],
666
+ })
667
+ })
668
+
669
+ test('tuple shorthand with description', async () => {
670
+ const cli = Cli.create('test')
671
+ cli.command('list', {
672
+ run({ ok }) {
673
+ return ok(
674
+ { items: [] },
675
+ {
676
+ cta: { commands: [{ command: 'get 1', description: 'View item 1' }] },
677
+ },
678
+ )
679
+ },
680
+ })
681
+
682
+ const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
683
+ const parsed = JSON.parse(output)
684
+ expect(parsed.meta.cta.commands).toEqual([
685
+ { command: 'test get 1', description: 'View item 1' },
686
+ ])
687
+ })
688
+
689
+ test('tuple form with args/options', async () => {
690
+ const cli = Cli.create('test')
691
+ cli.command('create', {
692
+ run({ ok }) {
693
+ return ok(
694
+ { id: 1 },
695
+ {
696
+ cta: {
697
+ commands: [
698
+ {
699
+ command: 'get',
700
+ args: { id: 1 },
701
+ options: { limit: 10 },
702
+ description: 'View the item',
703
+ },
704
+ ],
705
+ },
706
+ },
707
+ )
708
+ },
709
+ })
710
+
711
+ const { output } = await serve(cli, ['create', '--verbose', '--format', 'json'])
712
+ const parsed = JSON.parse(output)
713
+ expect(parsed.meta.cta.commands).toEqual([
714
+ { command: 'test get 1 --limit 10', description: 'View the item' },
715
+ ])
716
+ })
717
+
718
+ test('tuple form boolean args format as placeholders', async () => {
719
+ const cli = Cli.create('test')
720
+ cli.command('list', {
721
+ run({ ok }) {
722
+ return ok(
723
+ { items: [] },
724
+ {
725
+ cta: { commands: [{ command: 'get', args: { id: true }, options: { format: true } }] },
726
+ },
727
+ )
728
+ },
729
+ })
730
+
731
+ const { output } = await serve(cli, ['list', '--verbose', '--format', 'json'])
732
+ const parsed = JSON.parse(output)
733
+ expect(parsed.meta.cta.commands).toEqual([{ command: 'test get <id> --format <format>' }])
734
+ })
735
+
736
+ test('custom cta description', async () => {
737
+ const cli = Cli.create('test')
738
+ cli.command('create', {
739
+ run({ ok }) {
740
+ return ok(
741
+ { id: 1 },
742
+ {
743
+ cta: { description: 'View the created item:', commands: ['get 1'] },
744
+ },
745
+ )
746
+ },
747
+ })
748
+
749
+ const { output } = await serve(cli, ['create', '--verbose', '--format', 'json'])
750
+ const parsed = JSON.parse(output)
751
+ expect(parsed.meta.cta.description).toBe('View the created item:')
752
+ })
753
+
754
+ test('plain return omits meta.cta', async () => {
755
+ const cli = Cli.create('test')
756
+ cli.command('ping', { run: () => ({ pong: true }) })
757
+
758
+ const { output } = await serve(cli, ['ping', '--verbose', '--format', 'json'])
759
+ const parsed = JSON.parse(output)
760
+ expect(parsed.meta.cta).toBeUndefined()
761
+ })
762
+
763
+ test('empty commands array omits meta.cta', async () => {
764
+ const cli = Cli.create('test')
765
+ cli.command('noop', {
766
+ run({ ok }) {
767
+ return ok({ done: true }, { cta: { commands: [] } })
768
+ },
769
+ })
770
+
771
+ const { output } = await serve(cli, ['noop', '--verbose', '--format', 'json'])
772
+ const parsed = JSON.parse(output)
773
+ expect(parsed.meta.cta).toBeUndefined()
774
+ })
775
+
776
+ test('error() with cta', async () => {
777
+ const cli = Cli.create('test')
778
+ cli.command('fail', {
779
+ run({ error }) {
780
+ return error({
781
+ code: 'NOT_AUTHENTICATED',
782
+ message: 'Not logged in',
783
+ cta: {
784
+ description: 'Authenticate to continue:',
785
+ commands: ['auth login'],
786
+ },
787
+ })
788
+ },
789
+ })
790
+
791
+ const { output, exitCode } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
792
+ expect(exitCode).toBe(1)
793
+ const parsed = JSON.parse(output)
794
+ expect(parsed.ok).toBe(false)
795
+ expect(parsed.meta.cta).toEqual({
796
+ description: 'Authenticate to continue:',
797
+ commands: [{ command: 'test auth login' }],
798
+ })
799
+ })
800
+
801
+ test('error() without cta omits meta.cta', async () => {
802
+ const cli = Cli.create('test')
803
+ cli.command('fail', {
804
+ run({ error }) {
805
+ return error({ code: 'FAILED', message: 'Something went wrong' })
806
+ },
807
+ })
808
+
809
+ const { output, exitCode } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
810
+ expect(exitCode).toBe(1)
811
+ const parsed = JSON.parse(output)
812
+ expect(parsed.meta.cta).toBeUndefined()
813
+ })
814
+
815
+ test('thrown error does not include cta', async () => {
816
+ const cli = Cli.create('test')
817
+ cli.command('fail', {
818
+ run() {
819
+ throw new Error('boom')
820
+ },
821
+ })
822
+
823
+ const { output } = await serve(cli, ['fail', '--verbose', '--format', 'json'])
824
+ const parsed = JSON.parse(output)
825
+ expect(parsed.ok).toBe(false)
826
+ expect(parsed.meta.cta).toBeUndefined()
827
+ })
828
+
829
+ test('ok() cta works with sub-commands', async () => {
830
+ const cli = Cli.create('test')
831
+ const pr = Cli.create('pr', { description: 'PR management' }).command('create', {
832
+ args: z.object({ title: z.string() }),
833
+ output: z.object({ id: z.number(), title: z.string() }),
834
+ run({ args, ok }) {
835
+ return ok(
836
+ { id: 42, title: args.title },
837
+ {
838
+ cta: { commands: [{ command: 'pr get 42', description: 'View the PR' }] },
839
+ },
840
+ )
841
+ },
842
+ })
843
+ cli.command(pr)
844
+
845
+ const { output } = await serve(cli, ['pr', 'create', 'my-pr', '--verbose', '--format', 'json'])
846
+ const parsed = JSON.parse(output)
847
+ expect(parsed.meta.cta).toEqual({
848
+ description: 'Suggested commands:',
849
+ commands: [{ command: 'test pr get 42', description: 'View the PR' }],
850
+ })
851
+ })
852
+ })
853
+
854
+ describe('leaf cli', () => {
855
+ test('create with run returns a leaf cli (no command method)', () => {
856
+ const cli = Cli.create('ping', { run: () => ({ pong: true }) })
857
+ expect(cli.name).toBe('ping')
858
+ expect('command' in cli).toBe(false)
859
+ })
860
+
861
+ test('serves without a command name in argv', async () => {
862
+ const cli = Cli.create('ping', { run: () => ({ pong: true }) })
863
+ const { output } = await serve(cli, [])
864
+ expect(output).toMatchInlineSnapshot(`
865
+ "pong: true
866
+ "
867
+ `)
868
+ })
869
+
870
+ test('parses args and options', async () => {
871
+ const cli = Cli.create('greet', {
872
+ args: z.object({ name: z.string() }),
873
+ options: z.object({ loud: z.boolean().default(false) }),
874
+ run({ args, options }) {
875
+ return { message: options.loud ? `HELLO ${args.name}` : `hello ${args.name}` }
876
+ },
877
+ })
878
+ const { output } = await serve(cli, ['world', '--loud'])
879
+ expect(output).toMatchInlineSnapshot(`
880
+ "message: HELLO world
881
+ "
882
+ `)
883
+ })
884
+
885
+ test('--verbose outputs full envelope', async () => {
886
+ const cli = Cli.create('ping', { run: () => ({ pong: true }) })
887
+ const { output } = await serve(cli, ['--verbose'])
888
+ expect(output).toMatchInlineSnapshot(`
889
+ "ok: true
890
+ data:
891
+ pong: true
892
+ meta:
893
+ command: ping
894
+ duration: <stripped>
895
+ "
896
+ `)
897
+ })
898
+
899
+ test('--format json works', async () => {
900
+ const cli = Cli.create('ping', { run: () => ({ pong: true }) })
901
+ const { output } = await serve(cli, ['--format', 'json'])
902
+ expect(JSON.parse(output)).toEqual({ pong: true })
903
+ })
904
+
905
+ test('errors wrap in error envelope', async () => {
906
+ const cli = Cli.create('fail', {
907
+ run() {
908
+ throw new Error('boom')
909
+ },
910
+ })
911
+ const { output, exitCode } = await serve(cli, [])
912
+ expect(exitCode).toBe(1)
913
+ expect(output).toMatchInlineSnapshot(`
914
+ "Error: boom
915
+ "
916
+ `)
917
+ })
918
+
919
+ test('can be mounted on a parent as a single command', async () => {
920
+ const ping = Cli.create('ping', {
921
+ description: 'Health check',
922
+ run: () => ({ pong: true }),
923
+ })
924
+ const cli = Cli.create('app')
925
+ cli.command(ping)
926
+
927
+ const { output } = await serve(cli, ['ping'])
928
+ expect(output).toMatchInlineSnapshot(`
929
+ "pong: true
930
+ "
931
+ `)
932
+ })
933
+
934
+ test('mounted leaf with args/options works', async () => {
935
+ const greet = Cli.create('greet', {
936
+ args: z.object({ name: z.string() }),
937
+ options: z.object({ loud: z.boolean().default(false) }),
938
+ run({ args, options }) {
939
+ return { message: options.loud ? `HELLO ${args.name}` : `hello ${args.name}` }
940
+ },
941
+ })
942
+ const cli = Cli.create('app')
943
+ cli.command(greet)
944
+
945
+ const { output } = await serve(cli, ['greet', 'world', '--loud'])
946
+ expect(output).toMatchInlineSnapshot(`
947
+ "message: HELLO world
948
+ "
949
+ `)
950
+ })
951
+
952
+ test('mounted leaf appears in --llms manifest', async () => {
953
+ const ping = Cli.create('ping', {
954
+ description: 'Health check',
955
+ run: () => ({ pong: true }),
956
+ })
957
+ const cli = Cli.create('app')
958
+ cli.command(ping)
959
+
960
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
961
+ const manifest = JSON.parse(output)
962
+ expect(manifest.commands).toHaveLength(1)
963
+ expect(manifest.commands[0].name).toBe('ping')
964
+ expect(manifest.commands[0].description).toBe('Health check')
965
+ })
966
+ })
967
+
968
+ describe('help', () => {
969
+ test('router with no subcommand shows help', async () => {
970
+ const cli = Cli.create('tool')
971
+ cli.command('ping', {
972
+ description: 'Health check',
973
+ run: () => ({ pong: true }),
974
+ })
975
+
976
+ const { output, exitCode } = await serve(cli, [])
977
+ expect(exitCode).toBeUndefined()
978
+ expect(output).toMatchInlineSnapshot(`
979
+ "tool
980
+
981
+ Usage: tool <command>
982
+
983
+ Commands:
984
+ ping Health check
985
+
986
+ Built-in Commands:
987
+ mcp add Register as an MCP server
988
+ skills add Sync skill files to your agent
989
+
990
+ Global Options:
991
+ --format <toon|json|yaml|md|jsonl> Output format
992
+ --help Show help
993
+ --llms Print LLM-readable manifest
994
+ --mcp Start as MCP stdio server
995
+ --verbose Show full output envelope
996
+ --version Show version
997
+ "
998
+ `)
999
+ })
1000
+
1001
+ test('--help on root shows help', async () => {
1002
+ const cli = Cli.create('tool')
1003
+ cli.command('ping', {
1004
+ description: 'Health check',
1005
+ run: () => ({ pong: true }),
1006
+ })
1007
+
1008
+ const { output, exitCode } = await serve(cli, ['--help'])
1009
+ expect(exitCode).toBeUndefined()
1010
+ expect(output).toMatchInlineSnapshot(`
1011
+ "tool
1012
+
1013
+ Usage: tool <command>
1014
+
1015
+ Commands:
1016
+ ping Health check
1017
+
1018
+ Built-in Commands:
1019
+ mcp add Register as an MCP server
1020
+ skills add Sync skill files to your agent
1021
+
1022
+ Global Options:
1023
+ --format <toon|json|yaml|md|jsonl> Output format
1024
+ --help Show help
1025
+ --llms Print LLM-readable manifest
1026
+ --mcp Start as MCP stdio server
1027
+ --verbose Show full output envelope
1028
+ --version Show version
1029
+ "
1030
+ `)
1031
+ })
1032
+
1033
+ test('--help on leaf shows command help', async () => {
1034
+ const cli = Cli.create('tool')
1035
+ cli.command('greet', {
1036
+ description: 'Greet someone',
1037
+ args: z.object({ name: z.string().describe('Name') }),
1038
+ run: ({ args }) => ({ message: `hi ${args.name}` }),
1039
+ })
1040
+
1041
+ const { output, exitCode } = await serve(cli, ['greet', '--help'])
1042
+ expect(exitCode).toBeUndefined()
1043
+ expect(output).toMatchInlineSnapshot(`
1044
+ "tool greet — Greet someone
1045
+
1046
+ Usage: tool greet <name>
1047
+
1048
+ Arguments:
1049
+ name Name
1050
+
1051
+ Global Options:
1052
+ --format <toon|json|yaml|md|jsonl> Output format
1053
+ --help Show help
1054
+ --llms Print LLM-readable manifest
1055
+ --verbose Show full output envelope
1056
+ "
1057
+ `)
1058
+ })
1059
+
1060
+ test('group with no subcommand shows help', async () => {
1061
+ const pr = Cli.create('pr', { description: 'Pull request commands' })
1062
+ pr.command('list', {
1063
+ description: 'List PRs',
1064
+ run: () => ({}),
1065
+ })
1066
+
1067
+ const cli = Cli.create('gh')
1068
+ cli.command(pr)
1069
+
1070
+ const { output, exitCode } = await serve(cli, ['pr'])
1071
+ expect(exitCode).toBeUndefined()
1072
+ expect(output).toMatchInlineSnapshot(`
1073
+ "gh pr — Pull request commands
1074
+
1075
+ Usage: gh pr <command>
1076
+
1077
+ Commands:
1078
+ list List PRs
1079
+
1080
+ Global Options:
1081
+ --format <toon|json|yaml|md|jsonl> Output format
1082
+ --help Show help
1083
+ --llms Print LLM-readable manifest
1084
+ --verbose Show full output envelope
1085
+ "
1086
+ `)
1087
+ })
1088
+
1089
+ test('--version outputs version string', async () => {
1090
+ const cli = Cli.create('tool', { version: '1.2.3' })
1091
+ cli.command('ping', { run: () => ({}) })
1092
+
1093
+ const { output, exitCode } = await serve(cli, ['--version'])
1094
+ expect(exitCode).toBeUndefined()
1095
+ expect(output).toMatchInlineSnapshot(`
1096
+ "1.2.3
1097
+ "
1098
+ `)
1099
+ })
1100
+
1101
+ test('--help takes precedence over --version', async () => {
1102
+ const cli = Cli.create('tool', { version: '1.2.3' })
1103
+ cli.command('ping', { description: 'Ping', run: () => ({}) })
1104
+
1105
+ const { output } = await serve(cli, ['--help', '--version'])
1106
+ expect(output).toMatchInlineSnapshot(`
1107
+ "tool
1108
+ v1.2.3
1109
+
1110
+ Usage: tool <command>
1111
+
1112
+ Commands:
1113
+ ping Ping
1114
+
1115
+ Built-in Commands:
1116
+ mcp add Register as an MCP server
1117
+ skills add Sync skill files to your agent
1118
+
1119
+ Global Options:
1120
+ --format <toon|json|yaml|md|jsonl> Output format
1121
+ --help Show help
1122
+ --llms Print LLM-readable manifest
1123
+ --mcp Start as MCP stdio server
1124
+ --verbose Show full output envelope
1125
+ --version Show version
1126
+ "
1127
+ `)
1128
+ })
1129
+
1130
+ test('--help shows hint after examples', async () => {
1131
+ const cli = Cli.create('tool')
1132
+ cli.command('deploy', {
1133
+ description: 'Deploy the app',
1134
+ hint: 'Run "tool status" to check deployment progress.',
1135
+ run: () => ({ ok: true }),
1136
+ })
1137
+
1138
+ const { output } = await serve(cli, ['deploy', '--help'])
1139
+ expect(output).toMatchInlineSnapshot(`
1140
+ "tool deploy — Deploy the app
1141
+
1142
+ Usage: tool deploy
1143
+
1144
+ Run "tool status" to check deployment progress.
1145
+
1146
+ Global Options:
1147
+ --format <toon|json|yaml|md|jsonl> Output format
1148
+ --help Show help
1149
+ --llms Print LLM-readable manifest
1150
+ --verbose Show full output envelope
1151
+ "
1152
+ `)
1153
+ })
1154
+
1155
+ test('--help omits hint when not set', async () => {
1156
+ const cli = Cli.create('tool')
1157
+ cli.command('ping', {
1158
+ description: 'Health check',
1159
+ run: () => ({ pong: true }),
1160
+ })
1161
+
1162
+ const { output } = await serve(cli, ['ping', '--help'])
1163
+ expect(output).not.toContain('hint')
1164
+ })
1165
+ })
1166
+
1167
+ describe('env', () => {
1168
+ test('parses env vars and passes to handler', async () => {
1169
+ const cli = Cli.create('test')
1170
+ let receivedEnv: any
1171
+ cli.command('deploy', {
1172
+ env: z.object({
1173
+ API_TOKEN: z.string().describe('Auth token'),
1174
+ }),
1175
+ run({ env }) {
1176
+ receivedEnv = env
1177
+ return { ok: true }
1178
+ },
1179
+ })
1180
+
1181
+ await serve(cli, ['deploy'], { env: { API_TOKEN: 'secret-123' } })
1182
+ expect(receivedEnv).toEqual({ API_TOKEN: 'secret-123' })
1183
+ })
1184
+
1185
+ test('env validation error for missing required var', async () => {
1186
+ const cli = Cli.create('test')
1187
+ cli.command('deploy', {
1188
+ env: z.object({
1189
+ API_TOKEN: z.string().describe('Auth token'),
1190
+ }),
1191
+ run() {
1192
+ return {}
1193
+ },
1194
+ })
1195
+
1196
+ const { output, exitCode } = await serve(cli, ['deploy'], { env: {} })
1197
+ expect(exitCode).toBe(1)
1198
+ expect(output).toContain('Error')
1199
+ })
1200
+
1201
+ test('env with defaults works when var is unset', async () => {
1202
+ const cli = Cli.create('test')
1203
+ let receivedEnv: any
1204
+ cli.command('deploy', {
1205
+ env: z.object({
1206
+ API_URL: z.string().default('https://api.example.com').describe('API URL'),
1207
+ }),
1208
+ run({ env }) {
1209
+ receivedEnv = env
1210
+ return { ok: true }
1211
+ },
1212
+ })
1213
+
1214
+ await serve(cli, ['deploy'], { env: {} })
1215
+ expect(receivedEnv).toEqual({ API_URL: 'https://api.example.com' })
1216
+ })
1217
+
1218
+ test('--help shows environment variables section', async () => {
1219
+ const cli = Cli.create('test')
1220
+ cli.command('deploy', {
1221
+ env: z.object({
1222
+ API_TOKEN: z.string().describe('Auth token'),
1223
+ API_URL: z.string().default('https://api.example.com').describe('API URL'),
1224
+ }),
1225
+ run() {
1226
+ return {}
1227
+ },
1228
+ })
1229
+
1230
+ const { output } = await serve(cli, ['deploy', '--help'])
1231
+ expect(output).toMatchInlineSnapshot(`
1232
+ "test deploy
1233
+
1234
+ Usage: test deploy
1235
+
1236
+ Environment Variables:
1237
+ API_TOKEN Auth token
1238
+ API_URL API URL (default: https://api.example.com)
1239
+
1240
+ Global Options:
1241
+ --format <toon|json|yaml|md|jsonl> Output format
1242
+ --help Show help
1243
+ --llms Print LLM-readable manifest
1244
+ --verbose Show full output envelope
1245
+ "
1246
+ `)
1247
+ })
1248
+
1249
+ test('--llms json includes schema.env', async () => {
1250
+ const cli = Cli.create('test')
1251
+ cli.command('deploy', {
1252
+ env: z.object({
1253
+ API_TOKEN: z.string().describe('Auth token'),
1254
+ }),
1255
+ run() {
1256
+ return {}
1257
+ },
1258
+ })
1259
+
1260
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
1261
+ const cmd = JSON.parse(output).commands.find((c: any) => c.name === 'deploy')
1262
+ expect(cmd.schema.env).toMatchInlineSnapshot(`
1263
+ {
1264
+ "additionalProperties": false,
1265
+ "properties": {
1266
+ "API_TOKEN": {
1267
+ "description": "Auth token",
1268
+ "type": "string",
1269
+ },
1270
+ },
1271
+ "required": [
1272
+ "API_TOKEN",
1273
+ ],
1274
+ "type": "object",
1275
+ }
1276
+ `)
1277
+ })
1278
+
1279
+ test('--llms markdown includes environment variables table', async () => {
1280
+ const cli = Cli.create('test')
1281
+ cli.command('deploy', {
1282
+ env: z.object({
1283
+ API_TOKEN: z.string().describe('Auth token'),
1284
+ }),
1285
+ run() {
1286
+ return {}
1287
+ },
1288
+ })
1289
+
1290
+ const { output } = await serve(cli, ['--llms'])
1291
+ expect(output).toContain('Environment Variables')
1292
+ expect(output).toContain('`API_TOKEN`')
1293
+ })
1294
+
1295
+ test('env coerces boolean and number values', async () => {
1296
+ const cli = Cli.create('test')
1297
+ let receivedEnv: any
1298
+ cli.command('deploy', {
1299
+ env: z.object({
1300
+ DEBUG: z.boolean().default(false).describe('Debug mode'),
1301
+ PORT: z.number().default(3000).describe('Port'),
1302
+ }),
1303
+ run({ env }) {
1304
+ receivedEnv = env
1305
+ return { ok: true }
1306
+ },
1307
+ })
1308
+
1309
+ await serve(cli, ['deploy'], { env: { DEBUG: 'true', PORT: '8080' } })
1310
+ expect(receivedEnv).toEqual({ DEBUG: true, PORT: 8080 })
1311
+ })
1312
+ })
1313
+
1314
+ describe('skills staleness', () => {
1315
+ let stderrSpy: ReturnType<typeof vi.spyOn>
1316
+
1317
+ beforeEach(() => {
1318
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
1319
+ __mockSkillsHash = undefined
1320
+ })
1321
+
1322
+ afterEach(() => {
1323
+ stderrSpy.mockRestore()
1324
+ })
1325
+
1326
+ test('warns on stderr when skills are stale', async () => {
1327
+ __mockSkillsHash = '0000000000000000'
1328
+ const cli = Cli.create('test')
1329
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1330
+
1331
+ await serve(cli, ['ping'])
1332
+ expect(stderrSpy).toHaveBeenCalledWith(
1333
+ expect.stringContaining("Skills are out of date. Run 'pnpx test skills add' to update."),
1334
+ )
1335
+ })
1336
+
1337
+ test('does not warn when hash matches', async () => {
1338
+ const { Skill } = await import('incur')
1339
+ __mockSkillsHash = Skill.hash([{ name: 'ping', description: 'Health check' }])
1340
+ const cli = Cli.create('test')
1341
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
1342
+
1343
+ await serve(cli, ['ping'])
1344
+ expect(stderrSpy).not.toHaveBeenCalled()
1345
+ })
1346
+
1347
+ test('does not warn when no hash stored', async () => {
1348
+ __mockSkillsHash = undefined
1349
+ const cli = Cli.create('test')
1350
+ cli.command('ping', { run: () => ({ pong: true }) })
1351
+
1352
+ await serve(cli, ['ping'])
1353
+ expect(stderrSpy).not.toHaveBeenCalled()
1354
+ })
1355
+
1356
+ test('does not warn for skills add', async () => {
1357
+ __mockSkillsHash = '0000000000000000'
1358
+ const cli = Cli.create('test')
1359
+ cli.command('ping', { run: () => ({ pong: true }) })
1360
+
1361
+ await serve(cli, ['skills', 'add'])
1362
+ expect(stderrSpy).not.toHaveBeenCalled()
1363
+ })
1364
+
1365
+ test('does not warn for --help', async () => {
1366
+ __mockSkillsHash = '0000000000000000'
1367
+ const cli = Cli.create('test')
1368
+ cli.command('ping', { run: () => ({ pong: true }) })
1369
+
1370
+ await serve(cli, ['--help'])
1371
+ expect(stderrSpy).not.toHaveBeenCalled()
1372
+ })
1373
+ })