incur 0.2.0 → 0.2.1
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/README.md +118 -9
- package/SKILL.md +131 -0
- package/dist/Cli.d.ts +16 -4
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +318 -25
- package/dist/Cli.js.map +1 -1
- package/dist/Errors.d.ts +4 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +3 -0
- package/dist/Errors.js.map +1 -1
- package/dist/Filter.d.ts +14 -0
- package/dist/Filter.d.ts.map +1 -0
- package/dist/Filter.js +134 -0
- package/dist/Filter.js.map +1 -0
- package/dist/Help.js +2 -0
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +26 -0
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +2 -2
- package/dist/Mcp.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +8 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test-d.ts +25 -0
- package/src/Cli.test.ts +802 -0
- package/src/Cli.ts +423 -29
- package/src/Errors.ts +5 -0
- package/src/Filter.test.ts +237 -0
- package/src/Filter.ts +139 -0
- package/src/Help.test.ts +14 -0
- package/src/Help.ts +2 -0
- package/src/Mcp.ts +3 -3
- package/src/e2e.test.ts +603 -0
- package/src/index.ts +1 -0
- package/src/middleware.ts +9 -2
package/src/Cli.test.ts
CHANGED
|
@@ -580,6 +580,121 @@ describe('--llms', () => {
|
|
|
580
580
|
})
|
|
581
581
|
})
|
|
582
582
|
|
|
583
|
+
describe('--schema', () => {
|
|
584
|
+
test('returns command schema in toon format', async () => {
|
|
585
|
+
const cli = Cli.create('test')
|
|
586
|
+
cli.command('greet', {
|
|
587
|
+
args: z.object({ name: z.string().describe('Name to greet') }),
|
|
588
|
+
options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
|
|
589
|
+
output: z.object({ message: z.string() }),
|
|
590
|
+
run(c) {
|
|
591
|
+
return { message: `hello ${c.args.name}` }
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
const { output } = await serve(cli, ['greet', '--schema'])
|
|
595
|
+
expect(output).toContain('args')
|
|
596
|
+
expect(output).toContain('options')
|
|
597
|
+
expect(output).toContain('output')
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('returns command schema as JSON', async () => {
|
|
601
|
+
const cli = Cli.create('test')
|
|
602
|
+
cli.command('greet', {
|
|
603
|
+
args: z.object({ name: z.string().describe('Name to greet') }),
|
|
604
|
+
options: z.object({ loud: z.boolean().default(false).describe('Shout') }),
|
|
605
|
+
output: z.object({ message: z.string() }),
|
|
606
|
+
run(c) {
|
|
607
|
+
return { message: `hello ${c.args.name}` }
|
|
608
|
+
},
|
|
609
|
+
})
|
|
610
|
+
const { output } = await serve(cli, ['greet', '--schema', '--format', 'json'])
|
|
611
|
+
expect(JSON.parse(output)).toMatchInlineSnapshot(`
|
|
612
|
+
{
|
|
613
|
+
"args": {
|
|
614
|
+
"additionalProperties": false,
|
|
615
|
+
"properties": {
|
|
616
|
+
"name": {
|
|
617
|
+
"description": "Name to greet",
|
|
618
|
+
"type": "string",
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
"required": [
|
|
622
|
+
"name",
|
|
623
|
+
],
|
|
624
|
+
"type": "object",
|
|
625
|
+
},
|
|
626
|
+
"options": {
|
|
627
|
+
"additionalProperties": false,
|
|
628
|
+
"properties": {
|
|
629
|
+
"loud": {
|
|
630
|
+
"default": false,
|
|
631
|
+
"description": "Shout",
|
|
632
|
+
"type": "boolean",
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
"required": [
|
|
636
|
+
"loud",
|
|
637
|
+
],
|
|
638
|
+
"type": "object",
|
|
639
|
+
},
|
|
640
|
+
"output": {
|
|
641
|
+
"additionalProperties": false,
|
|
642
|
+
"properties": {
|
|
643
|
+
"message": {
|
|
644
|
+
"type": "string",
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
"required": [
|
|
648
|
+
"message",
|
|
649
|
+
],
|
|
650
|
+
"type": "object",
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
`)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
test('on root command', async () => {
|
|
657
|
+
const cli = Cli.create('test', {
|
|
658
|
+
args: z.object({ name: z.string() }),
|
|
659
|
+
output: z.object({ greeting: z.string() }),
|
|
660
|
+
run(c) {
|
|
661
|
+
return { greeting: `hi ${c.args.name}` }
|
|
662
|
+
},
|
|
663
|
+
})
|
|
664
|
+
const { output } = await serve(cli, ['--schema', '--format', 'json'])
|
|
665
|
+
const parsed = JSON.parse(output)
|
|
666
|
+
expect(parsed.args).toBeDefined()
|
|
667
|
+
expect(parsed.output).toBeDefined()
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('on unknown command shows error', async () => {
|
|
671
|
+
const cli = Cli.create('test')
|
|
672
|
+
cli.command('greet', { run: () => ({}) })
|
|
673
|
+
const { output, exitCode } = await serve(cli, ['nope', '--schema'])
|
|
674
|
+
expect(output).toContain("'nope' is not a command")
|
|
675
|
+
expect(exitCode).toBe(1)
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
test('on group shows available commands', async () => {
|
|
679
|
+
const cli = Cli.create('test')
|
|
680
|
+
const pr = Cli.create('pr', { description: 'PR management' }).command('list', {
|
|
681
|
+
description: 'List PRs',
|
|
682
|
+
run: () => ({ items: [] }),
|
|
683
|
+
})
|
|
684
|
+
cli.command(pr)
|
|
685
|
+
const { output } = await serve(cli, ['pr', '--schema'])
|
|
686
|
+
expect(output).toContain('pr')
|
|
687
|
+
expect(output).toContain('list')
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
test('omits empty schema sections', async () => {
|
|
691
|
+
const cli = Cli.create('test')
|
|
692
|
+
cli.command('ping', { run: () => ({ pong: true }) })
|
|
693
|
+
const { output } = await serve(cli, ['ping', '--schema', '--format', 'json'])
|
|
694
|
+
expect(JSON.parse(output)).toMatchInlineSnapshot('{}')
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
|
|
583
698
|
describe('subcommands', () => {
|
|
584
699
|
test('creates a command group with name and description', () => {
|
|
585
700
|
const pr = Cli.create('pr', { description: 'PR management' })
|
|
@@ -739,9 +854,11 @@ describe('subcommands', () => {
|
|
|
739
854
|
list
|
|
740
855
|
|
|
741
856
|
Global Options:
|
|
857
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
742
858
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
743
859
|
--help Show help
|
|
744
860
|
--llms Print LLM-readable manifest
|
|
861
|
+
--schema Show JSON Schema for a command
|
|
745
862
|
--verbose Show full output envelope
|
|
746
863
|
"
|
|
747
864
|
`)
|
|
@@ -1173,10 +1290,12 @@ describe('help', () => {
|
|
|
1173
1290
|
skills add Sync skill files to your agent
|
|
1174
1291
|
|
|
1175
1292
|
Global Options:
|
|
1293
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1176
1294
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1177
1295
|
--help Show help
|
|
1178
1296
|
--llms Print LLM-readable manifest
|
|
1179
1297
|
--mcp Start as MCP stdio server
|
|
1298
|
+
--schema Show JSON Schema for a command
|
|
1180
1299
|
--verbose Show full output envelope
|
|
1181
1300
|
--version Show version
|
|
1182
1301
|
"
|
|
@@ -1206,10 +1325,12 @@ describe('help', () => {
|
|
|
1206
1325
|
skills add Sync skill files to your agent
|
|
1207
1326
|
|
|
1208
1327
|
Global Options:
|
|
1328
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1209
1329
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1210
1330
|
--help Show help
|
|
1211
1331
|
--llms Print LLM-readable manifest
|
|
1212
1332
|
--mcp Start as MCP stdio server
|
|
1333
|
+
--schema Show JSON Schema for a command
|
|
1213
1334
|
--verbose Show full output envelope
|
|
1214
1335
|
--version Show version
|
|
1215
1336
|
"
|
|
@@ -1235,9 +1356,11 @@ describe('help', () => {
|
|
|
1235
1356
|
name Name
|
|
1236
1357
|
|
|
1237
1358
|
Global Options:
|
|
1359
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1238
1360
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1239
1361
|
--help Show help
|
|
1240
1362
|
--llms Print LLM-readable manifest
|
|
1363
|
+
--schema Show JSON Schema for a command
|
|
1241
1364
|
--verbose Show full output envelope
|
|
1242
1365
|
"
|
|
1243
1366
|
`)
|
|
@@ -1264,9 +1387,11 @@ describe('help', () => {
|
|
|
1264
1387
|
list List PRs
|
|
1265
1388
|
|
|
1266
1389
|
Global Options:
|
|
1390
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1267
1391
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1268
1392
|
--help Show help
|
|
1269
1393
|
--llms Print LLM-readable manifest
|
|
1394
|
+
--schema Show JSON Schema for a command
|
|
1270
1395
|
--verbose Show full output envelope
|
|
1271
1396
|
"
|
|
1272
1397
|
`)
|
|
@@ -1354,10 +1479,12 @@ describe('help', () => {
|
|
|
1354
1479
|
skills add Sync skill files to your agent
|
|
1355
1480
|
|
|
1356
1481
|
Global Options:
|
|
1482
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1357
1483
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1358
1484
|
--help Show help
|
|
1359
1485
|
--llms Print LLM-readable manifest
|
|
1360
1486
|
--mcp Start as MCP stdio server
|
|
1487
|
+
--schema Show JSON Schema for a command
|
|
1361
1488
|
--verbose Show full output envelope
|
|
1362
1489
|
--version Show version
|
|
1363
1490
|
"
|
|
@@ -1381,9 +1508,11 @@ describe('help', () => {
|
|
|
1381
1508
|
Run "tool status" to check deployment progress.
|
|
1382
1509
|
|
|
1383
1510
|
Global Options:
|
|
1511
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1384
1512
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1385
1513
|
--help Show help
|
|
1386
1514
|
--llms Print LLM-readable manifest
|
|
1515
|
+
--schema Show JSON Schema for a command
|
|
1387
1516
|
--verbose Show full output envelope
|
|
1388
1517
|
"
|
|
1389
1518
|
`)
|
|
@@ -1471,9 +1600,11 @@ describe('env', () => {
|
|
|
1471
1600
|
Usage: test deploy
|
|
1472
1601
|
|
|
1473
1602
|
Global Options:
|
|
1603
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1474
1604
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1475
1605
|
--help Show help
|
|
1476
1606
|
--llms Print LLM-readable manifest
|
|
1607
|
+
--schema Show JSON Schema for a command
|
|
1477
1608
|
--verbose Show full output envelope
|
|
1478
1609
|
|
|
1479
1610
|
Environment Variables:
|
|
@@ -1504,9 +1635,11 @@ describe('env', () => {
|
|
|
1504
1635
|
Usage: test deploy
|
|
1505
1636
|
|
|
1506
1637
|
Global Options:
|
|
1638
|
+
--filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
|
|
1507
1639
|
--format <toon|json|yaml|md|jsonl> Output format
|
|
1508
1640
|
--help Show help
|
|
1509
1641
|
--llms Print LLM-readable manifest
|
|
1642
|
+
--schema Show JSON Schema for a command
|
|
1510
1643
|
--verbose Show full output envelope
|
|
1511
1644
|
|
|
1512
1645
|
Environment Variables:
|
|
@@ -2104,6 +2237,43 @@ describe('outputPolicy', () => {
|
|
|
2104
2237
|
expect(captured).toEqual({ agent: false, command: 'deploy' })
|
|
2105
2238
|
})
|
|
2106
2239
|
|
|
2240
|
+
test('e2e: middleware and run context expose format metadata', async () => {
|
|
2241
|
+
let mwCaptured:
|
|
2242
|
+
| {
|
|
2243
|
+
format: string
|
|
2244
|
+
formatExplicit: boolean
|
|
2245
|
+
}
|
|
2246
|
+
| undefined
|
|
2247
|
+
let runCaptured:
|
|
2248
|
+
| {
|
|
2249
|
+
format: string
|
|
2250
|
+
formatExplicit: boolean
|
|
2251
|
+
}
|
|
2252
|
+
| undefined
|
|
2253
|
+
|
|
2254
|
+
const cli = Cli.create('test')
|
|
2255
|
+
.use(async (c, next) => {
|
|
2256
|
+
mwCaptured = {
|
|
2257
|
+
format: c.format,
|
|
2258
|
+
formatExplicit: c.formatExplicit,
|
|
2259
|
+
}
|
|
2260
|
+
await next()
|
|
2261
|
+
})
|
|
2262
|
+
.command('deploy', {
|
|
2263
|
+
run(c) {
|
|
2264
|
+
runCaptured = {
|
|
2265
|
+
format: c.format,
|
|
2266
|
+
formatExplicit: c.formatExplicit,
|
|
2267
|
+
}
|
|
2268
|
+
return { ok: true }
|
|
2269
|
+
},
|
|
2270
|
+
})
|
|
2271
|
+
|
|
2272
|
+
await serve(cli, ['deploy', '--format', 'json'])
|
|
2273
|
+
expect(mwCaptured).toEqual({ format: 'json', formatExplicit: true })
|
|
2274
|
+
expect(runCaptured).toEqual({ format: 'json', formatExplicit: true })
|
|
2275
|
+
})
|
|
2276
|
+
|
|
2107
2277
|
test('e2e: middleware works with streaming handlers', async () => {
|
|
2108
2278
|
const order: string[] = []
|
|
2109
2279
|
const cli = Cli.create('test')
|
|
@@ -2404,6 +2574,88 @@ test('streaming: generator returns error in buffered mode', async () => {
|
|
|
2404
2574
|
expect(output).toContain('RET_ERR')
|
|
2405
2575
|
})
|
|
2406
2576
|
|
|
2577
|
+
test('c.error({ exitCode }) uses custom exit code', async () => {
|
|
2578
|
+
const cli = Cli.create('test')
|
|
2579
|
+
cli.command('fail', {
|
|
2580
|
+
run(c) {
|
|
2581
|
+
return c.error({ code: 'AUTH', message: 'not authed', exitCode: 10 })
|
|
2582
|
+
},
|
|
2583
|
+
})
|
|
2584
|
+
|
|
2585
|
+
const { output, exitCode } = await serve(cli, ['fail'])
|
|
2586
|
+
expect(exitCode).toBe(10)
|
|
2587
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2588
|
+
"code: AUTH
|
|
2589
|
+
message: not authed
|
|
2590
|
+
"
|
|
2591
|
+
`)
|
|
2592
|
+
})
|
|
2593
|
+
|
|
2594
|
+
test('c.error() without exitCode defaults to 1', async () => {
|
|
2595
|
+
const cli = Cli.create('test')
|
|
2596
|
+
cli.command('fail', {
|
|
2597
|
+
run(c) {
|
|
2598
|
+
return c.error({ code: 'BAD', message: 'fail' })
|
|
2599
|
+
},
|
|
2600
|
+
})
|
|
2601
|
+
|
|
2602
|
+
const { output, exitCode } = await serve(cli, ['fail'])
|
|
2603
|
+
expect(exitCode).toBe(1)
|
|
2604
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2605
|
+
"code: BAD
|
|
2606
|
+
message: fail
|
|
2607
|
+
"
|
|
2608
|
+
`)
|
|
2609
|
+
})
|
|
2610
|
+
|
|
2611
|
+
test('middleware c.error({ exitCode }) uses custom exit code', async () => {
|
|
2612
|
+
const cli = Cli.create('test')
|
|
2613
|
+
cli.use((c) => {
|
|
2614
|
+
return c.error({ code: 'MW_ERR', message: 'blocked', exitCode: 42 })
|
|
2615
|
+
})
|
|
2616
|
+
cli.command('anything', { run: () => ({}) })
|
|
2617
|
+
|
|
2618
|
+
const { output, exitCode } = await serve(cli, ['anything'])
|
|
2619
|
+
expect(exitCode).toBe(42)
|
|
2620
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2621
|
+
"code: MW_ERR
|
|
2622
|
+
message: blocked
|
|
2623
|
+
"
|
|
2624
|
+
`)
|
|
2625
|
+
})
|
|
2626
|
+
|
|
2627
|
+
test('thrown IncurError with exitCode uses custom exit code', async () => {
|
|
2628
|
+
const cli = Cli.create('test')
|
|
2629
|
+
cli.command('fail', {
|
|
2630
|
+
run() {
|
|
2631
|
+
throw new Errors.IncurError({ code: 'RATE_LIMITED', message: 'too fast', exitCode: 99 })
|
|
2632
|
+
},
|
|
2633
|
+
})
|
|
2634
|
+
|
|
2635
|
+
const { output, exitCode } = await serve(cli, ['fail'])
|
|
2636
|
+
expect(exitCode).toBe(99)
|
|
2637
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2638
|
+
"code: RATE_LIMITED
|
|
2639
|
+
message: too fast
|
|
2640
|
+
retryable: false
|
|
2641
|
+
"
|
|
2642
|
+
`)
|
|
2643
|
+
})
|
|
2644
|
+
|
|
2645
|
+
test('streaming: c.error({ exitCode }) in yield uses custom exit code', async () => {
|
|
2646
|
+
const cli = Cli.create('test')
|
|
2647
|
+
cli.command('fail', {
|
|
2648
|
+
async *run(c) {
|
|
2649
|
+
yield { step: 1 }
|
|
2650
|
+
yield c.error({ code: 'STREAM_ERR', message: 'mid-stream', exitCode: 77 })
|
|
2651
|
+
},
|
|
2652
|
+
})
|
|
2653
|
+
|
|
2654
|
+
const { output, exitCode } = await serve(cli, ['fail', '--format', 'jsonl'])
|
|
2655
|
+
expect(exitCode).toBe(77)
|
|
2656
|
+
expect(output).toContain('STREAM_ERR')
|
|
2657
|
+
})
|
|
2658
|
+
|
|
2407
2659
|
test('deprecated short flag emits warning', async () => {
|
|
2408
2660
|
const cli = Cli.create('app').command('deploy', {
|
|
2409
2661
|
options: z.object({
|
|
@@ -2640,3 +2892,553 @@ describe('fetch', async () => {
|
|
|
2640
2892
|
expect(output).toContain('Proxy to API')
|
|
2641
2893
|
})
|
|
2642
2894
|
})
|
|
2895
|
+
|
|
2896
|
+
describe('--filter-output', () => {
|
|
2897
|
+
test('selects specific keys', async () => {
|
|
2898
|
+
const cli = Cli.create('test')
|
|
2899
|
+
cli.command('user', {
|
|
2900
|
+
run() {
|
|
2901
|
+
return { name: 'alice', age: 30, email: 'alice@example.com' }
|
|
2902
|
+
},
|
|
2903
|
+
})
|
|
2904
|
+
const { output } = await serve(cli, ['user', '--filter-output', 'name,age'])
|
|
2905
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2906
|
+
"name: alice
|
|
2907
|
+
age: 30
|
|
2908
|
+
"
|
|
2909
|
+
`)
|
|
2910
|
+
})
|
|
2911
|
+
|
|
2912
|
+
test('returns scalar for single key', async () => {
|
|
2913
|
+
const cli = Cli.create('test')
|
|
2914
|
+
cli.command('greet', {
|
|
2915
|
+
args: z.object({ name: z.string() }),
|
|
2916
|
+
run(c) {
|
|
2917
|
+
return { message: `hello ${c.args.name}` }
|
|
2918
|
+
},
|
|
2919
|
+
})
|
|
2920
|
+
const { output } = await serve(cli, ['greet', 'world', '--filter-output', 'message'])
|
|
2921
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2922
|
+
"hello world
|
|
2923
|
+
"
|
|
2924
|
+
`)
|
|
2925
|
+
})
|
|
2926
|
+
|
|
2927
|
+
test('dot notation filters nested keys', async () => {
|
|
2928
|
+
const cli = Cli.create('test')
|
|
2929
|
+
cli.command('profile', {
|
|
2930
|
+
run() {
|
|
2931
|
+
return { user: { name: 'alice', email: 'a@b.com' }, status: 'active' }
|
|
2932
|
+
},
|
|
2933
|
+
})
|
|
2934
|
+
const { output } = await serve(cli, ['profile', '--filter-output', 'user.name'])
|
|
2935
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2936
|
+
"user:
|
|
2937
|
+
name: alice
|
|
2938
|
+
"
|
|
2939
|
+
`)
|
|
2940
|
+
})
|
|
2941
|
+
|
|
2942
|
+
test('array slice', async () => {
|
|
2943
|
+
const cli = Cli.create('test')
|
|
2944
|
+
cli.command('list', {
|
|
2945
|
+
run() {
|
|
2946
|
+
return { items: [1, 2, 3, 4, 5] }
|
|
2947
|
+
},
|
|
2948
|
+
})
|
|
2949
|
+
const { output } = await serve(cli, ['list', '--filter-output', 'items[0,3]'])
|
|
2950
|
+
expect(output).toMatchInlineSnapshot(`
|
|
2951
|
+
"items[3]: 1,2,3
|
|
2952
|
+
"
|
|
2953
|
+
`)
|
|
2954
|
+
})
|
|
2955
|
+
|
|
2956
|
+
test('works with --format json', async () => {
|
|
2957
|
+
const cli = Cli.create('test')
|
|
2958
|
+
cli.command('user', {
|
|
2959
|
+
run() {
|
|
2960
|
+
return { name: 'alice', age: 30, email: 'alice@example.com' }
|
|
2961
|
+
},
|
|
2962
|
+
})
|
|
2963
|
+
const { output } = await serve(cli, ['user', '--filter-output', 'name,age', '--format', 'json'])
|
|
2964
|
+
const parsed = JSON.parse(output)
|
|
2965
|
+
expect(parsed).toEqual({ name: 'alice', age: 30 })
|
|
2966
|
+
})
|
|
2967
|
+
})
|
|
2968
|
+
|
|
2969
|
+
async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
|
|
2970
|
+
const res = await cli.fetch(req)
|
|
2971
|
+
const body = await res.json()
|
|
2972
|
+
body.meta.duration = '<stripped>'
|
|
2973
|
+
return { status: res.status, body }
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
describe('fetch', () => {
|
|
2977
|
+
test('GET /health → 200', async () => {
|
|
2978
|
+
const cli = Cli.create('test')
|
|
2979
|
+
cli.command('health', { run: () => ({ ok: true }) })
|
|
2980
|
+
expect(await fetchJson(cli, new Request('http://localhost/health'))).toMatchInlineSnapshot(`
|
|
2981
|
+
{
|
|
2982
|
+
"body": {
|
|
2983
|
+
"data": {
|
|
2984
|
+
"ok": true,
|
|
2985
|
+
},
|
|
2986
|
+
"meta": {
|
|
2987
|
+
"command": "health",
|
|
2988
|
+
"duration": "<stripped>",
|
|
2989
|
+
},
|
|
2990
|
+
"ok": true,
|
|
2991
|
+
},
|
|
2992
|
+
"status": 200,
|
|
2993
|
+
}
|
|
2994
|
+
`)
|
|
2995
|
+
})
|
|
2996
|
+
|
|
2997
|
+
test('GET /unknown → 404', async () => {
|
|
2998
|
+
const cli = Cli.create('test')
|
|
2999
|
+
cli.command('health', { run: () => ({}) })
|
|
3000
|
+
expect(await fetchJson(cli, new Request('http://localhost/unknown'))).toMatchInlineSnapshot(`
|
|
3001
|
+
{
|
|
3002
|
+
"body": {
|
|
3003
|
+
"error": {
|
|
3004
|
+
"code": "COMMAND_NOT_FOUND",
|
|
3005
|
+
"message": "'unknown' is not a command for 'test'.",
|
|
3006
|
+
},
|
|
3007
|
+
"meta": {
|
|
3008
|
+
"command": "unknown",
|
|
3009
|
+
"duration": "<stripped>",
|
|
3010
|
+
},
|
|
3011
|
+
"ok": false,
|
|
3012
|
+
},
|
|
3013
|
+
"status": 404,
|
|
3014
|
+
}
|
|
3015
|
+
`)
|
|
3016
|
+
})
|
|
3017
|
+
|
|
3018
|
+
test('GET / with root command → 200', async () => {
|
|
3019
|
+
const cli = Cli.create('test', { run: () => ({ root: true }) })
|
|
3020
|
+
expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
|
|
3021
|
+
{
|
|
3022
|
+
"body": {
|
|
3023
|
+
"data": {
|
|
3024
|
+
"root": true,
|
|
3025
|
+
},
|
|
3026
|
+
"meta": {
|
|
3027
|
+
"command": "test",
|
|
3028
|
+
"duration": "<stripped>",
|
|
3029
|
+
},
|
|
3030
|
+
"ok": true,
|
|
3031
|
+
},
|
|
3032
|
+
"status": 200,
|
|
3033
|
+
}
|
|
3034
|
+
`)
|
|
3035
|
+
})
|
|
3036
|
+
|
|
3037
|
+
test('GET / without root command → 404', async () => {
|
|
3038
|
+
const cli = Cli.create('test')
|
|
3039
|
+
cli.command('health', { run: () => ({}) })
|
|
3040
|
+
expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
|
|
3041
|
+
{
|
|
3042
|
+
"body": {
|
|
3043
|
+
"error": {
|
|
3044
|
+
"code": "COMMAND_NOT_FOUND",
|
|
3045
|
+
"message": "No root command defined.",
|
|
3046
|
+
},
|
|
3047
|
+
"meta": {
|
|
3048
|
+
"command": "/",
|
|
3049
|
+
"duration": "<stripped>",
|
|
3050
|
+
},
|
|
3051
|
+
"ok": false,
|
|
3052
|
+
},
|
|
3053
|
+
"status": 404,
|
|
3054
|
+
}
|
|
3055
|
+
`)
|
|
3056
|
+
})
|
|
3057
|
+
|
|
3058
|
+
test('GET search params → options', async () => {
|
|
3059
|
+
const cli = Cli.create('test')
|
|
3060
|
+
cli.command('users', {
|
|
3061
|
+
options: z.object({ limit: z.coerce.number().default(10) }),
|
|
3062
|
+
run: (c) => ({ limit: c.options.limit }),
|
|
3063
|
+
})
|
|
3064
|
+
expect(await fetchJson(cli, new Request('http://localhost/users?limit=5'))).toMatchInlineSnapshot(`
|
|
3065
|
+
{
|
|
3066
|
+
"body": {
|
|
3067
|
+
"data": {
|
|
3068
|
+
"limit": 5,
|
|
3069
|
+
},
|
|
3070
|
+
"meta": {
|
|
3071
|
+
"command": "users",
|
|
3072
|
+
"duration": "<stripped>",
|
|
3073
|
+
},
|
|
3074
|
+
"ok": true,
|
|
3075
|
+
},
|
|
3076
|
+
"status": 200,
|
|
3077
|
+
}
|
|
3078
|
+
`)
|
|
3079
|
+
})
|
|
3080
|
+
|
|
3081
|
+
test('POST body → options', async () => {
|
|
3082
|
+
const cli = Cli.create('test')
|
|
3083
|
+
cli.command('users', {
|
|
3084
|
+
options: z.object({ name: z.string() }),
|
|
3085
|
+
run: (c) => ({ created: true, name: c.options.name }),
|
|
3086
|
+
})
|
|
3087
|
+
const req = new Request('http://localhost/users', {
|
|
3088
|
+
method: 'POST',
|
|
3089
|
+
headers: { 'content-type': 'application/json' },
|
|
3090
|
+
body: JSON.stringify({ name: 'Bob' }),
|
|
3091
|
+
})
|
|
3092
|
+
expect(await fetchJson(cli, req)).toMatchInlineSnapshot(`
|
|
3093
|
+
{
|
|
3094
|
+
"body": {
|
|
3095
|
+
"data": {
|
|
3096
|
+
"created": true,
|
|
3097
|
+
"name": "Bob",
|
|
3098
|
+
},
|
|
3099
|
+
"meta": {
|
|
3100
|
+
"command": "users",
|
|
3101
|
+
"duration": "<stripped>",
|
|
3102
|
+
},
|
|
3103
|
+
"ok": true,
|
|
3104
|
+
},
|
|
3105
|
+
"status": 200,
|
|
3106
|
+
}
|
|
3107
|
+
`)
|
|
3108
|
+
})
|
|
3109
|
+
|
|
3110
|
+
test('trailing path segments → positional args', async () => {
|
|
3111
|
+
const cli = Cli.create('test')
|
|
3112
|
+
cli.command('users', {
|
|
3113
|
+
args: z.object({ id: z.coerce.number() }),
|
|
3114
|
+
run: (c) => ({ id: c.args.id }),
|
|
3115
|
+
})
|
|
3116
|
+
expect(await fetchJson(cli, new Request('http://localhost/users/42'))).toMatchInlineSnapshot(`
|
|
3117
|
+
{
|
|
3118
|
+
"body": {
|
|
3119
|
+
"data": {
|
|
3120
|
+
"id": 42,
|
|
3121
|
+
},
|
|
3122
|
+
"meta": {
|
|
3123
|
+
"command": "users",
|
|
3124
|
+
"duration": "<stripped>",
|
|
3125
|
+
},
|
|
3126
|
+
"ok": true,
|
|
3127
|
+
},
|
|
3128
|
+
"status": 200,
|
|
3129
|
+
}
|
|
3130
|
+
`)
|
|
3131
|
+
})
|
|
3132
|
+
|
|
3133
|
+
test('nested command resolution', async () => {
|
|
3134
|
+
const sub = Cli.create('users')
|
|
3135
|
+
sub.command('list', { run: () => ({ users: [] }) })
|
|
3136
|
+
const cli = Cli.create('test')
|
|
3137
|
+
cli.command(sub)
|
|
3138
|
+
expect(await fetchJson(cli, new Request('http://localhost/users/list'))).toMatchInlineSnapshot(`
|
|
3139
|
+
{
|
|
3140
|
+
"body": {
|
|
3141
|
+
"data": {
|
|
3142
|
+
"users": [],
|
|
3143
|
+
},
|
|
3144
|
+
"meta": {
|
|
3145
|
+
"command": "users list",
|
|
3146
|
+
"duration": "<stripped>",
|
|
3147
|
+
},
|
|
3148
|
+
"ok": true,
|
|
3149
|
+
},
|
|
3150
|
+
"status": 200,
|
|
3151
|
+
}
|
|
3152
|
+
`)
|
|
3153
|
+
})
|
|
3154
|
+
|
|
3155
|
+
test('validation error → 400', async () => {
|
|
3156
|
+
const cli = Cli.create('test')
|
|
3157
|
+
cli.command('users', {
|
|
3158
|
+
args: z.object({ id: z.coerce.number() }),
|
|
3159
|
+
run: (c) => ({ id: c.args.id }),
|
|
3160
|
+
})
|
|
3161
|
+
const { status, body } = await fetchJson(cli, new Request('http://localhost/users'))
|
|
3162
|
+
expect(status).toBe(400)
|
|
3163
|
+
expect(body.ok).toBe(false)
|
|
3164
|
+
expect(body.error.code).toBe('VALIDATION_ERROR')
|
|
3165
|
+
})
|
|
3166
|
+
|
|
3167
|
+
test('thrown error → 500', async () => {
|
|
3168
|
+
const cli = Cli.create('test')
|
|
3169
|
+
cli.command('fail', {
|
|
3170
|
+
run() {
|
|
3171
|
+
throw new Error('boom')
|
|
3172
|
+
},
|
|
3173
|
+
})
|
|
3174
|
+
expect(await fetchJson(cli, new Request('http://localhost/fail'))).toMatchInlineSnapshot(`
|
|
3175
|
+
{
|
|
3176
|
+
"body": {
|
|
3177
|
+
"error": {
|
|
3178
|
+
"code": "UNKNOWN",
|
|
3179
|
+
"message": "boom",
|
|
3180
|
+
},
|
|
3181
|
+
"meta": {
|
|
3182
|
+
"command": "fail",
|
|
3183
|
+
"duration": "<stripped>",
|
|
3184
|
+
},
|
|
3185
|
+
"ok": false,
|
|
3186
|
+
},
|
|
3187
|
+
"status": 500,
|
|
3188
|
+
}
|
|
3189
|
+
`)
|
|
3190
|
+
})
|
|
3191
|
+
|
|
3192
|
+
test('async generator → NDJSON streaming response', async () => {
|
|
3193
|
+
const cli = Cli.create('test')
|
|
3194
|
+
cli.command('stream', {
|
|
3195
|
+
async *run() {
|
|
3196
|
+
yield { progress: 1 }
|
|
3197
|
+
yield { progress: 2 }
|
|
3198
|
+
return { done: true }
|
|
3199
|
+
},
|
|
3200
|
+
})
|
|
3201
|
+
const res = await cli.fetch(new Request('http://localhost/stream'))
|
|
3202
|
+
expect(res.status).toBe(200)
|
|
3203
|
+
expect(res.headers.get('content-type')).toBe('application/x-ndjson')
|
|
3204
|
+
const text = await res.text()
|
|
3205
|
+
const lines = text.trim().split('\n').map((l) => JSON.parse(l))
|
|
3206
|
+
expect(lines).toMatchInlineSnapshot(`
|
|
3207
|
+
[
|
|
3208
|
+
{
|
|
3209
|
+
"data": {
|
|
3210
|
+
"progress": 1,
|
|
3211
|
+
},
|
|
3212
|
+
"type": "chunk",
|
|
3213
|
+
},
|
|
3214
|
+
{
|
|
3215
|
+
"data": {
|
|
3216
|
+
"progress": 2,
|
|
3217
|
+
},
|
|
3218
|
+
"type": "chunk",
|
|
3219
|
+
},
|
|
3220
|
+
{
|
|
3221
|
+
"meta": {
|
|
3222
|
+
"command": "stream",
|
|
3223
|
+
},
|
|
3224
|
+
"ok": true,
|
|
3225
|
+
"type": "done",
|
|
3226
|
+
},
|
|
3227
|
+
]
|
|
3228
|
+
`)
|
|
3229
|
+
})
|
|
3230
|
+
|
|
3231
|
+
test('middleware sets var → command sees it', async () => {
|
|
3232
|
+
const cli = Cli.create('test', {
|
|
3233
|
+
vars: z.object({ user: z.string().default('anonymous') }),
|
|
3234
|
+
})
|
|
3235
|
+
cli.use(async (c, next) => {
|
|
3236
|
+
c.set('user', 'alice')
|
|
3237
|
+
await next()
|
|
3238
|
+
})
|
|
3239
|
+
cli.command('whoami', {
|
|
3240
|
+
run: (c) => ({ user: c.var.user }),
|
|
3241
|
+
})
|
|
3242
|
+
expect(await fetchJson(cli, new Request('http://localhost/whoami'))).toMatchInlineSnapshot(`
|
|
3243
|
+
{
|
|
3244
|
+
"body": {
|
|
3245
|
+
"data": {
|
|
3246
|
+
"user": "alice",
|
|
3247
|
+
},
|
|
3248
|
+
"meta": {
|
|
3249
|
+
"command": "whoami",
|
|
3250
|
+
"duration": "<stripped>",
|
|
3251
|
+
},
|
|
3252
|
+
"ok": true,
|
|
3253
|
+
},
|
|
3254
|
+
"status": 200,
|
|
3255
|
+
}
|
|
3256
|
+
`)
|
|
3257
|
+
})
|
|
3258
|
+
|
|
3259
|
+
test('middleware error → error response', async () => {
|
|
3260
|
+
const cli = Cli.create('test')
|
|
3261
|
+
cli.use((c) => {
|
|
3262
|
+
c.error({ code: 'UNAUTHORIZED', message: 'not allowed' })
|
|
3263
|
+
})
|
|
3264
|
+
cli.command('secret', { run: () => ({ secret: true }) })
|
|
3265
|
+
expect(await fetchJson(cli, new Request('http://localhost/secret'))).toMatchInlineSnapshot(`
|
|
3266
|
+
{
|
|
3267
|
+
"body": {
|
|
3268
|
+
"error": {
|
|
3269
|
+
"code": "UNAUTHORIZED",
|
|
3270
|
+
"message": "not allowed",
|
|
3271
|
+
},
|
|
3272
|
+
"meta": {
|
|
3273
|
+
"command": "secret",
|
|
3274
|
+
"duration": "<stripped>",
|
|
3275
|
+
},
|
|
3276
|
+
"ok": false,
|
|
3277
|
+
},
|
|
3278
|
+
"status": 500,
|
|
3279
|
+
}
|
|
3280
|
+
`)
|
|
3281
|
+
})
|
|
3282
|
+
|
|
3283
|
+
test('fetch gateway → forwards request', async () => {
|
|
3284
|
+
const handler = (req: Request) => {
|
|
3285
|
+
const url = new URL(req.url)
|
|
3286
|
+
return new Response(JSON.stringify({ path: url.pathname }), {
|
|
3287
|
+
headers: { 'content-type': 'application/json' },
|
|
3288
|
+
})
|
|
3289
|
+
}
|
|
3290
|
+
const cli = Cli.create('test')
|
|
3291
|
+
cli.command('api', { fetch: handler })
|
|
3292
|
+
const res = await cli.fetch(new Request('http://localhost/api/users/list'))
|
|
3293
|
+
expect(res.status).toBe(200)
|
|
3294
|
+
const body = await res.json()
|
|
3295
|
+
expect(body).toMatchInlineSnapshot(`
|
|
3296
|
+
{
|
|
3297
|
+
"path": "/api/users/list",
|
|
3298
|
+
}
|
|
3299
|
+
`)
|
|
3300
|
+
})
|
|
3301
|
+
|
|
3302
|
+
describe('mcp over http', () => {
|
|
3303
|
+
function mcpCli() {
|
|
3304
|
+
const cli = Cli.create('test', { version: '1.0.0' })
|
|
3305
|
+
cli.command('greet', {
|
|
3306
|
+
description: 'Greet someone',
|
|
3307
|
+
args: z.object({ name: z.string() }),
|
|
3308
|
+
run: (c) => ({ message: `hello ${c.args.name}` }),
|
|
3309
|
+
})
|
|
3310
|
+
cli.command('ping', {
|
|
3311
|
+
description: 'Ping',
|
|
3312
|
+
run: () => ({ pong: true }),
|
|
3313
|
+
})
|
|
3314
|
+
return cli
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
async function mcpRequest(cli: Cli.Cli<any, any, any>, body: unknown, sessionId?: string) {
|
|
3318
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
|
|
3319
|
+
if (sessionId) headers['mcp-session-id'] = sessionId
|
|
3320
|
+
return cli.fetch(
|
|
3321
|
+
new Request('http://localhost/mcp', {
|
|
3322
|
+
method: 'POST',
|
|
3323
|
+
headers,
|
|
3324
|
+
body: JSON.stringify(body),
|
|
3325
|
+
}),
|
|
3326
|
+
)
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
async function initSession(cli: Cli.Cli<any, any, any>) {
|
|
3330
|
+
const res = await mcpRequest(cli, {
|
|
3331
|
+
jsonrpc: '2.0',
|
|
3332
|
+
id: 1,
|
|
3333
|
+
method: 'initialize',
|
|
3334
|
+
params: {
|
|
3335
|
+
protocolVersion: '2025-03-26',
|
|
3336
|
+
capabilities: {},
|
|
3337
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
3338
|
+
},
|
|
3339
|
+
})
|
|
3340
|
+
const sessionId = res.headers.get('mcp-session-id')
|
|
3341
|
+
const body = await res.json()
|
|
3342
|
+
// Send initialized notification
|
|
3343
|
+
await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId!)
|
|
3344
|
+
return { sessionId: sessionId!, body }
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
test('POST /mcp with initialize → valid MCP response', async () => {
|
|
3348
|
+
const cli = mcpCli()
|
|
3349
|
+
const res = await mcpRequest(cli, {
|
|
3350
|
+
jsonrpc: '2.0',
|
|
3351
|
+
id: 1,
|
|
3352
|
+
method: 'initialize',
|
|
3353
|
+
params: {
|
|
3354
|
+
protocolVersion: '2025-03-26',
|
|
3355
|
+
capabilities: {},
|
|
3356
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
3357
|
+
},
|
|
3358
|
+
})
|
|
3359
|
+
expect(res.status).toBe(200)
|
|
3360
|
+
const body = await res.json()
|
|
3361
|
+
expect({
|
|
3362
|
+
serverInfo: body.result.serverInfo,
|
|
3363
|
+
hasTools: 'tools' in (body.result.capabilities ?? {}),
|
|
3364
|
+
}).toMatchInlineSnapshot(`
|
|
3365
|
+
{
|
|
3366
|
+
"hasTools": true,
|
|
3367
|
+
"serverInfo": {
|
|
3368
|
+
"name": "test",
|
|
3369
|
+
"version": "1.0.0",
|
|
3370
|
+
},
|
|
3371
|
+
}
|
|
3372
|
+
`)
|
|
3373
|
+
})
|
|
3374
|
+
|
|
3375
|
+
test('POST /mcp with tools/list → returns registered tools', async () => {
|
|
3376
|
+
const cli = mcpCli()
|
|
3377
|
+
const { sessionId } = await initSession(cli)
|
|
3378
|
+
const res = await mcpRequest(
|
|
3379
|
+
cli,
|
|
3380
|
+
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
3381
|
+
sessionId,
|
|
3382
|
+
)
|
|
3383
|
+
expect(res.status).toBe(200)
|
|
3384
|
+
const body = await res.json()
|
|
3385
|
+
const tools = body.result.tools.map((t: any) => ({
|
|
3386
|
+
name: t.name,
|
|
3387
|
+
description: t.description,
|
|
3388
|
+
hasInputSchema: Object.keys(t.inputSchema?.properties ?? {}).length > 0,
|
|
3389
|
+
}))
|
|
3390
|
+
expect(tools).toMatchInlineSnapshot(`
|
|
3391
|
+
[
|
|
3392
|
+
{
|
|
3393
|
+
"description": "Greet someone",
|
|
3394
|
+
"hasInputSchema": true,
|
|
3395
|
+
"name": "greet",
|
|
3396
|
+
},
|
|
3397
|
+
{
|
|
3398
|
+
"description": "Ping",
|
|
3399
|
+
"hasInputSchema": false,
|
|
3400
|
+
"name": "ping",
|
|
3401
|
+
},
|
|
3402
|
+
]
|
|
3403
|
+
`)
|
|
3404
|
+
})
|
|
3405
|
+
|
|
3406
|
+
test('POST /mcp with tools/call → executes command', async () => {
|
|
3407
|
+
const cli = mcpCli()
|
|
3408
|
+
const { sessionId } = await initSession(cli)
|
|
3409
|
+
const res = await mcpRequest(
|
|
3410
|
+
cli,
|
|
3411
|
+
{
|
|
3412
|
+
jsonrpc: '2.0',
|
|
3413
|
+
id: 3,
|
|
3414
|
+
method: 'tools/call',
|
|
3415
|
+
params: { name: 'greet', arguments: { name: 'world' } },
|
|
3416
|
+
},
|
|
3417
|
+
sessionId,
|
|
3418
|
+
)
|
|
3419
|
+
expect(res.status).toBe(200)
|
|
3420
|
+
const body = await res.json()
|
|
3421
|
+
expect({
|
|
3422
|
+
isError: body.result.isError,
|
|
3423
|
+
content: JSON.parse(body.result.content[0].text),
|
|
3424
|
+
}).toMatchInlineSnapshot(`
|
|
3425
|
+
{
|
|
3426
|
+
"content": {
|
|
3427
|
+
"message": "hello world",
|
|
3428
|
+
},
|
|
3429
|
+
"isError": undefined,
|
|
3430
|
+
}
|
|
3431
|
+
`)
|
|
3432
|
+
})
|
|
3433
|
+
|
|
3434
|
+
test('non-/mcp paths still route to command API', async () => {
|
|
3435
|
+
const cli = mcpCli()
|
|
3436
|
+
const { body } = await fetchJson(cli, new Request('http://localhost/ping'))
|
|
3437
|
+
expect(body.data).toMatchInlineSnapshot(`
|
|
3438
|
+
{
|
|
3439
|
+
"pong": true,
|
|
3440
|
+
}
|
|
3441
|
+
`)
|
|
3442
|
+
})
|
|
3443
|
+
})
|
|
3444
|
+
})
|