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