incur 0.4.8 → 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.
- package/dist/Cli.d.ts +65 -19
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +294 -66
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +2 -1
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +52 -13
- package/dist/Completions.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -5
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +5 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +19 -5
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +21 -0
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +25 -8
- package/dist/Mcp.js.map +1 -1
- package/dist/Parser.d.ts +14 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +127 -1
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +5 -2
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.d.ts.map +1 -1
- package/dist/Skillgen.js +4 -38
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncMcp.d.ts.map +1 -1
- package/dist/SyncMcp.js +75 -8
- package/dist/SyncMcp.js.map +1 -1
- package/dist/SyncSkills.d.ts +9 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +13 -74
- package/dist/SyncSkills.js.map +1 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/internal/command.d.ts +2 -0
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +6 -5
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/cta.d.ts +22 -0
- package/dist/internal/cta.d.ts.map +1 -0
- package/dist/internal/cta.js +25 -0
- package/dist/internal/cta.js.map +1 -0
- package/dist/internal/json.d.ts +5 -0
- package/dist/internal/json.d.ts.map +1 -0
- package/dist/internal/json.js +13 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/yaml.d.ts +12 -0
- package/dist/internal/yaml.d.ts.map +1 -0
- package/dist/internal/yaml.js +20 -0
- package/dist/internal/yaml.js.map +1 -0
- package/dist/middleware.d.ts +8 -4
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test-d.ts +71 -0
- package/src/Cli.test.ts +1164 -65
- package/src/Cli.ts +456 -101
- package/src/Completions.test.ts +84 -0
- package/src/Completions.ts +48 -9
- package/src/Formatter.test.ts +17 -0
- package/src/Formatter.ts +7 -5
- package/src/Help.test.ts +31 -0
- package/src/Help.ts +42 -4
- package/src/Mcp.test.ts +193 -0
- package/src/Mcp.ts +49 -8
- package/src/Parser.test.ts +173 -0
- package/src/Parser.ts +132 -1
- package/src/Skill.test.ts +6 -0
- package/src/Skill.ts +8 -5
- package/src/Skillgen.test.ts +55 -6
- package/src/Skillgen.ts +9 -35
- package/src/SyncMcp.test.ts +89 -0
- package/src/SyncMcp.ts +72 -8
- package/src/SyncSkills.test.ts +78 -1
- package/src/SyncSkills.ts +20 -78
- package/src/bun-compile.test.ts +5 -0
- package/src/e2e.test.ts +162 -8
- package/src/internal/command.ts +8 -4
- package/src/internal/cta.ts +58 -0
- package/src/internal/json.ts +16 -0
- package/src/internal/yaml.ts +23 -0
- package/src/lazy-imports.test.ts +94 -0
- 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
|
|
1389
|
-
expect(output).toContain('test auth
|
|
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
|
-
|
|
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 {
|
|
4665
|
+
yield { expiry: 2461152330n }
|
|
4292
4666
|
return { done: true }
|
|
4293
4667
|
},
|
|
4294
4668
|
})
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
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
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
"
|
|
4679
|
+
{
|
|
4680
|
+
"data": {
|
|
4681
|
+
"expiry": "2461152330",
|
|
4682
|
+
},
|
|
4683
|
+
"type": "chunk",
|
|
4314
4684
|
},
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4685
|
+
{
|
|
4686
|
+
"meta": {
|
|
4687
|
+
"command": "stream",
|
|
4688
|
+
"duration": "<stripped>",
|
|
4689
|
+
},
|
|
4690
|
+
"ok": true,
|
|
4691
|
+
"type": "done",
|
|
4320
4692
|
},
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
]
|
|
4693
|
+
],
|
|
4694
|
+
"status": 200,
|
|
4695
|
+
}
|
|
4325
4696
|
`)
|
|
4326
4697
|
})
|
|
4327
4698
|
|
|
4328
|
-
test('
|
|
4329
|
-
const cli = Cli.create('test'
|
|
4330
|
-
|
|
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('
|
|
4337
|
-
run
|
|
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
|
|
4710
|
+
expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
|
|
4340
4711
|
{
|
|
4341
|
-
"
|
|
4342
|
-
|
|
4343
|
-
|
|
4712
|
+
"contentType": "application/x-ndjson",
|
|
4713
|
+
"lines": [
|
|
4714
|
+
{
|
|
4715
|
+
"data": {
|
|
4716
|
+
"progress": 1,
|
|
4717
|
+
},
|
|
4718
|
+
"type": "chunk",
|
|
4344
4719
|
},
|
|
4345
|
-
|
|
4346
|
-
"
|
|
4347
|
-
|
|
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
|
-
|
|
4350
|
-
},
|
|
4736
|
+
],
|
|
4351
4737
|
"status": 200,
|
|
4352
4738
|
}
|
|
4353
4739
|
`)
|
|
4354
4740
|
})
|
|
4355
4741
|
|
|
4356
|
-
test('
|
|
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
|
-
|
|
4745
|
+
cli.use(async (c, next) => {
|
|
4746
|
+
order.push(`before:${c.command}`)
|
|
4747
|
+
await next()
|
|
4748
|
+
order.push(`after:${c.command}`)
|
|
4360
4749
|
})
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
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(
|
|
4522
|
-
|
|
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
|
+
})
|