incur 0.4.8 → 0.4.10

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 (88) hide show
  1. package/dist/Cli.d.ts +69 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +419 -73
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +44 -13
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Parser.d.ts +14 -0
  21. package/dist/Parser.d.ts.map +1 -1
  22. package/dist/Parser.js +127 -1
  23. package/dist/Parser.js.map +1 -1
  24. package/dist/Skill.d.ts.map +1 -1
  25. package/dist/Skill.js +5 -2
  26. package/dist/Skill.js.map +1 -1
  27. package/dist/Skillgen.d.ts.map +1 -1
  28. package/dist/Skillgen.js +4 -38
  29. package/dist/Skillgen.js.map +1 -1
  30. package/dist/SyncMcp.d.ts.map +1 -1
  31. package/dist/SyncMcp.js +75 -8
  32. package/dist/SyncMcp.js.map +1 -1
  33. package/dist/SyncSkills.d.ts +9 -0
  34. package/dist/SyncSkills.d.ts.map +1 -1
  35. package/dist/SyncSkills.js +13 -74
  36. package/dist/SyncSkills.js.map +1 -1
  37. package/dist/bin.d.ts +1 -1
  38. package/dist/bin.d.ts.map +1 -1
  39. package/dist/internal/command.d.ts +31 -19
  40. package/dist/internal/command.d.ts.map +1 -1
  41. package/dist/internal/command.js +10 -5
  42. package/dist/internal/command.js.map +1 -1
  43. package/dist/internal/cta.d.ts +22 -0
  44. package/dist/internal/cta.d.ts.map +1 -0
  45. package/dist/internal/cta.js +25 -0
  46. package/dist/internal/cta.js.map +1 -0
  47. package/dist/internal/json.d.ts +5 -0
  48. package/dist/internal/json.d.ts.map +1 -0
  49. package/dist/internal/json.js +13 -0
  50. package/dist/internal/json.js.map +1 -0
  51. package/dist/internal/yaml.d.ts +12 -0
  52. package/dist/internal/yaml.d.ts.map +1 -0
  53. package/dist/internal/yaml.js +20 -0
  54. package/dist/internal/yaml.js.map +1 -0
  55. package/dist/middleware.d.ts +8 -4
  56. package/dist/middleware.d.ts.map +1 -1
  57. package/dist/middleware.js +1 -1
  58. package/dist/middleware.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/Cli.test-d.ts +73 -0
  61. package/src/Cli.test.ts +1536 -48
  62. package/src/Cli.ts +606 -108
  63. package/src/Completions.test.ts +85 -0
  64. package/src/Completions.ts +48 -9
  65. package/src/Formatter.test.ts +17 -0
  66. package/src/Formatter.ts +7 -5
  67. package/src/Help.test.ts +32 -1
  68. package/src/Help.ts +42 -4
  69. package/src/Mcp.test.ts +289 -0
  70. package/src/Mcp.ts +68 -13
  71. package/src/Parser.test.ts +173 -0
  72. package/src/Parser.ts +132 -1
  73. package/src/Skill.test.ts +6 -0
  74. package/src/Skill.ts +8 -5
  75. package/src/Skillgen.test.ts +55 -6
  76. package/src/Skillgen.ts +9 -35
  77. package/src/SyncMcp.test.ts +89 -0
  78. package/src/SyncMcp.ts +72 -8
  79. package/src/SyncSkills.test.ts +78 -1
  80. package/src/SyncSkills.ts +20 -78
  81. package/src/bun-compile.test.ts +5 -0
  82. package/src/e2e.test.ts +166 -12
  83. package/src/internal/command.ts +12 -4
  84. package/src/internal/cta.ts +58 -0
  85. package/src/internal/json.ts +16 -0
  86. package/src/internal/yaml.ts +23 -0
  87. package/src/lazy-imports.test.ts +94 -0
  88. package/src/middleware.ts +12 -3
package/src/Cli.test.ts CHANGED
@@ -1,8 +1,11 @@
1
- import { Cli, Errors, z } from 'incur'
1
+ import { Cli, Errors, Mcp, z } from 'incur'
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { homedir, tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
+ import * as Command from './internal/command.js'
7
+ import * as SyncMcp from './SyncMcp.js'
8
+
6
9
  const originalIsTTY = process.stdout.isTTY
7
10
  beforeAll(() => {
8
11
  ;(process.stdout as any).isTTY = false
@@ -45,6 +48,15 @@ async function serve(
45
48
  }
46
49
  }
47
50
 
51
+ function mockMcpServeResponses(responses: unknown[]) {
52
+ return vi.spyOn(Mcp, 'serve').mockImplementation(async (_name, _version, _commands, options) => {
53
+ for (const response of responses)
54
+ options!.output?.write(
55
+ `${typeof response === 'string' ? response : JSON.stringify(response)}\n`,
56
+ )
57
+ })
58
+ }
59
+
48
60
  function createConfigCli(flag?: string) {
49
61
  const project = Cli.create('project').command('list', {
50
62
  options: z.object({
@@ -1385,8 +1397,9 @@ describe('--llms', () => {
1385
1397
  cli.command('ping', { description: 'Health check', run: () => ({}) })
1386
1398
 
1387
1399
  const { output } = await serve(cli, ['auth', '--llms'])
1388
- expect(output).toContain('test auth auth login')
1389
- expect(output).toContain('test auth auth logout')
1400
+ expect(output).toContain('test auth login')
1401
+ expect(output).toContain('test auth logout')
1402
+ expect(output).not.toContain('test auth auth') // no doubled namespace
1390
1403
  expect(output).not.toContain('ping')
1391
1404
  })
1392
1405
 
@@ -1421,6 +1434,18 @@ describe('--llms', () => {
1421
1434
  expect(output).toContain('# my-cli auth')
1422
1435
  expect(output).not.toContain('# my-cli \n')
1423
1436
  })
1437
+
1438
+ test('scoped json index keeps full command paths', async () => {
1439
+ const cli = Cli.create('test')
1440
+ const group = Cli.create('auth', { description: 'Authentication' })
1441
+ group.command('login', { description: 'Log in', run: () => ({}) })
1442
+ group.command('logout', { description: 'Log out', run: () => ({}) })
1443
+ cli.command(group)
1444
+
1445
+ const { output } = await serve(cli, ['auth', '--llms', '--format', 'json'])
1446
+ const manifest = JSON.parse(output)
1447
+ expect(manifest.commands.map((c: any) => c.name).sort()).toEqual(['auth login', 'auth logout'])
1448
+ })
1424
1449
  })
1425
1450
 
1426
1451
  describe('--schema', () => {
@@ -2163,7 +2188,7 @@ describe('help', () => {
2163
2188
 
2164
2189
  Integrations:
2165
2190
  completions Generate shell completion script
2166
- mcp add Register as MCP server
2191
+ mcp Register as MCP server (add, doctor)
2167
2192
  skills Sync skill files to agents (add, list)
2168
2193
 
2169
2194
  Global Options:
@@ -2201,7 +2226,7 @@ describe('help', () => {
2201
2226
 
2202
2227
  Integrations:
2203
2228
  completions Generate shell completion script
2204
- mcp add Register as MCP server
2229
+ mcp Register as MCP server (add, doctor)
2205
2230
  skills Sync skill files to agents (add, list)
2206
2231
 
2207
2232
  Global Options:
@@ -2220,6 +2245,129 @@ describe('help', () => {
2220
2245
  `)
2221
2246
  })
2222
2247
 
2248
+ test('banner is printed before root help', async () => {
2249
+ const cli = Cli.create({
2250
+ name: 'mycli',
2251
+ banner: () => ' status: all good',
2252
+ })
2253
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2254
+
2255
+ const { output } = await serve(cli, [])
2256
+ expect(output).toContain('status: all good')
2257
+ expect(output.indexOf('status: all good')).toBeLessThan(output.indexOf('mycli'))
2258
+ })
2259
+
2260
+ test('async banner is supported', async () => {
2261
+ const cli = Cli.create({
2262
+ name: 'mycli',
2263
+ banner: async () => ' async banner',
2264
+ })
2265
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2266
+
2267
+ const { output } = await serve(cli, [])
2268
+ expect(output).toContain('async banner')
2269
+ })
2270
+
2271
+ test('banner returning undefined shows only help', async () => {
2272
+ const cli = Cli.create({
2273
+ name: 'mycli',
2274
+ banner: () => undefined,
2275
+ })
2276
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2277
+
2278
+ const { output } = await serve(cli, [])
2279
+ expect(output).toMatch(/^mycli/)
2280
+ })
2281
+
2282
+ test('banner errors are swallowed', async () => {
2283
+ const cli = Cli.create({
2284
+ name: 'mycli',
2285
+ banner: () => {
2286
+ throw new Error('boom')
2287
+ },
2288
+ })
2289
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2290
+
2291
+ const { output } = await serve(cli, [])
2292
+ expect(output).toMatch(/^mycli/)
2293
+ expect(output).not.toContain('boom')
2294
+ })
2295
+
2296
+ test('banner is skipped for subcommands', async () => {
2297
+ const cli = Cli.create({
2298
+ name: 'mycli',
2299
+ banner: () => 'BANNER',
2300
+ })
2301
+ cli.command('ping', {
2302
+ description: 'Health check',
2303
+ run: () => ({ pong: true }),
2304
+ output: z.object({ pong: z.boolean() }),
2305
+ })
2306
+
2307
+ const { output } = await serve(cli, ['ping'])
2308
+ expect(output).not.toContain('BANNER')
2309
+ })
2310
+
2311
+ test('banner with mode "agent" shows in non-TTY', async () => {
2312
+ const cli = Cli.create({
2313
+ name: 'mycli',
2314
+ banner: { render: () => 'AGENT BANNER', mode: 'agent' },
2315
+ })
2316
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2317
+
2318
+ const { output } = await serve(cli, [])
2319
+ expect(output).toContain('AGENT BANNER')
2320
+ })
2321
+
2322
+ test('banner with mode "human" is skipped in non-TTY', async () => {
2323
+ const cli = Cli.create({
2324
+ name: 'mycli',
2325
+ banner: { render: () => 'HUMAN BANNER', mode: 'human' },
2326
+ })
2327
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2328
+
2329
+ const { output } = await serve(cli, [])
2330
+ expect(output).not.toContain('HUMAN BANNER')
2331
+ })
2332
+
2333
+ test('banner object with default mode shows for all', async () => {
2334
+ const cli = Cli.create({
2335
+ name: 'mycli',
2336
+ banner: { render: () => 'ALL BANNER' },
2337
+ })
2338
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2339
+
2340
+ const { output } = await serve(cli, [])
2341
+ expect(output).toContain('ALL BANNER')
2342
+ })
2343
+
2344
+ test('banner is skipped for --help flag', async () => {
2345
+ const cli = Cli.create({
2346
+ name: 'mycli',
2347
+ banner: () => 'BANNER',
2348
+ })
2349
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2350
+
2351
+ const { output } = await serve(cli, ['--help'])
2352
+ expect(output).not.toContain('BANNER')
2353
+ })
2354
+
2355
+ test('banner is printed before root command help for missing required args', async () => {
2356
+ ;(process.stdout as any).isTTY = true
2357
+ const cli = Cli.create('fetch', {
2358
+ banner: () => 'BANNER',
2359
+ description: 'Fetch a URL',
2360
+ args: z.object({ url: z.string().describe('URL to fetch') }),
2361
+ run: ({ args }) => args.url,
2362
+ })
2363
+
2364
+ const { output, exitCode } = await serve(cli, [])
2365
+ ;(process.stdout as any).isTTY = false
2366
+ expect(exitCode).toBeUndefined()
2367
+ expect(output).toContain('BANNER')
2368
+ expect(output.indexOf('BANNER')).toBeLessThan(output.indexOf('fetch — Fetch a URL'))
2369
+ })
2370
+
2223
2371
  test('--help on leaf shows command help', async () => {
2224
2372
  const cli = Cli.create('tool')
2225
2373
  cli.command('greet', {
@@ -2364,7 +2512,7 @@ describe('help', () => {
2364
2512
 
2365
2513
  Integrations:
2366
2514
  completions Generate shell completion script
2367
- mcp add Register as MCP server
2515
+ mcp Register as MCP server (add, doctor)
2368
2516
  skills Sync skill files to agents (add, list)
2369
2517
 
2370
2518
  Global Options:
@@ -2694,6 +2842,7 @@ describe('built-in commands', () => {
2694
2842
  expect(output).toContain('test mcp')
2695
2843
  expect(output).toContain('Register as MCP server')
2696
2844
  expect(output).toContain('add')
2845
+ expect(output).toContain('doctor')
2697
2846
  })
2698
2847
 
2699
2848
  test('mcp --help shows help with subcommands', async () => {
@@ -2702,6 +2851,7 @@ describe('built-in commands', () => {
2702
2851
  const { output } = await serve(cli, ['mcp', '--help'])
2703
2852
  expect(output).toContain('test mcp')
2704
2853
  expect(output).toContain('add')
2854
+ expect(output).toContain('doctor')
2705
2855
  })
2706
2856
 
2707
2857
  test('mcp add --help shows options', async () => {
@@ -2714,6 +2864,364 @@ describe('built-in commands', () => {
2714
2864
  expect(output).toContain('--agent')
2715
2865
  })
2716
2866
 
2867
+ test('mcp add forwards command, agent, and global flags', async () => {
2868
+ const spy = vi
2869
+ .spyOn(SyncMcp, 'register')
2870
+ .mockResolvedValue({ command: 'pnpm test --mcp', agents: ['Cursor'] })
2871
+ try {
2872
+ const cli = Cli.create('test', { sync: { suggestions: ['Check health'] } })
2873
+ cli.command('ping', { run: () => ({ pong: true }) })
2874
+ const { output, exitCode } = await serve(cli, [
2875
+ 'mcp',
2876
+ 'add',
2877
+ '--no-global',
2878
+ '-c',
2879
+ 'pnpm test --mcp',
2880
+ '--agent',
2881
+ 'cursor',
2882
+ '--json',
2883
+ ])
2884
+ expect(exitCode).toBeUndefined()
2885
+ expect(spy).toHaveBeenCalledWith('test', {
2886
+ command: 'pnpm test --mcp',
2887
+ global: false,
2888
+ agents: ['cursor'],
2889
+ })
2890
+ expect(output).toContain('Registered test as MCP server')
2891
+ expect(output).toContain('Try asking:')
2892
+ expect(output).toContain('"Check health"')
2893
+ expect(output).toContain('"command": "pnpm test --mcp"')
2894
+ } finally {
2895
+ spy.mockRestore()
2896
+ }
2897
+ })
2898
+
2899
+ test('mcp add exits nonzero when registration fails', async () => {
2900
+ const spy = vi.spyOn(SyncMcp, 'register').mockRejectedValue('register failed')
2901
+ try {
2902
+ const cli = Cli.create('test')
2903
+ cli.command('ping', { run: () => ({ pong: true }) })
2904
+ const { output, exitCode } = await serve(cli, ['mcp', 'add', '--json'])
2905
+ expect(exitCode).toBe(1)
2906
+ expect(output).toContain('Registering MCP server...')
2907
+ expect(JSON.parse(output.slice(output.indexOf('{')))).toEqual({
2908
+ code: 'MCP_ADD_FAILED',
2909
+ message: 'register failed',
2910
+ })
2911
+ } finally {
2912
+ spy.mockRestore()
2913
+ }
2914
+ })
2915
+
2916
+ test('mcp doctor --help shows description', async () => {
2917
+ const cli = Cli.create('test')
2918
+ cli.command('ping', { run: () => ({ pong: true }) })
2919
+ const { output } = await serve(cli, ['mcp', 'doctor', '--help'])
2920
+ expect(output).toContain('test mcp doctor')
2921
+ expect(output).toContain('Validate MCP server startup and tool listing')
2922
+ })
2923
+
2924
+ test('mcp doctor lists tools', async () => {
2925
+ const cli = Cli.create('test', { version: '1.0.0' })
2926
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2927
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
2928
+ const result = JSON.parse(output)
2929
+ expect(exitCode).toBeUndefined()
2930
+ expect(result).toEqual({
2931
+ ok: true,
2932
+ toolCount: 1,
2933
+ tools: [{ name: 'ping', description: 'Health check' }],
2934
+ warnings: [],
2935
+ errors: [],
2936
+ })
2937
+ })
2938
+
2939
+ test('mcp doctor forwards MCP instructions to the smoke test server', async () => {
2940
+ const spy = mockMcpServeResponses([
2941
+ {
2942
+ jsonrpc: '2.0',
2943
+ id: 1,
2944
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
2945
+ },
2946
+ { jsonrpc: '2.0', id: 2, result: { tools: [] } },
2947
+ ])
2948
+ try {
2949
+ const cli = Cli.create('test', {
2950
+ version: '2.0.0',
2951
+ mcp: { instructions: 'Use read-only commands first.' },
2952
+ })
2953
+ cli.command('ping', { run: () => ({ pong: true }) })
2954
+ const { exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
2955
+ expect(exitCode).toBeUndefined()
2956
+ expect(spy).toHaveBeenCalledWith(
2957
+ 'test',
2958
+ '2.0.0',
2959
+ expect.any(Map),
2960
+ expect.objectContaining({
2961
+ version: '2.0.0',
2962
+ instructions: 'Use read-only commands first.',
2963
+ }),
2964
+ )
2965
+ } finally {
2966
+ spy.mockRestore()
2967
+ }
2968
+ })
2969
+
2970
+ test('mcp doctor does not call tools', async () => {
2971
+ let calls = 0
2972
+ const cli = Cli.create('test')
2973
+ cli.command('mutate', {
2974
+ run() {
2975
+ calls++
2976
+ return { ok: true }
2977
+ },
2978
+ })
2979
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
2980
+ expect(exitCode).toBeUndefined()
2981
+ expect(JSON.parse(output)).toMatchObject({ ok: true, toolCount: 1 })
2982
+ expect(calls).toBe(0)
2983
+ })
2984
+
2985
+ test('mcp doctor warns when no tools are exposed', async () => {
2986
+ const spy = mockMcpServeResponses([
2987
+ {
2988
+ jsonrpc: '2.0',
2989
+ id: 1,
2990
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
2991
+ },
2992
+ { jsonrpc: '2.0', id: 2, result: { tools: [] } },
2993
+ ])
2994
+ try {
2995
+ const cli = Cli.create('test')
2996
+ cli.command('ping', { run: () => ({ pong: true }) })
2997
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
2998
+ expect(exitCode).toBeUndefined()
2999
+ expect(JSON.parse(output)).toEqual({
3000
+ ok: true,
3001
+ toolCount: 0,
3002
+ tools: [],
3003
+ warnings: ['No MCP tools exposed.'],
3004
+ errors: [],
3005
+ })
3006
+ } finally {
3007
+ spy.mockRestore()
3008
+ }
3009
+ })
3010
+
3011
+ test('mcp doctor filters malformed tools from tools/list', async () => {
3012
+ const spy = mockMcpServeResponses([
3013
+ {
3014
+ jsonrpc: '2.0',
3015
+ id: 1,
3016
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
3017
+ },
3018
+ {
3019
+ jsonrpc: '2.0',
3020
+ id: 2,
3021
+ result: {
3022
+ tools: [
3023
+ null,
3024
+ { name: 123, description: 'bad name' },
3025
+ { name: 'without_description', description: 123 },
3026
+ { name: 'with_description', description: 'Useful tool' },
3027
+ ],
3028
+ },
3029
+ },
3030
+ ])
3031
+ try {
3032
+ const cli = Cli.create('test')
3033
+ cli.command('ping', { run: () => ({ pong: true }) })
3034
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3035
+ expect(exitCode).toBeUndefined()
3036
+ expect(JSON.parse(output)).toEqual({
3037
+ ok: true,
3038
+ toolCount: 2,
3039
+ tools: [
3040
+ { name: 'without_description' },
3041
+ { name: 'with_description', description: 'Useful tool' },
3042
+ ],
3043
+ warnings: [],
3044
+ errors: [],
3045
+ })
3046
+ } finally {
3047
+ spy.mockRestore()
3048
+ }
3049
+ })
3050
+
3051
+ test('mcp doctor exits nonzero when MCP server fails', async () => {
3052
+ const spy = vi.spyOn(Mcp, 'serve').mockRejectedValue(new Error('boom'))
3053
+ try {
3054
+ const cli = Cli.create('test')
3055
+ cli.command('ping', { run: () => ({ pong: true }) })
3056
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3057
+ expect(exitCode).toBe(1)
3058
+ expect(JSON.parse(output)).toEqual({
3059
+ ok: false,
3060
+ toolCount: 0,
3061
+ tools: [],
3062
+ warnings: [],
3063
+ errors: [{ code: 'MCP_SERVER_FAILED', message: 'boom' }],
3064
+ })
3065
+ } finally {
3066
+ spy.mockRestore()
3067
+ }
3068
+ })
3069
+
3070
+ test('mcp doctor stringifies non-error MCP server failures', async () => {
3071
+ const spy = vi.spyOn(Mcp, 'serve').mockRejectedValue('boom')
3072
+ try {
3073
+ const cli = Cli.create('test')
3074
+ cli.command('ping', { run: () => ({ pong: true }) })
3075
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3076
+ expect(exitCode).toBe(1)
3077
+ expect(JSON.parse(output).errors).toEqual([{ code: 'MCP_SERVER_FAILED', message: 'boom' }])
3078
+ } finally {
3079
+ spy.mockRestore()
3080
+ }
3081
+ })
3082
+
3083
+ test('mcp doctor exits nonzero when MCP response cannot be parsed', async () => {
3084
+ const spy = mockMcpServeResponses(['{bad json}'])
3085
+ try {
3086
+ const cli = Cli.create('test')
3087
+ cli.command('ping', { run: () => ({ pong: true }) })
3088
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3089
+ expect(exitCode).toBe(1)
3090
+ expect(JSON.parse(output)).toMatchObject({
3091
+ ok: false,
3092
+ toolCount: 0,
3093
+ tools: [],
3094
+ warnings: [],
3095
+ errors: [{ code: 'MCP_RESPONSE_PARSE_FAILED' }],
3096
+ })
3097
+ } finally {
3098
+ spy.mockRestore()
3099
+ }
3100
+ })
3101
+
3102
+ test('mcp doctor exits nonzero when an MCP response is not an object', async () => {
3103
+ const spy = mockMcpServeResponses([null])
3104
+ try {
3105
+ const cli = Cli.create('test')
3106
+ cli.command('ping', { run: () => ({ pong: true }) })
3107
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3108
+ expect(exitCode).toBe(1)
3109
+ expect(JSON.parse(output)).toMatchObject({
3110
+ ok: false,
3111
+ errors: [
3112
+ {
3113
+ code: 'MCP_RESPONSE_PARSE_FAILED',
3114
+ message: 'Expected JSON-RPC response object.',
3115
+ },
3116
+ ],
3117
+ })
3118
+ } finally {
3119
+ spy.mockRestore()
3120
+ }
3121
+ })
3122
+
3123
+ test('mcp doctor exits nonzero when initialize response is missing', async () => {
3124
+ const spy = mockMcpServeResponses([{ jsonrpc: '2.0', id: 2, result: { tools: [] } }])
3125
+ try {
3126
+ const cli = Cli.create('test')
3127
+ cli.command('ping', { run: () => ({ pong: true }) })
3128
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3129
+ expect(exitCode).toBe(1)
3130
+ expect(JSON.parse(output).errors).toEqual([
3131
+ { code: 'MCP_INITIALIZE_MISSING', message: 'Missing initialize response.' },
3132
+ ])
3133
+ } finally {
3134
+ spy.mockRestore()
3135
+ }
3136
+ })
3137
+
3138
+ test('mcp doctor exits nonzero when initialize fails', async () => {
3139
+ const spy = mockMcpServeResponses([
3140
+ { jsonrpc: '2.0', id: 1, error: 'initialize failed' },
3141
+ { jsonrpc: '2.0', id: 2, result: { tools: [] } },
3142
+ ])
3143
+ try {
3144
+ const cli = Cli.create('test')
3145
+ cli.command('ping', { run: () => ({ pong: true }) })
3146
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3147
+ expect(exitCode).toBe(1)
3148
+ expect(JSON.parse(output).errors).toEqual([
3149
+ { code: 'MCP_INITIALIZE_FAILED', message: '"initialize failed"' },
3150
+ ])
3151
+ } finally {
3152
+ spy.mockRestore()
3153
+ }
3154
+ })
3155
+
3156
+ test('mcp doctor exits nonzero when tools/list response is missing', async () => {
3157
+ const spy = mockMcpServeResponses([
3158
+ {
3159
+ jsonrpc: '2.0',
3160
+ id: 1,
3161
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
3162
+ },
3163
+ ])
3164
+ try {
3165
+ const cli = Cli.create('test')
3166
+ cli.command('ping', { run: () => ({ pong: true }) })
3167
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3168
+ expect(exitCode).toBe(1)
3169
+ expect(JSON.parse(output).errors).toEqual([
3170
+ { code: 'MCP_TOOLS_LIST_MISSING', message: 'Missing tools/list response.' },
3171
+ ])
3172
+ } finally {
3173
+ spy.mockRestore()
3174
+ }
3175
+ })
3176
+
3177
+ test('mcp doctor exits nonzero when tools/list fails', async () => {
3178
+ const spy = mockMcpServeResponses([
3179
+ {
3180
+ jsonrpc: '2.0',
3181
+ id: 1,
3182
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
3183
+ },
3184
+ { jsonrpc: '2.0', id: 2, error: { code: -32603, message: 'list failed' } },
3185
+ ])
3186
+ try {
3187
+ const cli = Cli.create('test')
3188
+ cli.command('ping', { run: () => ({ pong: true }) })
3189
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3190
+ expect(exitCode).toBe(1)
3191
+ expect(JSON.parse(output)).toEqual({
3192
+ ok: false,
3193
+ toolCount: 0,
3194
+ tools: [],
3195
+ warnings: [],
3196
+ errors: [{ code: 'MCP_TOOLS_LIST_FAILED', message: 'list failed' }],
3197
+ })
3198
+ } finally {
3199
+ spy.mockRestore()
3200
+ }
3201
+ })
3202
+
3203
+ test('mcp doctor exits nonzero when tools/list has an invalid shape', async () => {
3204
+ const spy = mockMcpServeResponses([
3205
+ {
3206
+ jsonrpc: '2.0',
3207
+ id: 1,
3208
+ result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: {} },
3209
+ },
3210
+ { jsonrpc: '2.0', id: 2, result: { tools: 'invalid' } },
3211
+ ])
3212
+ try {
3213
+ const cli = Cli.create('test')
3214
+ cli.command('ping', { run: () => ({ pong: true }) })
3215
+ const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json'])
3216
+ expect(exitCode).toBe(1)
3217
+ expect(JSON.parse(output).errors).toEqual([
3218
+ { code: 'MCP_TOOLS_LIST_INVALID', message: 'tools/list did not return a tools array.' },
3219
+ ])
3220
+ } finally {
3221
+ spy.mockRestore()
3222
+ }
3223
+ })
3224
+
2717
3225
  test('bare skills shows help with subcommands', async () => {
2718
3226
  const cli = Cli.create('test')
2719
3227
  cli.command('ping', { run: () => ({ pong: true }) })
@@ -2750,6 +3258,22 @@ describe('built-in commands', () => {
2750
3258
  expect(output).toContain('test mcp add')
2751
3259
  })
2752
3260
 
3261
+ test('mcp typo shows human suggestions in TTY', async () => {
3262
+ const previous = process.stdout.isTTY
3263
+ ;(process.stdout as any).isTTY = true
3264
+ try {
3265
+ const cli = Cli.create('test')
3266
+ cli.command('ping', { run: () => ({}) })
3267
+ const { output, exitCode } = await serve(cli, ['mcp', 'zzzz'])
3268
+ expect(exitCode).toBe(1)
3269
+ expect(output).toContain("Error: 'zzzz' is not a command for 'test mcp'.")
3270
+ expect(output).toContain('Suggested command:')
3271
+ expect(output).toContain('test mcp --help')
3272
+ } finally {
3273
+ ;(process.stdout as any).isTTY = previous
3274
+ }
3275
+ })
3276
+
2753
3277
  test('skills add --help shows options', async () => {
2754
3278
  const cli = Cli.create('test')
2755
3279
  cli.command('ping', { run: () => ({ pong: true }) })
@@ -3072,12 +3596,14 @@ describe('outputPolicy', () => {
3072
3596
  async *run() {
3073
3597
  yield { step: 1 }
3074
3598
  yield { step: 2 }
3599
+ yield { expiry: 2461152330n }
3075
3600
  },
3076
3601
  })
3077
3602
 
3078
3603
  const { output } = await serve(cli, ['stream'])
3079
3604
  expect(output).toContain('{"type":"chunk","data":{"step":1}}')
3080
3605
  expect(output).toContain('{"type":"chunk","data":{"step":2}}')
3606
+ expect(output).toContain('{"type":"chunk","data":{"expiry":"2461152330"}}')
3081
3607
  })
3082
3608
 
3083
3609
  test('e2e: realistic multi-level CLI with mixed policies', async () => {
@@ -3654,24 +4180,75 @@ test('streaming: generator throws in buffered mode', async () => {
3654
4180
  expect(output).toContain('generator exploded')
3655
4181
  })
3656
4182
 
3657
- test('streaming: generator returns error in buffered mode', async () => {
4183
+ test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => {
3658
4184
  const cli = Cli.create('test')
3659
- cli.command('fail', {
3660
- async *run(c) {
4185
+ cli.command('limited', {
4186
+ async *run() {
3661
4187
  yield { step: 1 }
3662
- return c.error({ code: 'RET_ERR', message: 'returned error' })
4188
+ throw new Errors.IncurError({
4189
+ code: 'RATE_LIMITED',
4190
+ message: 'too fast',
4191
+ retryable: true,
4192
+ })
3663
4193
  },
3664
4194
  })
3665
4195
 
3666
- const { output, exitCode } = await serve(cli, ['fail', '--format', 'json'])
3667
- expect(exitCode).toBe(1)
3668
- expect(output).toContain('RET_ERR')
3669
- })
4196
+ const jsonl = await serve(cli, ['limited', '--format', 'jsonl'])
4197
+ const jsonlLines = jsonl.output
4198
+ .trim()
4199
+ .split('\n')
4200
+ .map((line) => JSON.parse(line))
4201
+ expect(jsonl.exitCode).toBe(1)
4202
+ expect(jsonlLines[1]).toMatchInlineSnapshot(`
4203
+ {
4204
+ "error": {
4205
+ "code": "RATE_LIMITED",
4206
+ "message": "too fast",
4207
+ "retryable": true,
4208
+ },
4209
+ "ok": false,
4210
+ "type": "error",
4211
+ }
4212
+ `)
3670
4213
 
3671
- test('c.error({ exitCode }) uses custom exit code', async () => {
3672
- const cli = Cli.create('test')
3673
- cli.command('fail', {
3674
- run(c) {
4214
+ const json = await serve(cli, ['limited', '--full-output', '--format', 'json'])
4215
+ const body = JSON.parse(json.output)
4216
+ body.meta.duration = '<stripped>'
4217
+ expect(json.exitCode).toBe(1)
4218
+ expect(body).toMatchInlineSnapshot(`
4219
+ {
4220
+ "error": {
4221
+ "code": "RATE_LIMITED",
4222
+ "message": "too fast",
4223
+ "retryable": true,
4224
+ },
4225
+ "meta": {
4226
+ "command": "limited",
4227
+ "duration": "<stripped>",
4228
+ },
4229
+ "ok": false,
4230
+ }
4231
+ `)
4232
+ })
4233
+
4234
+ test('streaming: generator returns error in buffered mode', async () => {
4235
+ const cli = Cli.create('test')
4236
+ cli.command('fail', {
4237
+ async *run(c) {
4238
+ yield { step: 1 }
4239
+ return c.error({ code: 'RET_ERR', message: 'returned error' })
4240
+ },
4241
+ })
4242
+
4243
+ const { output, exitCode } = await serve(cli, ['fail', '--format', 'json'])
4244
+ expect(exitCode).toBe(1)
4245
+ expect(output).toContain('RET_ERR')
4246
+ })
4247
+
4248
+ test('c.error({ exitCode }) uses custom exit code', async () => {
4249
+ const cli = Cli.create('test')
4250
+ cli.command('fail', {
4251
+ run(c) {
3675
4252
  return c.error({ code: 'AUTH', message: 'not authed', exitCode: 10 })
3676
4253
  },
3677
4254
  })
@@ -3782,6 +4359,42 @@ test('--llms includes hint in skill output', async () => {
3782
4359
  expect(output).toContain('Always confirm before deploying to production')
3783
4360
  })
3784
4361
 
4362
+ test('--llms appends confirmation hint for destructive commands', async () => {
4363
+ const cli = Cli.create('test')
4364
+ cli.command('destroy', {
4365
+ description: 'Destroy the app',
4366
+ destructive: true,
4367
+ hint: 'Deletes production resources.',
4368
+ run: () => ({}),
4369
+ })
4370
+ cli.command('status', {
4371
+ description: 'Show status',
4372
+ hint: 'Read-only status check.',
4373
+ run: () => ({}),
4374
+ })
4375
+
4376
+ const { output } = await serve(cli, ['--llms-full'])
4377
+ expect(output).toContain(
4378
+ 'Deletes production resources. Confirm with the user before executing this destructive command.',
4379
+ )
4380
+ expect(output).toContain('Read-only status check.')
4381
+ expect(output).not.toContain(
4382
+ 'Read-only status check. Confirm with the user before executing this destructive command.',
4383
+ )
4384
+ })
4385
+
4386
+ test('--llms treats MCP destructiveHint as destructive', async () => {
4387
+ const cli = Cli.create('test')
4388
+ cli.command('deploy', {
4389
+ description: 'Deploy the app',
4390
+ mcp: { annotations: { destructiveHint: true } },
4391
+ run: () => ({}),
4392
+ })
4393
+
4394
+ const { output } = await serve(cli, ['--llms-full'])
4395
+ expect(output).toContain('Confirm with the user before executing this destructive command.')
4396
+ })
4397
+
3785
4398
  describe('fetch', async () => {
3786
4399
  const { app } = await import('../test/fixtures/hono-api.js')
3787
4400
 
@@ -4051,13 +4664,95 @@ describe('--filter-output', () => {
4051
4664
  })
4052
4665
  })
4053
4666
 
4054
- async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
4667
+ describe('Command.execute', () => {
4668
+ test.each([
4669
+ {
4670
+ name: 'split',
4671
+ command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) },
4672
+ inputOptions: { name: 123 },
4673
+ path: 'name',
4674
+ parseMode: 'split' as const,
4675
+ },
4676
+ {
4677
+ name: 'flat',
4678
+ command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) },
4679
+ inputOptions: { id: 123 },
4680
+ path: 'id',
4681
+ parseMode: 'flat' as const,
4682
+ },
4683
+ ])('$name mode returns validation fieldErrors for invalid command input', async (c) => {
4684
+ const result = await Command.execute(c.command, {
4685
+ agent: true,
4686
+ argv: [],
4687
+ format: 'json',
4688
+ formatExplicit: false,
4689
+ inputOptions: c.inputOptions,
4690
+ name: 'test',
4691
+ parseMode: c.parseMode,
4692
+ path: 'users',
4693
+ version: undefined,
4694
+ })
4695
+
4696
+ expect(result).toMatchObject({
4697
+ ok: false,
4698
+ error: {
4699
+ code: 'VALIDATION_ERROR',
4700
+ fieldErrors: [
4701
+ {
4702
+ code: 'invalid_type',
4703
+ missing: false,
4704
+ path: c.path,
4705
+ },
4706
+ ],
4707
+ },
4708
+ })
4709
+ })
4710
+
4711
+ test('does not normalize handler-thrown Zod errors as command input', async () => {
4712
+ const result = await Command.execute(
4713
+ {
4714
+ run() {
4715
+ z.object({ name: z.string() }).parse({ name: 123 })
4716
+ },
4717
+ },
4718
+ {
4719
+ agent: true,
4720
+ argv: [],
4721
+ format: 'json',
4722
+ formatExplicit: false,
4723
+ inputOptions: {},
4724
+ name: 'test',
4725
+ path: 'users',
4726
+ version: undefined,
4727
+ },
4728
+ )
4729
+
4730
+ expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } })
4731
+ expect(result).not.toHaveProperty('error.fieldErrors')
4732
+ })
4733
+ })
4734
+
4735
+ async function fetchJson(cli: Cli.Cli<any, any, any, any>, req: Request) {
4055
4736
  const res = await cli.fetch(req)
4056
4737
  const body = await res.json()
4057
4738
  body.meta.duration = '<stripped>'
4058
4739
  return { status: res.status, body }
4059
4740
  }
4060
4741
 
4742
+ async function fetchNdjson(cli: Cli.Cli<any, any, any>, req: Request) {
4743
+ const res = await cli.fetch(req)
4744
+ const lines = (await res.text())
4745
+ .trim()
4746
+ .split('\n')
4747
+ .map((line) => JSON.parse(line))
4748
+ for (const line of lines)
4749
+ if (line.meta?.duration) {
4750
+ expect(line.meta.duration).toMatch(/^\d+ms$/)
4751
+ line.meta.duration = '<stripped>'
4752
+ }
4753
+ return { status: res.status, contentType: res.headers.get('content-type'), lines }
4754
+ }
4755
+
4061
4756
  describe('fetch', () => {
4062
4757
  test('GET /health → 200', async () => {
4063
4758
  const cli = Cli.create('test')
@@ -4201,6 +4896,16 @@ describe('fetch', () => {
4201
4896
  `)
4202
4897
  })
4203
4898
 
4899
+ test('serializes bigint values in command responses', async () => {
4900
+ const cli = Cli.create('test')
4901
+ cli.command('whois', {
4902
+ output: z.object({ expiry: z.bigint() }),
4903
+ run: () => ({ expiry: 2461152330n }),
4904
+ })
4905
+ const { body } = await fetchJson(cli, new Request('http://localhost/whois'))
4906
+ expect(body.data).toEqual({ expiry: '2461152330' })
4907
+ })
4908
+
4204
4909
  test('trailing path segments → positional args', async () => {
4205
4910
  const cli = Cli.create('test')
4206
4911
  cli.command('users', {
@@ -4256,6 +4961,61 @@ describe('fetch', () => {
4256
4961
  expect(status).toBe(400)
4257
4962
  expect(body.ok).toBe(false)
4258
4963
  expect(body.error.code).toBe('VALIDATION_ERROR')
4964
+ expect(body.error.fieldErrors).toMatchObject([{ missing: true, path: 'id' }])
4965
+ })
4966
+
4967
+ test('validation error includes fieldErrors for body options', async () => {
4968
+ const cli = Cli.create('test')
4969
+ cli.command('users', {
4970
+ options: z.object({ name: z.string() }),
4971
+ run: (c) => ({ name: c.options.name }),
4972
+ })
4973
+ const { status, body } = await fetchJson(
4974
+ cli,
4975
+ new Request('http://localhost/users', {
4976
+ method: 'POST',
4977
+ headers: { 'content-type': 'application/json' },
4978
+ body: JSON.stringify({ name: 123 }),
4979
+ }),
4980
+ )
4981
+ expect(status).toBe(400)
4982
+ expect(body.ok).toBe(false)
4983
+ expect(body.error).toMatchObject({
4984
+ code: 'VALIDATION_ERROR',
4985
+ fieldErrors: [{ code: 'invalid_type', missing: false, path: 'name' }],
4986
+ })
4987
+ })
4988
+
4989
+ test('object validation error includes fieldErrors', async () => {
4990
+ const cli = Cli.create('test')
4991
+ cli.command('users', {
4992
+ options: z.object({ name: z.string() }),
4993
+ run: (c) => ({ name: c.options.name }),
4994
+ })
4995
+
4996
+ const { status, body } = await fetchJson(
4997
+ cli,
4998
+ new Request('http://localhost/users', {
4999
+ method: 'POST',
5000
+ headers: { 'content-type': 'application/json' },
5001
+ body: JSON.stringify({ name: 123 }),
5002
+ }),
5003
+ )
5004
+
5005
+ expect(status).toBe(400)
5006
+ expect(body).toMatchObject({
5007
+ ok: false,
5008
+ error: {
5009
+ code: 'VALIDATION_ERROR',
5010
+ fieldErrors: [
5011
+ {
5012
+ code: 'invalid_type',
5013
+ missing: false,
5014
+ path: 'name',
5015
+ },
5016
+ ],
5017
+ },
5018
+ })
4259
5019
  })
4260
5020
 
4261
5021
  test('thrown error → 500', async () => {
@@ -4288,40 +5048,360 @@ describe('fetch', () => {
4288
5048
  cli.command('stream', {
4289
5049
  async *run() {
4290
5050
  yield { progress: 1 }
4291
- yield { progress: 2 }
5051
+ yield { expiry: 2461152330n }
4292
5052
  return { done: true }
4293
5053
  },
4294
5054
  })
5055
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5056
+ {
5057
+ "contentType": "application/x-ndjson",
5058
+ "lines": [
5059
+ {
5060
+ "data": {
5061
+ "progress": 1,
5062
+ },
5063
+ "type": "chunk",
5064
+ },
5065
+ {
5066
+ "data": {
5067
+ "expiry": "2461152330",
5068
+ },
5069
+ "type": "chunk",
5070
+ },
5071
+ {
5072
+ "meta": {
5073
+ "command": "stream",
5074
+ "duration": "<stripped>",
5075
+ },
5076
+ "ok": true,
5077
+ "type": "done",
5078
+ },
5079
+ ],
5080
+ "status": 200,
5081
+ }
5082
+ `)
5083
+ })
5084
+
5085
+ test('streaming response preserves returned ok CTA through middleware', async () => {
5086
+ const cli = Cli.create('test')
5087
+ cli.use(async (_c, next) => {
5088
+ await next()
5089
+ })
5090
+ cli.command('stream', {
5091
+ async *run(c) {
5092
+ yield { progress: 1 }
5093
+ return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } })
5094
+ },
5095
+ })
5096
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5097
+ {
5098
+ "contentType": "application/x-ndjson",
5099
+ "lines": [
5100
+ {
5101
+ "data": {
5102
+ "progress": 1,
5103
+ },
5104
+ "type": "chunk",
5105
+ },
5106
+ {
5107
+ "meta": {
5108
+ "command": "stream",
5109
+ "cta": {
5110
+ "commands": [
5111
+ {
5112
+ "command": "test next",
5113
+ },
5114
+ ],
5115
+ "description": "Next steps:",
5116
+ },
5117
+ "duration": "<stripped>",
5118
+ },
5119
+ "ok": true,
5120
+ "type": "done",
5121
+ },
5122
+ ],
5123
+ "status": 200,
5124
+ }
5125
+ `)
5126
+ })
5127
+
5128
+ test('streaming response handles terminal-only sentinel returns through middleware', async () => {
5129
+ const order: string[] = []
5130
+ const cli = Cli.create('test')
5131
+ cli.use(async (c, next) => {
5132
+ order.push(`before:${c.command}`)
5133
+ await next()
5134
+ order.push(`after:${c.command}`)
5135
+ })
5136
+ const sub = Cli.create('ops')
5137
+ sub.command('ok', {
5138
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
5139
+ async *run(c) {
5140
+ return c.ok(
5141
+ { ignored: true },
5142
+ { cta: { commands: [{ command: 'next', description: 'Continue' }] } },
5143
+ )
5144
+ },
5145
+ })
5146
+ sub.command('fail', {
5147
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
5148
+ async *run(c) {
5149
+ return c.error({
5150
+ code: 'EMPTY_FAIL',
5151
+ cta: { commands: ['retry'], description: 'Recover with:' },
5152
+ message: 'failed before chunks',
5153
+ retryable: true,
5154
+ })
5155
+ },
5156
+ })
5157
+ cli.command(sub)
5158
+
5159
+ const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok'))
5160
+ expect(ok).toMatchInlineSnapshot(`
5161
+ {
5162
+ "contentType": "application/x-ndjson",
5163
+ "lines": [
5164
+ {
5165
+ "meta": {
5166
+ "command": "ops ok",
5167
+ "cta": {
5168
+ "commands": [
5169
+ {
5170
+ "command": "test next",
5171
+ "description": "Continue",
5172
+ },
5173
+ ],
5174
+ "description": "Suggested command:",
5175
+ },
5176
+ "duration": "<stripped>",
5177
+ },
5178
+ "ok": true,
5179
+ "type": "done",
5180
+ },
5181
+ ],
5182
+ "status": 200,
5183
+ }
5184
+ `)
5185
+ expect(ok.lines[0]).not.toHaveProperty('data')
5186
+
5187
+ expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(`
5188
+ {
5189
+ "contentType": "application/x-ndjson",
5190
+ "lines": [
5191
+ {
5192
+ "error": {
5193
+ "code": "EMPTY_FAIL",
5194
+ "message": "failed before chunks",
5195
+ "retryable": true,
5196
+ },
5197
+ "meta": {
5198
+ "command": "ops fail",
5199
+ "cta": {
5200
+ "commands": [
5201
+ {
5202
+ "command": "test retry",
5203
+ },
5204
+ ],
5205
+ "description": "Recover with:",
5206
+ },
5207
+ "duration": "<stripped>",
5208
+ },
5209
+ "ok": false,
5210
+ "type": "error",
5211
+ },
5212
+ ],
5213
+ "status": 200,
5214
+ }
5215
+ `)
5216
+ expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail'])
5217
+ })
5218
+
5219
+ test('streaming response represents returned error as terminal error', async () => {
5220
+ const cli = Cli.create('test')
5221
+ cli.command('stream', {
5222
+ async *run(c) {
5223
+ yield { progress: 1 }
5224
+ return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true })
5225
+ },
5226
+ })
5227
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5228
+ {
5229
+ "contentType": "application/x-ndjson",
5230
+ "lines": [
5231
+ {
5232
+ "data": {
5233
+ "progress": 1,
5234
+ },
5235
+ "type": "chunk",
5236
+ },
5237
+ {
5238
+ "error": {
5239
+ "code": "STREAM_FAIL",
5240
+ "message": "failed late",
5241
+ "retryable": true,
5242
+ },
5243
+ "meta": {
5244
+ "command": "stream",
5245
+ "duration": "<stripped>",
5246
+ },
5247
+ "ok": false,
5248
+ "type": "error",
5249
+ },
5250
+ ],
5251
+ "status": 200,
5252
+ }
5253
+ `)
5254
+ })
5255
+
5256
+ test('streaming response represents yielded error as terminal error', async () => {
5257
+ let closed = false
5258
+ const cli = Cli.create('test')
5259
+ cli.command('stream', {
5260
+ async *run(c) {
5261
+ try {
5262
+ yield { progress: 1 }
5263
+ yield c.error({ code: 'STREAM_FAIL', message: 'failed now' })
5264
+ yield { progress: 2 }
5265
+ } finally {
5266
+ closed = true
5267
+ }
5268
+ },
5269
+ })
5270
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5271
+ {
5272
+ "contentType": "application/x-ndjson",
5273
+ "lines": [
5274
+ {
5275
+ "data": {
5276
+ "progress": 1,
5277
+ },
5278
+ "type": "chunk",
5279
+ },
5280
+ {
5281
+ "error": {
5282
+ "code": "STREAM_FAIL",
5283
+ "message": "failed now",
5284
+ },
5285
+ "meta": {
5286
+ "command": "stream",
5287
+ "duration": "<stripped>",
5288
+ },
5289
+ "ok": false,
5290
+ "type": "error",
5291
+ },
5292
+ ],
5293
+ "status": 200,
5294
+ }
5295
+ `)
5296
+ expect(closed).toBe(true)
5297
+ })
5298
+
5299
+ test('streaming response cancellation unwinds generator and middleware', async () => {
5300
+ let resolveAfter = () => {}
5301
+ const after = new Promise<void>((resolve) => {
5302
+ resolveAfter = resolve
5303
+ })
5304
+ const order: string[] = []
5305
+ const cli = Cli.create('test')
5306
+ cli.use(async (_c, next) => {
5307
+ order.push('mw:before')
5308
+ await next()
5309
+ order.push('mw:after')
5310
+ resolveAfter()
5311
+ })
5312
+ cli.command('stream', {
5313
+ async *run() {
5314
+ try {
5315
+ order.push('stream:yield')
5316
+ yield { progress: 1 }
5317
+ while (true) yield { progress: 2 }
5318
+ } finally {
5319
+ order.push('stream:finally')
5320
+ }
5321
+ },
5322
+ })
4295
5323
  const res = await cli.fetch(new Request('http://localhost/stream'))
4296
- expect(res.status).toBe(200)
4297
- expect(res.headers.get('content-type')).toBe('application/x-ndjson')
4298
- const text = await res.text()
4299
- const lines = text
4300
- .trim()
4301
- .split('\n')
4302
- .map((l) => JSON.parse(l))
4303
- expect(lines).toMatchInlineSnapshot(`
4304
- [
4305
- {
4306
- "data": {
4307
- "progress": 1,
5324
+ const reader = res.body!.getReader()
5325
+ await reader.read()
5326
+ await reader.cancel()
5327
+ await after
5328
+ expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after'])
5329
+ })
5330
+
5331
+ test('streaming response thrown error includes terminal duration metadata', async () => {
5332
+ const cli = Cli.create('test')
5333
+ cli.command('stream', {
5334
+ async *run() {
5335
+ yield { progress: 1 }
5336
+ throw new Error('boom')
5337
+ },
5338
+ })
5339
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5340
+ {
5341
+ "contentType": "application/x-ndjson",
5342
+ "lines": [
5343
+ {
5344
+ "data": {
5345
+ "progress": 1,
5346
+ },
5347
+ "type": "chunk",
4308
5348
  },
4309
- "type": "chunk",
4310
- },
4311
- {
4312
- "data": {
4313
- "progress": 2,
5349
+ {
5350
+ "error": {
5351
+ "code": "UNKNOWN",
5352
+ "message": "boom",
5353
+ },
5354
+ "meta": {
5355
+ "command": "stream",
5356
+ "duration": "<stripped>",
5357
+ },
5358
+ "ok": false,
5359
+ "type": "error",
4314
5360
  },
4315
- "type": "chunk",
4316
- },
4317
- {
4318
- "meta": {
4319
- "command": "stream",
5361
+ ],
5362
+ "status": 200,
5363
+ }
5364
+ `)
5365
+ })
5366
+
5367
+ test('streaming response thrown IncurError preserves code and retryable metadata', async () => {
5368
+ const cli = Cli.create('test')
5369
+ cli.command('stream', {
5370
+ async *run() {
5371
+ yield { progress: 1 }
5372
+ throw new Errors.IncurError({
5373
+ code: 'RATE_LIMITED',
5374
+ message: 'too fast',
5375
+ retryable: true,
5376
+ })
5377
+ },
5378
+ })
5379
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
5380
+ {
5381
+ "contentType": "application/x-ndjson",
5382
+ "lines": [
5383
+ {
5384
+ "data": {
5385
+ "progress": 1,
5386
+ },
5387
+ "type": "chunk",
4320
5388
  },
4321
- "ok": true,
4322
- "type": "done",
4323
- },
4324
- ]
5389
+ {
5390
+ "error": {
5391
+ "code": "RATE_LIMITED",
5392
+ "message": "too fast",
5393
+ "retryable": true,
5394
+ },
5395
+ "meta": {
5396
+ "command": "stream",
5397
+ "duration": "<stripped>",
5398
+ },
5399
+ "ok": false,
5400
+ "type": "error",
5401
+ },
5402
+ ],
5403
+ "status": 200,
5404
+ }
4325
5405
  `)
4326
5406
  })
4327
5407
 
@@ -4518,8 +5598,12 @@ describe('fetch', () => {
4518
5598
  const sessionId = res.headers.get('mcp-session-id')
4519
5599
  const body = await res.json()
4520
5600
  // Send initialized notification
4521
- await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId!)
4522
- return { sessionId: sessionId!, body }
5601
+ await mcpRequest(
5602
+ cli,
5603
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
5604
+ sessionId ?? undefined,
5605
+ )
5606
+ return { sessionId: sessionId ?? undefined, body }
4523
5607
  }
4524
5608
 
4525
5609
  test('POST /mcp with initialize → valid MCP response', async () => {
@@ -4535,6 +5619,7 @@ describe('fetch', () => {
4535
5619
  },
4536
5620
  })
4537
5621
  expect(res.status).toBe(200)
5622
+ expect(res.headers.get('mcp-session-id')).toBeNull()
4538
5623
  const body = await res.json()
4539
5624
  expect({
4540
5625
  serverInfo: body.result.serverInfo,
@@ -4550,6 +5635,34 @@ describe('fetch', () => {
4550
5635
  `)
4551
5636
  })
4552
5637
 
5638
+ test('POST /mcp with tools/list works without session state', async () => {
5639
+ const cli = mcpCli()
5640
+ await mcpRequest(cli, {
5641
+ jsonrpc: '2.0',
5642
+ id: 1,
5643
+ method: 'initialize',
5644
+ params: {
5645
+ protocolVersion: '2025-03-26',
5646
+ capabilities: {},
5647
+ clientInfo: { name: 'test-client', version: '1.0.0' },
5648
+ },
5649
+ })
5650
+ const res = await mcpRequest(cli, {
5651
+ jsonrpc: '2.0',
5652
+ id: 2,
5653
+ method: 'tools/list',
5654
+ params: {},
5655
+ })
5656
+ expect(res.status).toBe(200)
5657
+ const body = await res.json()
5658
+ expect(body.result.tools.map((t: any) => t.name)).toMatchInlineSnapshot(`
5659
+ [
5660
+ "greet",
5661
+ "ping",
5662
+ ]
5663
+ `)
5664
+ })
5665
+
4553
5666
  test('POST /mcp with tools/list → returns registered tools', async () => {
4554
5667
  const cli = mcpCli()
4555
5668
  const { sessionId } = await initSession(cli)
@@ -4581,6 +5694,37 @@ describe('fetch', () => {
4581
5694
  `)
4582
5695
  })
4583
5696
 
5697
+ test('GET /mcp returns method not allowed in stateless mode', async () => {
5698
+ const cli = mcpCli()
5699
+ const res = await cli.fetch(
5700
+ new Request('http://localhost/mcp', {
5701
+ method: 'GET',
5702
+ headers: { accept: 'text/event-stream' },
5703
+ }),
5704
+ )
5705
+ expect(res.status).toBe(405)
5706
+ expect(res.headers.get('allow')).toBe('POST')
5707
+ expect(await res.text()).toBe('')
5708
+ })
5709
+
5710
+ test('mcp.stateless false keeps stateful session handling', async () => {
5711
+ const cli = Cli.create('test', { version: '1.0.0', mcp: { stateless: false } })
5712
+ cli.command('ping', {
5713
+ description: 'Ping',
5714
+ run: () => ({ pong: true }),
5715
+ })
5716
+ const { sessionId } = await initSession(cli)
5717
+ expect(sessionId).toEqual(expect.any(String))
5718
+
5719
+ const res = await mcpRequest(cli, {
5720
+ jsonrpc: '2.0',
5721
+ id: 2,
5722
+ method: 'tools/list',
5723
+ params: {},
5724
+ })
5725
+ expect(res.status).toBe(400)
5726
+ })
5727
+
4584
5728
  test('POST /mcp with tools/call → executes command', async () => {
4585
5729
  const cli = mcpCli()
4586
5730
  const { sessionId } = await initSession(cli)
@@ -4708,6 +5852,296 @@ describe('displayName', () => {
4708
5852
  })
4709
5853
  })
4710
5854
 
5855
+ describe('globals', () => {
5856
+ test('globals are parsed and available in middleware', async () => {
5857
+ const cli = Cli.create('test', {
5858
+ globals: z.object({ rpcUrl: z.string() }),
5859
+ vars: z.object({ rpcUrl: z.string().default('') }),
5860
+ })
5861
+ .use(async (c, next) => {
5862
+ c.set('rpcUrl', c.globals.rpcUrl)
5863
+ await next()
5864
+ })
5865
+ .command('ping', {
5866
+ run(c) {
5867
+ return { url: c.var.rpcUrl }
5868
+ },
5869
+ })
5870
+
5871
+ const { output } = await serve(cli, ['--rpc-url', 'http://example.com', 'ping', '--json'])
5872
+ expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5873
+ })
5874
+
5875
+ test('globals aliases work', async () => {
5876
+ const cli = Cli.create('test', {
5877
+ globals: z.object({ rpcUrl: z.string() }),
5878
+ globalAlias: { rpcUrl: 'r' },
5879
+ vars: z.object({ rpcUrl: z.string().default('') }),
5880
+ })
5881
+ .use(async (c, next) => {
5882
+ c.set('rpcUrl', c.globals.rpcUrl)
5883
+ await next()
5884
+ })
5885
+ .command('ping', {
5886
+ run(c) {
5887
+ return { url: c.var.rpcUrl }
5888
+ },
5889
+ })
5890
+
5891
+ const { output } = await serve(cli, ['-r', 'http://example.com', 'ping', '--json'])
5892
+ expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5893
+ })
5894
+
5895
+ test('globals with defaults work when not provided', async () => {
5896
+ const cli = Cli.create('test', {
5897
+ globals: z.object({ chain: z.string().default('mainnet') }),
5898
+ vars: z.object({ chain: z.string().default('') }),
5899
+ })
5900
+ .use(async (c, next) => {
5901
+ c.set('chain', c.globals.chain)
5902
+ await next()
5903
+ })
5904
+ .command('ping', {
5905
+ run(c) {
5906
+ return { chain: c.var.chain }
5907
+ },
5908
+ })
5909
+
5910
+ const { output } = await serve(cli, ['ping', '--json'])
5911
+ expect(JSON.parse(output)).toEqual({ chain: 'mainnet' })
5912
+ })
5913
+
5914
+ test('globals appear in --help output', async () => {
5915
+ const cli = Cli.create('test', {
5916
+ globals: z.object({
5917
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5918
+ }),
5919
+ globalAlias: { rpcUrl: 'r' },
5920
+ }).command('ping', { run: () => ({}) })
5921
+
5922
+ const { output } = await serve(cli, ['--help'])
5923
+ expect(output).toContain('Custom Global Options')
5924
+ expect(output).toContain('--rpc-url')
5925
+ })
5926
+
5927
+ test('informational commands do not require globals', async () => {
5928
+ const cli = Cli.create('test', {
5929
+ version: '1.0.0',
5930
+ globals: z.object({
5931
+ rpcUrl: z.string().describe('RPC endpoint URL'),
5932
+ }),
5933
+ }).command('ping', {
5934
+ args: z.object({ target: z.string() }),
5935
+ run: () => ({}),
5936
+ })
5937
+
5938
+ const help = await serve(cli, ['--help'])
5939
+ expect(help.exitCode).toBeUndefined()
5940
+ expect(help.output).toContain('--rpc-url')
5941
+
5942
+ const schema = await serve(cli, ['ping', '--schema', '--format', 'json'])
5943
+ expect(schema.exitCode).toBeUndefined()
5944
+ expect(JSON.parse(schema.output).globals.properties.rpcUrl).toBeDefined()
5945
+
5946
+ const llms = await serve(cli, ['--llms', '--format', 'json'])
5947
+ expect(llms.exitCode).toBeUndefined()
5948
+ expect(JSON.parse(llms.output).globals.properties.rpcUrl).toBeDefined()
5949
+
5950
+ const version = await serve(cli, ['--version'])
5951
+ expect(version.exitCode).toBeUndefined()
5952
+ expect(version.output).toBe('1.0.0\n')
5953
+ })
5954
+
5955
+ test('globals appear in --llms manifest', async () => {
5956
+ const cli = Cli.create('test', {
5957
+ globals: z.object({
5958
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5959
+ }),
5960
+ }).command('ping', { description: 'Health check', run: () => ({}) })
5961
+
5962
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
5963
+ const manifest = JSON.parse(output)
5964
+ expect(manifest.globals).toBeDefined()
5965
+ expect(manifest.globals.properties.rpcUrl).toBeDefined()
5966
+ })
5967
+
5968
+ test('globals validation error shows message and exits 1', async () => {
5969
+ const cli = Cli.create('test', {
5970
+ globals: z.object({ limit: z.number() }),
5971
+ }).command('ping', { run: () => ({}) })
5972
+
5973
+ const { output, exitCode } = await serve(cli, ['--limit', 'not-a-number', 'ping'])
5974
+ expect(exitCode).toBe(1)
5975
+ expect(output).toContain('Invalid input')
5976
+ })
5977
+
5978
+ test('globals position is flexible', async () => {
5979
+ const cli = Cli.create('test', {
5980
+ globals: z.object({ rpcUrl: z.string() }),
5981
+ vars: z.object({ rpcUrl: z.string().default('') }),
5982
+ })
5983
+ .use(async (c, next) => {
5984
+ c.set('rpcUrl', c.globals.rpcUrl)
5985
+ await next()
5986
+ })
5987
+ .command('deploy', {
5988
+ run(c) {
5989
+ return { url: c.var.rpcUrl }
5990
+ },
5991
+ })
5992
+
5993
+ const { output } = await serve(cli, ['deploy', '--rpc-url', 'http://x', '--json'])
5994
+ expect(JSON.parse(output)).toEqual({ url: 'http://x' })
5995
+ })
5996
+
5997
+ test('globals conflict with builtins errors at create() time', () => {
5998
+ expect(() =>
5999
+ Cli.create('test', {
6000
+ globals: z.object({ format: z.string() }),
6001
+ }),
6002
+ ).toThrow(/conflicts with a built-in flag/)
6003
+ })
6004
+
6005
+ test('command option conflicting with global errors at command() time', () => {
6006
+ const cli = Cli.create('test', {
6007
+ globals: z.object({ rpcUrl: z.string() }),
6008
+ })
6009
+ expect(() =>
6010
+ cli.command('deploy', {
6011
+ options: z.object({ rpcUrl: z.string() }),
6012
+ run: () => ({}),
6013
+ }),
6014
+ ).toThrow(/conflicts with a global option/)
6015
+ })
6016
+
6017
+ test('mounted root command option conflicting with global errors at command() time', () => {
6018
+ const cli = Cli.create('test', {
6019
+ globals: z.object({ rpcUrl: z.string() }),
6020
+ })
6021
+ const deploy = Cli.create('deploy', {
6022
+ options: z.object({ rpcUrl: z.string() }),
6023
+ run: () => ({}),
6024
+ })
6025
+
6026
+ expect(() => cli.command(deploy)).toThrow(/conflicts with a global option/)
6027
+ })
6028
+
6029
+ test('mounted subcommand option conflicting with global errors at command() time', () => {
6030
+ const cli = Cli.create('test', {
6031
+ globals: z.object({ rpcUrl: z.string() }),
6032
+ })
6033
+ const admin = Cli.create('admin').command('deploy', {
6034
+ options: z.object({ rpcUrl: z.string() }),
6035
+ run: () => ({}),
6036
+ })
6037
+
6038
+ expect(() => cli.command(admin)).toThrow(/conflicts with a global option/)
6039
+ })
6040
+
6041
+ test('boolean globals handle --no- negation', async () => {
6042
+ const cli = Cli.create('test', {
6043
+ globals: z.object({ dryRun: z.boolean().default(true) }),
6044
+ vars: z.object({ dryRun: z.boolean().default(false) }),
6045
+ })
6046
+ .use(async (c, next) => {
6047
+ c.set('dryRun', c.globals.dryRun)
6048
+ await next()
6049
+ })
6050
+ .command('ping', {
6051
+ run(c) {
6052
+ return { dryRun: c.var.dryRun }
6053
+ },
6054
+ })
6055
+
6056
+ const { output } = await serve(cli, ['--no-dry-run', 'ping', '--json'])
6057
+ expect(JSON.parse(output)).toEqual({ dryRun: false })
6058
+ })
6059
+
6060
+ test('parseGlobals error produces clean error output with exit code 1', async () => {
6061
+ const cli = Cli.create('test', {
6062
+ globals: z.object({ rpcUrl: z.string() }),
6063
+ }).command('ping', { run: () => ({}) })
6064
+
6065
+ const { output, exitCode } = await serve(cli, ['--rpc-url'])
6066
+ expect(exitCode).toBe(1)
6067
+ expect(output).toContain('Missing value for flag')
6068
+ })
6069
+
6070
+ test('global alias collision with -h throws at create() time', () => {
6071
+ expect(() =>
6072
+ Cli.create('test', {
6073
+ globals: z.object({ host: z.string().optional() }),
6074
+ globalAlias: { host: 'h' },
6075
+ }),
6076
+ ).toThrow(/conflicts with a built-in short flag/)
6077
+ })
6078
+
6079
+ test('command alias collision with global alias throws at command() time', () => {
6080
+ const cli = Cli.create('test', {
6081
+ globals: z.object({ rpcUrl: z.string().optional() }),
6082
+ globalAlias: { rpcUrl: 'r' },
6083
+ })
6084
+ expect(() =>
6085
+ cli.command('deploy', {
6086
+ options: z.object({ region: z.string().optional() }),
6087
+ alias: { region: 'r' },
6088
+ run: () => ({}),
6089
+ }),
6090
+ ).toThrow(/conflicts with a global alias/)
6091
+ })
6092
+
6093
+ test('globals validation error in agent mode outputs toon format', async () => {
6094
+ ;(process.stdout as any).isTTY = false
6095
+ const cli = Cli.create('test', {
6096
+ globals: z.object({ limit: z.number() }),
6097
+ }).command('ping', { run: () => ({}) })
6098
+
6099
+ const { output, exitCode } = await serve(cli, ['--limit', 'abc', 'ping'])
6100
+ expect(exitCode).toBe(1)
6101
+ expect(output).toContain('UNKNOWN')
6102
+ ;(process.stdout as any).isTTY = true
6103
+ })
6104
+
6105
+ test('globals appear in --schema output', async () => {
6106
+ const cli = Cli.create('test', {
6107
+ globals: z.object({
6108
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
6109
+ }),
6110
+ }).command('ping', {
6111
+ args: z.object({ target: z.string() }),
6112
+ run: () => ({}),
6113
+ })
6114
+
6115
+ const { output } = await serve(cli, ['ping', '--schema', '--format', 'json'])
6116
+ const parsed = JSON.parse(output)
6117
+ expect(parsed.globals).toBeDefined()
6118
+ expect(parsed.globals.properties.rpcUrl).toBeDefined()
6119
+ })
6120
+
6121
+ test('globals are available in fetch middleware', async () => {
6122
+ const cli = Cli.create('test', {
6123
+ globals: z.object({ rpcUrl: z.string().default('fallback') }),
6124
+ vars: z.object({ rpcUrl: z.string().default('') }),
6125
+ })
6126
+ .use(async (c, next) => {
6127
+ c.set('rpcUrl', c.globals.rpcUrl)
6128
+ await next()
6129
+ })
6130
+ .command('ping', {
6131
+ options: z.object({ limit: z.coerce.number().default(0) }),
6132
+ run(c) {
6133
+ return { limit: c.options.limit, url: c.var.rpcUrl }
6134
+ },
6135
+ })
6136
+
6137
+ const { body } = await fetchJson(
6138
+ cli,
6139
+ new Request('http://localhost/ping?rpcUrl=http://example.com&limit=3'),
6140
+ )
6141
+ expect(body.data).toEqual({ limit: 3, url: 'http://example.com' })
6142
+ })
6143
+ })
6144
+
4711
6145
  test('--format rejects invalid format values', async () => {
4712
6146
  const cli = Cli.create('test').command('hello', {
4713
6147
  run: (c) => c.ok({ message: 'hi' }),
@@ -4806,3 +6240,57 @@ describe('command aliases', () => {
4806
6240
  expect(output).toContain('updated')
4807
6241
  })
4808
6242
  })
6243
+
6244
+ describe('--mcp', () => {
6245
+ test('mcp.instructions from create() is forwarded to Mcp.serve', async () => {
6246
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
6247
+ try {
6248
+ const cli = Cli.create('test', { mcp: { instructions: 'Always pass --dry-run first.' } })
6249
+ cli.command('ping', { run: () => ({ pong: true }) })
6250
+ await cli.serve(['--mcp'])
6251
+ expect(spy).toHaveBeenCalledOnce()
6252
+ expect(spy.mock.calls[0]![3]).toMatchObject({ instructions: 'Always pass --dry-run first.' })
6253
+ } finally {
6254
+ spy.mockRestore()
6255
+ }
6256
+ })
6257
+
6258
+ test('instructions is omitted from Mcp.serve when not set in create()', async () => {
6259
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
6260
+ try {
6261
+ const cli = Cli.create('test')
6262
+ cli.command('ping', { run: () => ({ pong: true }) })
6263
+ await cli.serve(['--mcp'])
6264
+ expect(spy).toHaveBeenCalledOnce()
6265
+ expect(spy.mock.calls[0]![3]?.instructions).toBeUndefined()
6266
+ } finally {
6267
+ spy.mockRestore()
6268
+ }
6269
+ })
6270
+
6271
+ test('command mcp metadata is forwarded through public command definitions', async () => {
6272
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
6273
+ try {
6274
+ const cli = Cli.create('test')
6275
+ cli.command('deploy', {
6276
+ mcp: {
6277
+ name: 'deploy_service',
6278
+ description: 'Deploy service through MCP',
6279
+ annotations: { destructiveHint: true, idempotentHint: false },
6280
+ instructions: 'Require confirmation before production deploys.',
6281
+ },
6282
+ run: () => ({ deployed: true }),
6283
+ })
6284
+ await cli.serve(['--mcp'])
6285
+
6286
+ const commands = spy.mock.calls[0]![2] as Map<string, any>
6287
+ const [tool] = Mcp.collectTools(commands, [])
6288
+ expect(tool?.name).toBe('deploy_service')
6289
+ expect(tool?.description).toBe('Deploy service through MCP')
6290
+ expect(tool?.annotations).toEqual({ destructiveHint: true, idempotentHint: false })
6291
+ expect(tool?.instructions).toBe('Require confirmation before production deploys.')
6292
+ } finally {
6293
+ spy.mockRestore()
6294
+ }
6295
+ })
6296
+ })