incur 0.4.7 → 0.4.9

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 (94) hide show
  1. package/dist/Cli.d.ts +65 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +294 -66
  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 +25 -8
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Openapi.d.ts +14 -0
  21. package/dist/Openapi.d.ts.map +1 -1
  22. package/dist/Openapi.js +85 -1
  23. package/dist/Openapi.js.map +1 -1
  24. package/dist/Parser.d.ts +14 -0
  25. package/dist/Parser.d.ts.map +1 -1
  26. package/dist/Parser.js +127 -1
  27. package/dist/Parser.js.map +1 -1
  28. package/dist/Skill.d.ts.map +1 -1
  29. package/dist/Skill.js +5 -2
  30. package/dist/Skill.js.map +1 -1
  31. package/dist/Skillgen.d.ts.map +1 -1
  32. package/dist/Skillgen.js +4 -38
  33. package/dist/Skillgen.js.map +1 -1
  34. package/dist/SyncMcp.d.ts.map +1 -1
  35. package/dist/SyncMcp.js +75 -8
  36. package/dist/SyncMcp.js.map +1 -1
  37. package/dist/SyncSkills.d.ts +9 -0
  38. package/dist/SyncSkills.d.ts.map +1 -1
  39. package/dist/SyncSkills.js +13 -74
  40. package/dist/SyncSkills.js.map +1 -1
  41. package/dist/bin.d.ts +1 -1
  42. package/dist/bin.d.ts.map +1 -1
  43. package/dist/internal/command.d.ts +2 -0
  44. package/dist/internal/command.d.ts.map +1 -1
  45. package/dist/internal/command.js +6 -5
  46. package/dist/internal/command.js.map +1 -1
  47. package/dist/internal/cta.d.ts +22 -0
  48. package/dist/internal/cta.d.ts.map +1 -0
  49. package/dist/internal/cta.js +25 -0
  50. package/dist/internal/cta.js.map +1 -0
  51. package/dist/internal/json.d.ts +5 -0
  52. package/dist/internal/json.d.ts.map +1 -0
  53. package/dist/internal/json.js +13 -0
  54. package/dist/internal/json.js.map +1 -0
  55. package/dist/internal/yaml.d.ts +12 -0
  56. package/dist/internal/yaml.d.ts.map +1 -0
  57. package/dist/internal/yaml.js +20 -0
  58. package/dist/internal/yaml.js.map +1 -0
  59. package/dist/middleware.d.ts +8 -4
  60. package/dist/middleware.d.ts.map +1 -1
  61. package/dist/middleware.js +1 -1
  62. package/dist/middleware.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/Cli.test-d.ts +71 -0
  65. package/src/Cli.test.ts +1164 -65
  66. package/src/Cli.ts +456 -101
  67. package/src/Completions.test.ts +84 -0
  68. package/src/Completions.ts +48 -9
  69. package/src/Formatter.test.ts +17 -0
  70. package/src/Formatter.ts +7 -5
  71. package/src/Help.test.ts +31 -0
  72. package/src/Help.ts +42 -4
  73. package/src/Mcp.test.ts +193 -0
  74. package/src/Mcp.ts +49 -8
  75. package/src/Openapi.test.ts +179 -0
  76. package/src/Openapi.ts +122 -2
  77. package/src/Parser.test.ts +173 -0
  78. package/src/Parser.ts +132 -1
  79. package/src/Skill.test.ts +6 -0
  80. package/src/Skill.ts +8 -5
  81. package/src/Skillgen.test.ts +55 -6
  82. package/src/Skillgen.ts +9 -35
  83. package/src/SyncMcp.test.ts +89 -0
  84. package/src/SyncMcp.ts +72 -8
  85. package/src/SyncSkills.test.ts +78 -1
  86. package/src/SyncSkills.ts +20 -78
  87. package/src/bun-compile.test.ts +5 -0
  88. package/src/e2e.test.ts +162 -8
  89. package/src/internal/command.ts +8 -4
  90. package/src/internal/cta.ts +58 -0
  91. package/src/internal/json.ts +16 -0
  92. package/src/internal/yaml.ts +23 -0
  93. package/src/lazy-imports.test.ts +94 -0
  94. package/src/middleware.ts +12 -3
package/src/Cli.test.ts CHANGED
@@ -1,8 +1,10 @@
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
+
6
8
  const originalIsTTY = process.stdout.isTTY
7
9
  beforeAll(() => {
8
10
  ;(process.stdout as any).isTTY = false
@@ -1385,8 +1387,9 @@ describe('--llms', () => {
1385
1387
  cli.command('ping', { description: 'Health check', run: () => ({}) })
1386
1388
 
1387
1389
  const { output } = await serve(cli, ['auth', '--llms'])
1388
- expect(output).toContain('test auth auth login')
1389
- expect(output).toContain('test auth auth logout')
1390
+ expect(output).toContain('test auth login')
1391
+ expect(output).toContain('test auth logout')
1392
+ expect(output).not.toContain('test auth auth') // no doubled namespace
1390
1393
  expect(output).not.toContain('ping')
1391
1394
  })
1392
1395
 
@@ -1421,6 +1424,18 @@ describe('--llms', () => {
1421
1424
  expect(output).toContain('# my-cli auth')
1422
1425
  expect(output).not.toContain('# my-cli \n')
1423
1426
  })
1427
+
1428
+ test('scoped json index keeps full command paths', async () => {
1429
+ const cli = Cli.create('test')
1430
+ const group = Cli.create('auth', { description: 'Authentication' })
1431
+ group.command('login', { description: 'Log in', run: () => ({}) })
1432
+ group.command('logout', { description: 'Log out', run: () => ({}) })
1433
+ cli.command(group)
1434
+
1435
+ const { output } = await serve(cli, ['auth', '--llms', '--format', 'json'])
1436
+ const manifest = JSON.parse(output)
1437
+ expect(manifest.commands.map((c: any) => c.name).sort()).toEqual(['auth login', 'auth logout'])
1438
+ })
1424
1439
  })
1425
1440
 
1426
1441
  describe('--schema', () => {
@@ -2220,6 +2235,129 @@ describe('help', () => {
2220
2235
  `)
2221
2236
  })
2222
2237
 
2238
+ test('banner is printed before root help', async () => {
2239
+ const cli = Cli.create({
2240
+ name: 'mycli',
2241
+ banner: () => ' status: all good',
2242
+ })
2243
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2244
+
2245
+ const { output } = await serve(cli, [])
2246
+ expect(output).toContain('status: all good')
2247
+ expect(output.indexOf('status: all good')).toBeLessThan(output.indexOf('mycli'))
2248
+ })
2249
+
2250
+ test('async banner is supported', async () => {
2251
+ const cli = Cli.create({
2252
+ name: 'mycli',
2253
+ banner: async () => ' async banner',
2254
+ })
2255
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2256
+
2257
+ const { output } = await serve(cli, [])
2258
+ expect(output).toContain('async banner')
2259
+ })
2260
+
2261
+ test('banner returning undefined shows only help', async () => {
2262
+ const cli = Cli.create({
2263
+ name: 'mycli',
2264
+ banner: () => undefined,
2265
+ })
2266
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2267
+
2268
+ const { output } = await serve(cli, [])
2269
+ expect(output).toMatch(/^mycli/)
2270
+ })
2271
+
2272
+ test('banner errors are swallowed', async () => {
2273
+ const cli = Cli.create({
2274
+ name: 'mycli',
2275
+ banner: () => {
2276
+ throw new Error('boom')
2277
+ },
2278
+ })
2279
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2280
+
2281
+ const { output } = await serve(cli, [])
2282
+ expect(output).toMatch(/^mycli/)
2283
+ expect(output).not.toContain('boom')
2284
+ })
2285
+
2286
+ test('banner is skipped for subcommands', async () => {
2287
+ const cli = Cli.create({
2288
+ name: 'mycli',
2289
+ banner: () => 'BANNER',
2290
+ })
2291
+ cli.command('ping', {
2292
+ description: 'Health check',
2293
+ run: () => ({ pong: true }),
2294
+ output: z.object({ pong: z.boolean() }),
2295
+ })
2296
+
2297
+ const { output } = await serve(cli, ['ping'])
2298
+ expect(output).not.toContain('BANNER')
2299
+ })
2300
+
2301
+ test('banner with mode "agent" shows in non-TTY', async () => {
2302
+ const cli = Cli.create({
2303
+ name: 'mycli',
2304
+ banner: { render: () => 'AGENT BANNER', mode: 'agent' },
2305
+ })
2306
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2307
+
2308
+ const { output } = await serve(cli, [])
2309
+ expect(output).toContain('AGENT BANNER')
2310
+ })
2311
+
2312
+ test('banner with mode "human" is skipped in non-TTY', async () => {
2313
+ const cli = Cli.create({
2314
+ name: 'mycli',
2315
+ banner: { render: () => 'HUMAN BANNER', mode: 'human' },
2316
+ })
2317
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2318
+
2319
+ const { output } = await serve(cli, [])
2320
+ expect(output).not.toContain('HUMAN BANNER')
2321
+ })
2322
+
2323
+ test('banner object with default mode shows for all', async () => {
2324
+ const cli = Cli.create({
2325
+ name: 'mycli',
2326
+ banner: { render: () => 'ALL BANNER' },
2327
+ })
2328
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2329
+
2330
+ const { output } = await serve(cli, [])
2331
+ expect(output).toContain('ALL BANNER')
2332
+ })
2333
+
2334
+ test('banner is skipped for --help flag', async () => {
2335
+ const cli = Cli.create({
2336
+ name: 'mycli',
2337
+ banner: () => 'BANNER',
2338
+ })
2339
+ cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2340
+
2341
+ const { output } = await serve(cli, ['--help'])
2342
+ expect(output).not.toContain('BANNER')
2343
+ })
2344
+
2345
+ test('banner is printed before root command help for missing required args', async () => {
2346
+ ;(process.stdout as any).isTTY = true
2347
+ const cli = Cli.create('fetch', {
2348
+ banner: () => 'BANNER',
2349
+ description: 'Fetch a URL',
2350
+ args: z.object({ url: z.string().describe('URL to fetch') }),
2351
+ run: ({ args }) => args.url,
2352
+ })
2353
+
2354
+ const { output, exitCode } = await serve(cli, [])
2355
+ ;(process.stdout as any).isTTY = false
2356
+ expect(exitCode).toBeUndefined()
2357
+ expect(output).toContain('BANNER')
2358
+ expect(output.indexOf('BANNER')).toBeLessThan(output.indexOf('fetch — Fetch a URL'))
2359
+ })
2360
+
2223
2361
  test('--help on leaf shows command help', async () => {
2224
2362
  const cli = Cli.create('tool')
2225
2363
  cli.command('greet', {
@@ -3072,12 +3210,14 @@ describe('outputPolicy', () => {
3072
3210
  async *run() {
3073
3211
  yield { step: 1 }
3074
3212
  yield { step: 2 }
3213
+ yield { expiry: 2461152330n }
3075
3214
  },
3076
3215
  })
3077
3216
 
3078
3217
  const { output } = await serve(cli, ['stream'])
3079
3218
  expect(output).toContain('{"type":"chunk","data":{"step":1}}')
3080
3219
  expect(output).toContain('{"type":"chunk","data":{"step":2}}')
3220
+ expect(output).toContain('{"type":"chunk","data":{"expiry":"2461152330"}}')
3081
3221
  })
3082
3222
 
3083
3223
  test('e2e: realistic multi-level CLI with mixed policies', async () => {
@@ -3654,6 +3794,57 @@ test('streaming: generator throws in buffered mode', async () => {
3654
3794
  expect(output).toContain('generator exploded')
3655
3795
  })
3656
3796
 
3797
+ test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => {
3798
+ const cli = Cli.create('test')
3799
+ cli.command('limited', {
3800
+ async *run() {
3801
+ yield { step: 1 }
3802
+ throw new Errors.IncurError({
3803
+ code: 'RATE_LIMITED',
3804
+ message: 'too fast',
3805
+ retryable: true,
3806
+ })
3807
+ },
3808
+ })
3809
+
3810
+ const jsonl = await serve(cli, ['limited', '--format', 'jsonl'])
3811
+ const jsonlLines = jsonl.output
3812
+ .trim()
3813
+ .split('\n')
3814
+ .map((line) => JSON.parse(line))
3815
+ expect(jsonl.exitCode).toBe(1)
3816
+ expect(jsonlLines[1]).toMatchInlineSnapshot(`
3817
+ {
3818
+ "error": {
3819
+ "code": "RATE_LIMITED",
3820
+ "message": "too fast",
3821
+ "retryable": true,
3822
+ },
3823
+ "ok": false,
3824
+ "type": "error",
3825
+ }
3826
+ `)
3827
+
3828
+ const json = await serve(cli, ['limited', '--full-output', '--format', 'json'])
3829
+ const body = JSON.parse(json.output)
3830
+ body.meta.duration = '<stripped>'
3831
+ expect(json.exitCode).toBe(1)
3832
+ expect(body).toMatchInlineSnapshot(`
3833
+ {
3834
+ "error": {
3835
+ "code": "RATE_LIMITED",
3836
+ "message": "too fast",
3837
+ "retryable": true,
3838
+ },
3839
+ "meta": {
3840
+ "command": "limited",
3841
+ "duration": "<stripped>",
3842
+ },
3843
+ "ok": false,
3844
+ }
3845
+ `)
3846
+ })
3847
+
3657
3848
  test('streaming: generator returns error in buffered mode', async () => {
3658
3849
  const cli = Cli.create('test')
3659
3850
  cli.command('fail', {
@@ -3782,6 +3973,42 @@ test('--llms includes hint in skill output', async () => {
3782
3973
  expect(output).toContain('Always confirm before deploying to production')
3783
3974
  })
3784
3975
 
3976
+ test('--llms appends confirmation hint for destructive commands', async () => {
3977
+ const cli = Cli.create('test')
3978
+ cli.command('destroy', {
3979
+ description: 'Destroy the app',
3980
+ destructive: true,
3981
+ hint: 'Deletes production resources.',
3982
+ run: () => ({}),
3983
+ })
3984
+ cli.command('status', {
3985
+ description: 'Show status',
3986
+ hint: 'Read-only status check.',
3987
+ run: () => ({}),
3988
+ })
3989
+
3990
+ const { output } = await serve(cli, ['--llms-full'])
3991
+ expect(output).toContain(
3992
+ 'Deletes production resources. Confirm with the user before executing this destructive command.',
3993
+ )
3994
+ expect(output).toContain('Read-only status check.')
3995
+ expect(output).not.toContain(
3996
+ 'Read-only status check. Confirm with the user before executing this destructive command.',
3997
+ )
3998
+ })
3999
+
4000
+ test('--llms treats MCP destructiveHint as destructive', async () => {
4001
+ const cli = Cli.create('test')
4002
+ cli.command('deploy', {
4003
+ description: 'Deploy the app',
4004
+ mcp: { annotations: { destructiveHint: true } },
4005
+ run: () => ({}),
4006
+ })
4007
+
4008
+ const { output } = await serve(cli, ['--llms-full'])
4009
+ expect(output).toContain('Confirm with the user before executing this destructive command.')
4010
+ })
4011
+
3785
4012
  describe('fetch', async () => {
3786
4013
  const { app } = await import('../test/fixtures/hono-api.js')
3787
4014
 
@@ -4051,13 +4278,95 @@ describe('--filter-output', () => {
4051
4278
  })
4052
4279
  })
4053
4280
 
4054
- async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
4281
+ describe('Command.execute', () => {
4282
+ test.each([
4283
+ {
4284
+ name: 'split',
4285
+ command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) },
4286
+ inputOptions: { name: 123 },
4287
+ path: 'name',
4288
+ parseMode: 'split' as const,
4289
+ },
4290
+ {
4291
+ name: 'flat',
4292
+ command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) },
4293
+ inputOptions: { id: 123 },
4294
+ path: 'id',
4295
+ parseMode: 'flat' as const,
4296
+ },
4297
+ ])('$name mode returns validation fieldErrors for invalid command input', async (c) => {
4298
+ const result = await Command.execute(c.command, {
4299
+ agent: true,
4300
+ argv: [],
4301
+ format: 'json',
4302
+ formatExplicit: false,
4303
+ inputOptions: c.inputOptions,
4304
+ name: 'test',
4305
+ parseMode: c.parseMode,
4306
+ path: 'users',
4307
+ version: undefined,
4308
+ })
4309
+
4310
+ expect(result).toMatchObject({
4311
+ ok: false,
4312
+ error: {
4313
+ code: 'VALIDATION_ERROR',
4314
+ fieldErrors: [
4315
+ {
4316
+ code: 'invalid_type',
4317
+ missing: false,
4318
+ path: c.path,
4319
+ },
4320
+ ],
4321
+ },
4322
+ })
4323
+ })
4324
+
4325
+ test('does not normalize handler-thrown Zod errors as command input', async () => {
4326
+ const result = await Command.execute(
4327
+ {
4328
+ run() {
4329
+ z.object({ name: z.string() }).parse({ name: 123 })
4330
+ },
4331
+ },
4332
+ {
4333
+ agent: true,
4334
+ argv: [],
4335
+ format: 'json',
4336
+ formatExplicit: false,
4337
+ inputOptions: {},
4338
+ name: 'test',
4339
+ path: 'users',
4340
+ version: undefined,
4341
+ },
4342
+ )
4343
+
4344
+ expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } })
4345
+ expect(result).not.toHaveProperty('error.fieldErrors')
4346
+ })
4347
+ })
4348
+
4349
+ async function fetchJson(cli: Cli.Cli<any, any, any, any>, req: Request) {
4055
4350
  const res = await cli.fetch(req)
4056
4351
  const body = await res.json()
4057
4352
  body.meta.duration = '<stripped>'
4058
4353
  return { status: res.status, body }
4059
4354
  }
4060
4355
 
4356
+ async function fetchNdjson(cli: Cli.Cli<any, any, any>, req: Request) {
4357
+ const res = await cli.fetch(req)
4358
+ const lines = (await res.text())
4359
+ .trim()
4360
+ .split('\n')
4361
+ .map((line) => JSON.parse(line))
4362
+ for (const line of lines)
4363
+ if (line.meta?.duration) {
4364
+ expect(line.meta.duration).toMatch(/^\d+ms$/)
4365
+ line.meta.duration = '<stripped>'
4366
+ }
4367
+ return { status: res.status, contentType: res.headers.get('content-type'), lines }
4368
+ }
4369
+
4061
4370
  describe('fetch', () => {
4062
4371
  test('GET /health → 200', async () => {
4063
4372
  const cli = Cli.create('test')
@@ -4201,6 +4510,16 @@ describe('fetch', () => {
4201
4510
  `)
4202
4511
  })
4203
4512
 
4513
+ test('serializes bigint values in command responses', async () => {
4514
+ const cli = Cli.create('test')
4515
+ cli.command('whois', {
4516
+ output: z.object({ expiry: z.bigint() }),
4517
+ run: () => ({ expiry: 2461152330n }),
4518
+ })
4519
+ const { body } = await fetchJson(cli, new Request('http://localhost/whois'))
4520
+ expect(body.data).toEqual({ expiry: '2461152330' })
4521
+ })
4522
+
4204
4523
  test('trailing path segments → positional args', async () => {
4205
4524
  const cli = Cli.create('test')
4206
4525
  cli.command('users', {
@@ -4256,6 +4575,61 @@ describe('fetch', () => {
4256
4575
  expect(status).toBe(400)
4257
4576
  expect(body.ok).toBe(false)
4258
4577
  expect(body.error.code).toBe('VALIDATION_ERROR')
4578
+ expect(body.error.fieldErrors).toMatchObject([{ missing: true, path: 'id' }])
4579
+ })
4580
+
4581
+ test('validation error includes fieldErrors for body options', async () => {
4582
+ const cli = Cli.create('test')
4583
+ cli.command('users', {
4584
+ options: z.object({ name: z.string() }),
4585
+ run: (c) => ({ name: c.options.name }),
4586
+ })
4587
+ const { status, body } = await fetchJson(
4588
+ cli,
4589
+ new Request('http://localhost/users', {
4590
+ method: 'POST',
4591
+ headers: { 'content-type': 'application/json' },
4592
+ body: JSON.stringify({ name: 123 }),
4593
+ }),
4594
+ )
4595
+ expect(status).toBe(400)
4596
+ expect(body.ok).toBe(false)
4597
+ expect(body.error).toMatchObject({
4598
+ code: 'VALIDATION_ERROR',
4599
+ fieldErrors: [{ code: 'invalid_type', missing: false, path: 'name' }],
4600
+ })
4601
+ })
4602
+
4603
+ test('object validation error includes fieldErrors', async () => {
4604
+ const cli = Cli.create('test')
4605
+ cli.command('users', {
4606
+ options: z.object({ name: z.string() }),
4607
+ run: (c) => ({ name: c.options.name }),
4608
+ })
4609
+
4610
+ const { status, body } = await fetchJson(
4611
+ cli,
4612
+ new Request('http://localhost/users', {
4613
+ method: 'POST',
4614
+ headers: { 'content-type': 'application/json' },
4615
+ body: JSON.stringify({ name: 123 }),
4616
+ }),
4617
+ )
4618
+
4619
+ expect(status).toBe(400)
4620
+ expect(body).toMatchObject({
4621
+ ok: false,
4622
+ error: {
4623
+ code: 'VALIDATION_ERROR',
4624
+ fieldErrors: [
4625
+ {
4626
+ code: 'invalid_type',
4627
+ missing: false,
4628
+ path: 'name',
4629
+ },
4630
+ ],
4631
+ },
4632
+ })
4259
4633
  })
4260
4634
 
4261
4635
  test('thrown error → 500', async () => {
@@ -4288,87 +4662,407 @@ describe('fetch', () => {
4288
4662
  cli.command('stream', {
4289
4663
  async *run() {
4290
4664
  yield { progress: 1 }
4291
- yield { progress: 2 }
4665
+ yield { expiry: 2461152330n }
4292
4666
  return { done: true }
4293
4667
  },
4294
4668
  })
4295
- 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,
4669
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4670
+ {
4671
+ "contentType": "application/x-ndjson",
4672
+ "lines": [
4673
+ {
4674
+ "data": {
4675
+ "progress": 1,
4676
+ },
4677
+ "type": "chunk",
4308
4678
  },
4309
- "type": "chunk",
4310
- },
4311
- {
4312
- "data": {
4313
- "progress": 2,
4679
+ {
4680
+ "data": {
4681
+ "expiry": "2461152330",
4682
+ },
4683
+ "type": "chunk",
4314
4684
  },
4315
- "type": "chunk",
4316
- },
4317
- {
4318
- "meta": {
4319
- "command": "stream",
4685
+ {
4686
+ "meta": {
4687
+ "command": "stream",
4688
+ "duration": "<stripped>",
4689
+ },
4690
+ "ok": true,
4691
+ "type": "done",
4320
4692
  },
4321
- "ok": true,
4322
- "type": "done",
4323
- },
4324
- ]
4693
+ ],
4694
+ "status": 200,
4695
+ }
4325
4696
  `)
4326
4697
  })
4327
4698
 
4328
- test('middleware sets var command sees it', async () => {
4329
- const cli = Cli.create('test', {
4330
- vars: z.object({ user: z.string().default('anonymous') }),
4331
- })
4332
- cli.use(async (c, next) => {
4333
- c.set('user', 'alice')
4699
+ test('streaming response preserves returned ok CTA through middleware', async () => {
4700
+ const cli = Cli.create('test')
4701
+ cli.use(async (_c, next) => {
4334
4702
  await next()
4335
4703
  })
4336
- cli.command('whoami', {
4337
- run: (c) => ({ user: c.var.user }),
4704
+ cli.command('stream', {
4705
+ async *run(c) {
4706
+ yield { progress: 1 }
4707
+ return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } })
4708
+ },
4338
4709
  })
4339
- expect(await fetchJson(cli, new Request('http://localhost/whoami'))).toMatchInlineSnapshot(`
4710
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4340
4711
  {
4341
- "body": {
4342
- "data": {
4343
- "user": "alice",
4712
+ "contentType": "application/x-ndjson",
4713
+ "lines": [
4714
+ {
4715
+ "data": {
4716
+ "progress": 1,
4717
+ },
4718
+ "type": "chunk",
4344
4719
  },
4345
- "meta": {
4346
- "command": "whoami",
4347
- "duration": "<stripped>",
4720
+ {
4721
+ "meta": {
4722
+ "command": "stream",
4723
+ "cta": {
4724
+ "commands": [
4725
+ {
4726
+ "command": "test next",
4727
+ },
4728
+ ],
4729
+ "description": "Next steps:",
4730
+ },
4731
+ "duration": "<stripped>",
4732
+ },
4733
+ "ok": true,
4734
+ "type": "done",
4348
4735
  },
4349
- "ok": true,
4350
- },
4736
+ ],
4351
4737
  "status": 200,
4352
4738
  }
4353
4739
  `)
4354
4740
  })
4355
4741
 
4356
- test('middleware error error response', async () => {
4742
+ test('streaming response handles terminal-only sentinel returns through middleware', async () => {
4743
+ const order: string[] = []
4357
4744
  const cli = Cli.create('test')
4358
- cli.use((c) => {
4359
- c.error({ code: 'UNAUTHORIZED', message: 'not allowed' })
4745
+ cli.use(async (c, next) => {
4746
+ order.push(`before:${c.command}`)
4747
+ await next()
4748
+ order.push(`after:${c.command}`)
4360
4749
  })
4361
- cli.command('secret', { run: () => ({ secret: true }) })
4362
- expect(await fetchJson(cli, new Request('http://localhost/secret'))).toMatchInlineSnapshot(`
4363
- {
4364
- "body": {
4365
- "error": {
4366
- "code": "UNAUTHORIZED",
4367
- "message": "not allowed",
4368
- },
4369
- "meta": {
4370
- "command": "secret",
4371
- "duration": "<stripped>",
4750
+ const sub = Cli.create('ops')
4751
+ sub.command('ok', {
4752
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
4753
+ async *run(c) {
4754
+ return c.ok(
4755
+ { ignored: true },
4756
+ { cta: { commands: [{ command: 'next', description: 'Continue' }] } },
4757
+ )
4758
+ },
4759
+ })
4760
+ sub.command('fail', {
4761
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
4762
+ async *run(c) {
4763
+ return c.error({
4764
+ code: 'EMPTY_FAIL',
4765
+ cta: { commands: ['retry'], description: 'Recover with:' },
4766
+ message: 'failed before chunks',
4767
+ retryable: true,
4768
+ })
4769
+ },
4770
+ })
4771
+ cli.command(sub)
4772
+
4773
+ const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok'))
4774
+ expect(ok).toMatchInlineSnapshot(`
4775
+ {
4776
+ "contentType": "application/x-ndjson",
4777
+ "lines": [
4778
+ {
4779
+ "meta": {
4780
+ "command": "ops ok",
4781
+ "cta": {
4782
+ "commands": [
4783
+ {
4784
+ "command": "test next",
4785
+ "description": "Continue",
4786
+ },
4787
+ ],
4788
+ "description": "Suggested command:",
4789
+ },
4790
+ "duration": "<stripped>",
4791
+ },
4792
+ "ok": true,
4793
+ "type": "done",
4794
+ },
4795
+ ],
4796
+ "status": 200,
4797
+ }
4798
+ `)
4799
+ expect(ok.lines[0]).not.toHaveProperty('data')
4800
+
4801
+ expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(`
4802
+ {
4803
+ "contentType": "application/x-ndjson",
4804
+ "lines": [
4805
+ {
4806
+ "error": {
4807
+ "code": "EMPTY_FAIL",
4808
+ "message": "failed before chunks",
4809
+ "retryable": true,
4810
+ },
4811
+ "meta": {
4812
+ "command": "ops fail",
4813
+ "cta": {
4814
+ "commands": [
4815
+ {
4816
+ "command": "test retry",
4817
+ },
4818
+ ],
4819
+ "description": "Recover with:",
4820
+ },
4821
+ "duration": "<stripped>",
4822
+ },
4823
+ "ok": false,
4824
+ "type": "error",
4825
+ },
4826
+ ],
4827
+ "status": 200,
4828
+ }
4829
+ `)
4830
+ expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail'])
4831
+ })
4832
+
4833
+ test('streaming response represents returned error as terminal error', async () => {
4834
+ const cli = Cli.create('test')
4835
+ cli.command('stream', {
4836
+ async *run(c) {
4837
+ yield { progress: 1 }
4838
+ return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true })
4839
+ },
4840
+ })
4841
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4842
+ {
4843
+ "contentType": "application/x-ndjson",
4844
+ "lines": [
4845
+ {
4846
+ "data": {
4847
+ "progress": 1,
4848
+ },
4849
+ "type": "chunk",
4850
+ },
4851
+ {
4852
+ "error": {
4853
+ "code": "STREAM_FAIL",
4854
+ "message": "failed late",
4855
+ "retryable": true,
4856
+ },
4857
+ "meta": {
4858
+ "command": "stream",
4859
+ "duration": "<stripped>",
4860
+ },
4861
+ "ok": false,
4862
+ "type": "error",
4863
+ },
4864
+ ],
4865
+ "status": 200,
4866
+ }
4867
+ `)
4868
+ })
4869
+
4870
+ test('streaming response represents yielded error as terminal error', async () => {
4871
+ let closed = false
4872
+ const cli = Cli.create('test')
4873
+ cli.command('stream', {
4874
+ async *run(c) {
4875
+ try {
4876
+ yield { progress: 1 }
4877
+ yield c.error({ code: 'STREAM_FAIL', message: 'failed now' })
4878
+ yield { progress: 2 }
4879
+ } finally {
4880
+ closed = true
4881
+ }
4882
+ },
4883
+ })
4884
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4885
+ {
4886
+ "contentType": "application/x-ndjson",
4887
+ "lines": [
4888
+ {
4889
+ "data": {
4890
+ "progress": 1,
4891
+ },
4892
+ "type": "chunk",
4893
+ },
4894
+ {
4895
+ "error": {
4896
+ "code": "STREAM_FAIL",
4897
+ "message": "failed now",
4898
+ },
4899
+ "meta": {
4900
+ "command": "stream",
4901
+ "duration": "<stripped>",
4902
+ },
4903
+ "ok": false,
4904
+ "type": "error",
4905
+ },
4906
+ ],
4907
+ "status": 200,
4908
+ }
4909
+ `)
4910
+ expect(closed).toBe(true)
4911
+ })
4912
+
4913
+ test('streaming response cancellation unwinds generator and middleware', async () => {
4914
+ let resolveAfter = () => {}
4915
+ const after = new Promise<void>((resolve) => {
4916
+ resolveAfter = resolve
4917
+ })
4918
+ const order: string[] = []
4919
+ const cli = Cli.create('test')
4920
+ cli.use(async (_c, next) => {
4921
+ order.push('mw:before')
4922
+ await next()
4923
+ order.push('mw:after')
4924
+ resolveAfter()
4925
+ })
4926
+ cli.command('stream', {
4927
+ async *run() {
4928
+ try {
4929
+ order.push('stream:yield')
4930
+ yield { progress: 1 }
4931
+ while (true) yield { progress: 2 }
4932
+ } finally {
4933
+ order.push('stream:finally')
4934
+ }
4935
+ },
4936
+ })
4937
+ const res = await cli.fetch(new Request('http://localhost/stream'))
4938
+ const reader = res.body!.getReader()
4939
+ await reader.read()
4940
+ await reader.cancel()
4941
+ await after
4942
+ expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after'])
4943
+ })
4944
+
4945
+ test('streaming response thrown error includes terminal duration metadata', async () => {
4946
+ const cli = Cli.create('test')
4947
+ cli.command('stream', {
4948
+ async *run() {
4949
+ yield { progress: 1 }
4950
+ throw new Error('boom')
4951
+ },
4952
+ })
4953
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4954
+ {
4955
+ "contentType": "application/x-ndjson",
4956
+ "lines": [
4957
+ {
4958
+ "data": {
4959
+ "progress": 1,
4960
+ },
4961
+ "type": "chunk",
4962
+ },
4963
+ {
4964
+ "error": {
4965
+ "code": "UNKNOWN",
4966
+ "message": "boom",
4967
+ },
4968
+ "meta": {
4969
+ "command": "stream",
4970
+ "duration": "<stripped>",
4971
+ },
4972
+ "ok": false,
4973
+ "type": "error",
4974
+ },
4975
+ ],
4976
+ "status": 200,
4977
+ }
4978
+ `)
4979
+ })
4980
+
4981
+ test('streaming response thrown IncurError preserves code and retryable metadata', async () => {
4982
+ const cli = Cli.create('test')
4983
+ cli.command('stream', {
4984
+ async *run() {
4985
+ yield { progress: 1 }
4986
+ throw new Errors.IncurError({
4987
+ code: 'RATE_LIMITED',
4988
+ message: 'too fast',
4989
+ retryable: true,
4990
+ })
4991
+ },
4992
+ })
4993
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
4994
+ {
4995
+ "contentType": "application/x-ndjson",
4996
+ "lines": [
4997
+ {
4998
+ "data": {
4999
+ "progress": 1,
5000
+ },
5001
+ "type": "chunk",
5002
+ },
5003
+ {
5004
+ "error": {
5005
+ "code": "RATE_LIMITED",
5006
+ "message": "too fast",
5007
+ "retryable": true,
5008
+ },
5009
+ "meta": {
5010
+ "command": "stream",
5011
+ "duration": "<stripped>",
5012
+ },
5013
+ "ok": false,
5014
+ "type": "error",
5015
+ },
5016
+ ],
5017
+ "status": 200,
5018
+ }
5019
+ `)
5020
+ })
5021
+
5022
+ test('middleware sets var → command sees it', async () => {
5023
+ const cli = Cli.create('test', {
5024
+ vars: z.object({ user: z.string().default('anonymous') }),
5025
+ })
5026
+ cli.use(async (c, next) => {
5027
+ c.set('user', 'alice')
5028
+ await next()
5029
+ })
5030
+ cli.command('whoami', {
5031
+ run: (c) => ({ user: c.var.user }),
5032
+ })
5033
+ expect(await fetchJson(cli, new Request('http://localhost/whoami'))).toMatchInlineSnapshot(`
5034
+ {
5035
+ "body": {
5036
+ "data": {
5037
+ "user": "alice",
5038
+ },
5039
+ "meta": {
5040
+ "command": "whoami",
5041
+ "duration": "<stripped>",
5042
+ },
5043
+ "ok": true,
5044
+ },
5045
+ "status": 200,
5046
+ }
5047
+ `)
5048
+ })
5049
+
5050
+ test('middleware error → error response', async () => {
5051
+ const cli = Cli.create('test')
5052
+ cli.use((c) => {
5053
+ c.error({ code: 'UNAUTHORIZED', message: 'not allowed' })
5054
+ })
5055
+ cli.command('secret', { run: () => ({ secret: true }) })
5056
+ expect(await fetchJson(cli, new Request('http://localhost/secret'))).toMatchInlineSnapshot(`
5057
+ {
5058
+ "body": {
5059
+ "error": {
5060
+ "code": "UNAUTHORIZED",
5061
+ "message": "not allowed",
5062
+ },
5063
+ "meta": {
5064
+ "command": "secret",
5065
+ "duration": "<stripped>",
4372
5066
  },
4373
5067
  "ok": false,
4374
5068
  },
@@ -4518,8 +5212,12 @@ describe('fetch', () => {
4518
5212
  const sessionId = res.headers.get('mcp-session-id')
4519
5213
  const body = await res.json()
4520
5214
  // Send initialized notification
4521
- await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId!)
4522
- return { sessionId: sessionId!, body }
5215
+ await mcpRequest(
5216
+ cli,
5217
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
5218
+ sessionId ?? undefined,
5219
+ )
5220
+ return { sessionId: sessionId ?? undefined, body }
4523
5221
  }
4524
5222
 
4525
5223
  test('POST /mcp with initialize → valid MCP response', async () => {
@@ -4535,6 +5233,7 @@ describe('fetch', () => {
4535
5233
  },
4536
5234
  })
4537
5235
  expect(res.status).toBe(200)
5236
+ expect(res.headers.get('mcp-session-id')).toBeNull()
4538
5237
  const body = await res.json()
4539
5238
  expect({
4540
5239
  serverInfo: body.result.serverInfo,
@@ -4550,6 +5249,34 @@ describe('fetch', () => {
4550
5249
  `)
4551
5250
  })
4552
5251
 
5252
+ test('POST /mcp with tools/list works without session state', async () => {
5253
+ const cli = mcpCli()
5254
+ await mcpRequest(cli, {
5255
+ jsonrpc: '2.0',
5256
+ id: 1,
5257
+ method: 'initialize',
5258
+ params: {
5259
+ protocolVersion: '2025-03-26',
5260
+ capabilities: {},
5261
+ clientInfo: { name: 'test-client', version: '1.0.0' },
5262
+ },
5263
+ })
5264
+ const res = await mcpRequest(cli, {
5265
+ jsonrpc: '2.0',
5266
+ id: 2,
5267
+ method: 'tools/list',
5268
+ params: {},
5269
+ })
5270
+ expect(res.status).toBe(200)
5271
+ const body = await res.json()
5272
+ expect(body.result.tools.map((t: any) => t.name)).toMatchInlineSnapshot(`
5273
+ [
5274
+ "greet",
5275
+ "ping",
5276
+ ]
5277
+ `)
5278
+ })
5279
+
4553
5280
  test('POST /mcp with tools/list → returns registered tools', async () => {
4554
5281
  const cli = mcpCli()
4555
5282
  const { sessionId } = await initSession(cli)
@@ -4581,6 +5308,37 @@ describe('fetch', () => {
4581
5308
  `)
4582
5309
  })
4583
5310
 
5311
+ test('GET /mcp returns method not allowed in stateless mode', async () => {
5312
+ const cli = mcpCli()
5313
+ const res = await cli.fetch(
5314
+ new Request('http://localhost/mcp', {
5315
+ method: 'GET',
5316
+ headers: { accept: 'text/event-stream' },
5317
+ }),
5318
+ )
5319
+ expect(res.status).toBe(405)
5320
+ expect(res.headers.get('allow')).toBe('POST')
5321
+ expect(await res.text()).toBe('')
5322
+ })
5323
+
5324
+ test('mcp.stateless false keeps stateful session handling', async () => {
5325
+ const cli = Cli.create('test', { version: '1.0.0', mcp: { stateless: false } })
5326
+ cli.command('ping', {
5327
+ description: 'Ping',
5328
+ run: () => ({ pong: true }),
5329
+ })
5330
+ const { sessionId } = await initSession(cli)
5331
+ expect(sessionId).toEqual(expect.any(String))
5332
+
5333
+ const res = await mcpRequest(cli, {
5334
+ jsonrpc: '2.0',
5335
+ id: 2,
5336
+ method: 'tools/list',
5337
+ params: {},
5338
+ })
5339
+ expect(res.status).toBe(400)
5340
+ })
5341
+
4584
5342
  test('POST /mcp with tools/call → executes command', async () => {
4585
5343
  const cli = mcpCli()
4586
5344
  const { sessionId } = await initSession(cli)
@@ -4708,6 +5466,296 @@ describe('displayName', () => {
4708
5466
  })
4709
5467
  })
4710
5468
 
5469
+ describe('globals', () => {
5470
+ test('globals are parsed and available in middleware', async () => {
5471
+ const cli = Cli.create('test', {
5472
+ globals: z.object({ rpcUrl: z.string() }),
5473
+ vars: z.object({ rpcUrl: z.string().default('') }),
5474
+ })
5475
+ .use(async (c, next) => {
5476
+ c.set('rpcUrl', c.globals.rpcUrl)
5477
+ await next()
5478
+ })
5479
+ .command('ping', {
5480
+ run(c) {
5481
+ return { url: c.var.rpcUrl }
5482
+ },
5483
+ })
5484
+
5485
+ const { output } = await serve(cli, ['--rpc-url', 'http://example.com', 'ping', '--json'])
5486
+ expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5487
+ })
5488
+
5489
+ test('globals aliases work', async () => {
5490
+ const cli = Cli.create('test', {
5491
+ globals: z.object({ rpcUrl: z.string() }),
5492
+ globalAlias: { rpcUrl: 'r' },
5493
+ vars: z.object({ rpcUrl: z.string().default('') }),
5494
+ })
5495
+ .use(async (c, next) => {
5496
+ c.set('rpcUrl', c.globals.rpcUrl)
5497
+ await next()
5498
+ })
5499
+ .command('ping', {
5500
+ run(c) {
5501
+ return { url: c.var.rpcUrl }
5502
+ },
5503
+ })
5504
+
5505
+ const { output } = await serve(cli, ['-r', 'http://example.com', 'ping', '--json'])
5506
+ expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5507
+ })
5508
+
5509
+ test('globals with defaults work when not provided', async () => {
5510
+ const cli = Cli.create('test', {
5511
+ globals: z.object({ chain: z.string().default('mainnet') }),
5512
+ vars: z.object({ chain: z.string().default('') }),
5513
+ })
5514
+ .use(async (c, next) => {
5515
+ c.set('chain', c.globals.chain)
5516
+ await next()
5517
+ })
5518
+ .command('ping', {
5519
+ run(c) {
5520
+ return { chain: c.var.chain }
5521
+ },
5522
+ })
5523
+
5524
+ const { output } = await serve(cli, ['ping', '--json'])
5525
+ expect(JSON.parse(output)).toEqual({ chain: 'mainnet' })
5526
+ })
5527
+
5528
+ test('globals appear in --help output', async () => {
5529
+ const cli = Cli.create('test', {
5530
+ globals: z.object({
5531
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5532
+ }),
5533
+ globalAlias: { rpcUrl: 'r' },
5534
+ }).command('ping', { run: () => ({}) })
5535
+
5536
+ const { output } = await serve(cli, ['--help'])
5537
+ expect(output).toContain('Custom Global Options')
5538
+ expect(output).toContain('--rpc-url')
5539
+ })
5540
+
5541
+ test('informational commands do not require globals', async () => {
5542
+ const cli = Cli.create('test', {
5543
+ version: '1.0.0',
5544
+ globals: z.object({
5545
+ rpcUrl: z.string().describe('RPC endpoint URL'),
5546
+ }),
5547
+ }).command('ping', {
5548
+ args: z.object({ target: z.string() }),
5549
+ run: () => ({}),
5550
+ })
5551
+
5552
+ const help = await serve(cli, ['--help'])
5553
+ expect(help.exitCode).toBeUndefined()
5554
+ expect(help.output).toContain('--rpc-url')
5555
+
5556
+ const schema = await serve(cli, ['ping', '--schema', '--format', 'json'])
5557
+ expect(schema.exitCode).toBeUndefined()
5558
+ expect(JSON.parse(schema.output).globals.properties.rpcUrl).toBeDefined()
5559
+
5560
+ const llms = await serve(cli, ['--llms', '--format', 'json'])
5561
+ expect(llms.exitCode).toBeUndefined()
5562
+ expect(JSON.parse(llms.output).globals.properties.rpcUrl).toBeDefined()
5563
+
5564
+ const version = await serve(cli, ['--version'])
5565
+ expect(version.exitCode).toBeUndefined()
5566
+ expect(version.output).toBe('1.0.0\n')
5567
+ })
5568
+
5569
+ test('globals appear in --llms manifest', async () => {
5570
+ const cli = Cli.create('test', {
5571
+ globals: z.object({
5572
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5573
+ }),
5574
+ }).command('ping', { description: 'Health check', run: () => ({}) })
5575
+
5576
+ const { output } = await serve(cli, ['--llms', '--format', 'json'])
5577
+ const manifest = JSON.parse(output)
5578
+ expect(manifest.globals).toBeDefined()
5579
+ expect(manifest.globals.properties.rpcUrl).toBeDefined()
5580
+ })
5581
+
5582
+ test('globals validation error shows message and exits 1', async () => {
5583
+ const cli = Cli.create('test', {
5584
+ globals: z.object({ limit: z.number() }),
5585
+ }).command('ping', { run: () => ({}) })
5586
+
5587
+ const { output, exitCode } = await serve(cli, ['--limit', 'not-a-number', 'ping'])
5588
+ expect(exitCode).toBe(1)
5589
+ expect(output).toContain('Invalid input')
5590
+ })
5591
+
5592
+ test('globals position is flexible', async () => {
5593
+ const cli = Cli.create('test', {
5594
+ globals: z.object({ rpcUrl: z.string() }),
5595
+ vars: z.object({ rpcUrl: z.string().default('') }),
5596
+ })
5597
+ .use(async (c, next) => {
5598
+ c.set('rpcUrl', c.globals.rpcUrl)
5599
+ await next()
5600
+ })
5601
+ .command('deploy', {
5602
+ run(c) {
5603
+ return { url: c.var.rpcUrl }
5604
+ },
5605
+ })
5606
+
5607
+ const { output } = await serve(cli, ['deploy', '--rpc-url', 'http://x', '--json'])
5608
+ expect(JSON.parse(output)).toEqual({ url: 'http://x' })
5609
+ })
5610
+
5611
+ test('globals conflict with builtins errors at create() time', () => {
5612
+ expect(() =>
5613
+ Cli.create('test', {
5614
+ globals: z.object({ format: z.string() }),
5615
+ }),
5616
+ ).toThrow(/conflicts with a built-in flag/)
5617
+ })
5618
+
5619
+ test('command option conflicting with global errors at command() time', () => {
5620
+ const cli = Cli.create('test', {
5621
+ globals: z.object({ rpcUrl: z.string() }),
5622
+ })
5623
+ expect(() =>
5624
+ cli.command('deploy', {
5625
+ options: z.object({ rpcUrl: z.string() }),
5626
+ run: () => ({}),
5627
+ }),
5628
+ ).toThrow(/conflicts with a global option/)
5629
+ })
5630
+
5631
+ test('mounted root command option conflicting with global errors at command() time', () => {
5632
+ const cli = Cli.create('test', {
5633
+ globals: z.object({ rpcUrl: z.string() }),
5634
+ })
5635
+ const deploy = Cli.create('deploy', {
5636
+ options: z.object({ rpcUrl: z.string() }),
5637
+ run: () => ({}),
5638
+ })
5639
+
5640
+ expect(() => cli.command(deploy)).toThrow(/conflicts with a global option/)
5641
+ })
5642
+
5643
+ test('mounted subcommand option conflicting with global errors at command() time', () => {
5644
+ const cli = Cli.create('test', {
5645
+ globals: z.object({ rpcUrl: z.string() }),
5646
+ })
5647
+ const admin = Cli.create('admin').command('deploy', {
5648
+ options: z.object({ rpcUrl: z.string() }),
5649
+ run: () => ({}),
5650
+ })
5651
+
5652
+ expect(() => cli.command(admin)).toThrow(/conflicts with a global option/)
5653
+ })
5654
+
5655
+ test('boolean globals handle --no- negation', async () => {
5656
+ const cli = Cli.create('test', {
5657
+ globals: z.object({ dryRun: z.boolean().default(true) }),
5658
+ vars: z.object({ dryRun: z.boolean().default(false) }),
5659
+ })
5660
+ .use(async (c, next) => {
5661
+ c.set('dryRun', c.globals.dryRun)
5662
+ await next()
5663
+ })
5664
+ .command('ping', {
5665
+ run(c) {
5666
+ return { dryRun: c.var.dryRun }
5667
+ },
5668
+ })
5669
+
5670
+ const { output } = await serve(cli, ['--no-dry-run', 'ping', '--json'])
5671
+ expect(JSON.parse(output)).toEqual({ dryRun: false })
5672
+ })
5673
+
5674
+ test('parseGlobals error produces clean error output with exit code 1', async () => {
5675
+ const cli = Cli.create('test', {
5676
+ globals: z.object({ rpcUrl: z.string() }),
5677
+ }).command('ping', { run: () => ({}) })
5678
+
5679
+ const { output, exitCode } = await serve(cli, ['--rpc-url'])
5680
+ expect(exitCode).toBe(1)
5681
+ expect(output).toContain('Missing value for flag')
5682
+ })
5683
+
5684
+ test('global alias collision with -h throws at create() time', () => {
5685
+ expect(() =>
5686
+ Cli.create('test', {
5687
+ globals: z.object({ host: z.string().optional() }),
5688
+ globalAlias: { host: 'h' },
5689
+ }),
5690
+ ).toThrow(/conflicts with a built-in short flag/)
5691
+ })
5692
+
5693
+ test('command alias collision with global alias throws at command() time', () => {
5694
+ const cli = Cli.create('test', {
5695
+ globals: z.object({ rpcUrl: z.string().optional() }),
5696
+ globalAlias: { rpcUrl: 'r' },
5697
+ })
5698
+ expect(() =>
5699
+ cli.command('deploy', {
5700
+ options: z.object({ region: z.string().optional() }),
5701
+ alias: { region: 'r' },
5702
+ run: () => ({}),
5703
+ }),
5704
+ ).toThrow(/conflicts with a global alias/)
5705
+ })
5706
+
5707
+ test('globals validation error in agent mode outputs toon format', async () => {
5708
+ ;(process.stdout as any).isTTY = false
5709
+ const cli = Cli.create('test', {
5710
+ globals: z.object({ limit: z.number() }),
5711
+ }).command('ping', { run: () => ({}) })
5712
+
5713
+ const { output, exitCode } = await serve(cli, ['--limit', 'abc', 'ping'])
5714
+ expect(exitCode).toBe(1)
5715
+ expect(output).toContain('UNKNOWN')
5716
+ ;(process.stdout as any).isTTY = true
5717
+ })
5718
+
5719
+ test('globals appear in --schema output', async () => {
5720
+ const cli = Cli.create('test', {
5721
+ globals: z.object({
5722
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5723
+ }),
5724
+ }).command('ping', {
5725
+ args: z.object({ target: z.string() }),
5726
+ run: () => ({}),
5727
+ })
5728
+
5729
+ const { output } = await serve(cli, ['ping', '--schema', '--format', 'json'])
5730
+ const parsed = JSON.parse(output)
5731
+ expect(parsed.globals).toBeDefined()
5732
+ expect(parsed.globals.properties.rpcUrl).toBeDefined()
5733
+ })
5734
+
5735
+ test('globals are available in fetch middleware', async () => {
5736
+ const cli = Cli.create('test', {
5737
+ globals: z.object({ rpcUrl: z.string().default('fallback') }),
5738
+ vars: z.object({ rpcUrl: z.string().default('') }),
5739
+ })
5740
+ .use(async (c, next) => {
5741
+ c.set('rpcUrl', c.globals.rpcUrl)
5742
+ await next()
5743
+ })
5744
+ .command('ping', {
5745
+ options: z.object({ limit: z.coerce.number().default(0) }),
5746
+ run(c) {
5747
+ return { limit: c.options.limit, url: c.var.rpcUrl }
5748
+ },
5749
+ })
5750
+
5751
+ const { body } = await fetchJson(
5752
+ cli,
5753
+ new Request('http://localhost/ping?rpcUrl=http://example.com&limit=3'),
5754
+ )
5755
+ expect(body.data).toEqual({ limit: 3, url: 'http://example.com' })
5756
+ })
5757
+ })
5758
+
4711
5759
  test('--format rejects invalid format values', async () => {
4712
5760
  const cli = Cli.create('test').command('hello', {
4713
5761
  run: (c) => c.ok({ message: 'hi' }),
@@ -4806,3 +5854,54 @@ describe('command aliases', () => {
4806
5854
  expect(output).toContain('updated')
4807
5855
  })
4808
5856
  })
5857
+
5858
+ describe('--mcp', () => {
5859
+ test('mcp.instructions from create() is forwarded to Mcp.serve', async () => {
5860
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
5861
+ try {
5862
+ const cli = Cli.create('test', { mcp: { instructions: 'Always pass --dry-run first.' } })
5863
+ cli.command('ping', { run: () => ({ pong: true }) })
5864
+ await cli.serve(['--mcp'])
5865
+ expect(spy).toHaveBeenCalledOnce()
5866
+ expect(spy.mock.calls[0]![3]).toMatchObject({ instructions: 'Always pass --dry-run first.' })
5867
+ } finally {
5868
+ spy.mockRestore()
5869
+ }
5870
+ })
5871
+
5872
+ test('instructions is omitted from Mcp.serve when not set in create()', async () => {
5873
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
5874
+ try {
5875
+ const cli = Cli.create('test')
5876
+ cli.command('ping', { run: () => ({ pong: true }) })
5877
+ await cli.serve(['--mcp'])
5878
+ expect(spy).toHaveBeenCalledOnce()
5879
+ expect(spy.mock.calls[0]![3]?.instructions).toBeUndefined()
5880
+ } finally {
5881
+ spy.mockRestore()
5882
+ }
5883
+ })
5884
+
5885
+ test('command mcp metadata is forwarded through public command definitions', async () => {
5886
+ const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined)
5887
+ try {
5888
+ const cli = Cli.create('test')
5889
+ cli.command('deploy', {
5890
+ mcp: {
5891
+ annotations: { destructiveHint: true, idempotentHint: false },
5892
+ instructions: 'Require confirmation before production deploys.',
5893
+ },
5894
+ run: () => ({ deployed: true }),
5895
+ })
5896
+ await cli.serve(['--mcp'])
5897
+
5898
+ const commands = spy.mock.calls[0]![2] as Map<string, any>
5899
+ const [tool] = Mcp.collectTools(commands, [])
5900
+ expect(tool?.name).toBe('deploy')
5901
+ expect(tool?.annotations).toEqual({ destructiveHint: true, idempotentHint: false })
5902
+ expect(tool?.instructions).toBe('Require confirmation before production deploys.')
5903
+ } finally {
5904
+ spy.mockRestore()
5905
+ }
5906
+ })
5907
+ })