incur 0.2.0 → 0.2.2

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/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,14 @@ 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
862
+ --token-count Print token count of output (instead of output)
863
+ --token-limit <n> Limit output to n tokens
864
+ --token-offset <n> Skip first n tokens of output
745
865
  --verbose Show full output envelope
746
866
  "
747
867
  `)
@@ -1173,10 +1293,15 @@ describe('help', () => {
1173
1293
  skills add Sync skill files to your agent
1174
1294
 
1175
1295
  Global Options:
1296
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1176
1297
  --format <toon|json|yaml|md|jsonl> Output format
1177
1298
  --help Show help
1178
1299
  --llms Print LLM-readable manifest
1179
1300
  --mcp Start as MCP stdio server
1301
+ --schema Show JSON Schema for a command
1302
+ --token-count Print token count of output (instead of output)
1303
+ --token-limit <n> Limit output to n tokens
1304
+ --token-offset <n> Skip first n tokens of output
1180
1305
  --verbose Show full output envelope
1181
1306
  --version Show version
1182
1307
  "
@@ -1206,10 +1331,15 @@ describe('help', () => {
1206
1331
  skills add Sync skill files to your agent
1207
1332
 
1208
1333
  Global Options:
1334
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1209
1335
  --format <toon|json|yaml|md|jsonl> Output format
1210
1336
  --help Show help
1211
1337
  --llms Print LLM-readable manifest
1212
1338
  --mcp Start as MCP stdio server
1339
+ --schema Show JSON Schema for a command
1340
+ --token-count Print token count of output (instead of output)
1341
+ --token-limit <n> Limit output to n tokens
1342
+ --token-offset <n> Skip first n tokens of output
1213
1343
  --verbose Show full output envelope
1214
1344
  --version Show version
1215
1345
  "
@@ -1235,9 +1365,14 @@ describe('help', () => {
1235
1365
  name Name
1236
1366
 
1237
1367
  Global Options:
1368
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1238
1369
  --format <toon|json|yaml|md|jsonl> Output format
1239
1370
  --help Show help
1240
1371
  --llms Print LLM-readable manifest
1372
+ --schema Show JSON Schema for a command
1373
+ --token-count Print token count of output (instead of output)
1374
+ --token-limit <n> Limit output to n tokens
1375
+ --token-offset <n> Skip first n tokens of output
1241
1376
  --verbose Show full output envelope
1242
1377
  "
1243
1378
  `)
@@ -1264,9 +1399,14 @@ describe('help', () => {
1264
1399
  list List PRs
1265
1400
 
1266
1401
  Global Options:
1402
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1267
1403
  --format <toon|json|yaml|md|jsonl> Output format
1268
1404
  --help Show help
1269
1405
  --llms Print LLM-readable manifest
1406
+ --schema Show JSON Schema for a command
1407
+ --token-count Print token count of output (instead of output)
1408
+ --token-limit <n> Limit output to n tokens
1409
+ --token-offset <n> Skip first n tokens of output
1270
1410
  --verbose Show full output envelope
1271
1411
  "
1272
1412
  `)
@@ -1354,10 +1494,15 @@ describe('help', () => {
1354
1494
  skills add Sync skill files to your agent
1355
1495
 
1356
1496
  Global Options:
1497
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1357
1498
  --format <toon|json|yaml|md|jsonl> Output format
1358
1499
  --help Show help
1359
1500
  --llms Print LLM-readable manifest
1360
1501
  --mcp Start as MCP stdio server
1502
+ --schema Show JSON Schema for a command
1503
+ --token-count Print token count of output (instead of output)
1504
+ --token-limit <n> Limit output to n tokens
1505
+ --token-offset <n> Skip first n tokens of output
1361
1506
  --verbose Show full output envelope
1362
1507
  --version Show version
1363
1508
  "
@@ -1381,9 +1526,14 @@ describe('help', () => {
1381
1526
  Run "tool status" to check deployment progress.
1382
1527
 
1383
1528
  Global Options:
1529
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1384
1530
  --format <toon|json|yaml|md|jsonl> Output format
1385
1531
  --help Show help
1386
1532
  --llms Print LLM-readable manifest
1533
+ --schema Show JSON Schema for a command
1534
+ --token-count Print token count of output (instead of output)
1535
+ --token-limit <n> Limit output to n tokens
1536
+ --token-offset <n> Skip first n tokens of output
1387
1537
  --verbose Show full output envelope
1388
1538
  "
1389
1539
  `)
@@ -1471,9 +1621,14 @@ describe('env', () => {
1471
1621
  Usage: test deploy
1472
1622
 
1473
1623
  Global Options:
1624
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1474
1625
  --format <toon|json|yaml|md|jsonl> Output format
1475
1626
  --help Show help
1476
1627
  --llms Print LLM-readable manifest
1628
+ --schema Show JSON Schema for a command
1629
+ --token-count Print token count of output (instead of output)
1630
+ --token-limit <n> Limit output to n tokens
1631
+ --token-offset <n> Skip first n tokens of output
1477
1632
  --verbose Show full output envelope
1478
1633
 
1479
1634
  Environment Variables:
@@ -1504,9 +1659,14 @@ describe('env', () => {
1504
1659
  Usage: test deploy
1505
1660
 
1506
1661
  Global Options:
1662
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1507
1663
  --format <toon|json|yaml|md|jsonl> Output format
1508
1664
  --help Show help
1509
1665
  --llms Print LLM-readable manifest
1666
+ --schema Show JSON Schema for a command
1667
+ --token-count Print token count of output (instead of output)
1668
+ --token-limit <n> Limit output to n tokens
1669
+ --token-offset <n> Skip first n tokens of output
1510
1670
  --verbose Show full output envelope
1511
1671
 
1512
1672
  Environment Variables:
@@ -2104,6 +2264,43 @@ describe('outputPolicy', () => {
2104
2264
  expect(captured).toEqual({ agent: false, command: 'deploy' })
2105
2265
  })
2106
2266
 
2267
+ test('e2e: middleware and run context expose format metadata', async () => {
2268
+ let mwCaptured:
2269
+ | {
2270
+ format: string
2271
+ formatExplicit: boolean
2272
+ }
2273
+ | undefined
2274
+ let runCaptured:
2275
+ | {
2276
+ format: string
2277
+ formatExplicit: boolean
2278
+ }
2279
+ | undefined
2280
+
2281
+ const cli = Cli.create('test')
2282
+ .use(async (c, next) => {
2283
+ mwCaptured = {
2284
+ format: c.format,
2285
+ formatExplicit: c.formatExplicit,
2286
+ }
2287
+ await next()
2288
+ })
2289
+ .command('deploy', {
2290
+ run(c) {
2291
+ runCaptured = {
2292
+ format: c.format,
2293
+ formatExplicit: c.formatExplicit,
2294
+ }
2295
+ return { ok: true }
2296
+ },
2297
+ })
2298
+
2299
+ await serve(cli, ['deploy', '--format', 'json'])
2300
+ expect(mwCaptured).toEqual({ format: 'json', formatExplicit: true })
2301
+ expect(runCaptured).toEqual({ format: 'json', formatExplicit: true })
2302
+ })
2303
+
2107
2304
  test('e2e: middleware works with streaming handlers', async () => {
2108
2305
  const order: string[] = []
2109
2306
  const cli = Cli.create('test')
@@ -2404,6 +2601,88 @@ test('streaming: generator returns error in buffered mode', async () => {
2404
2601
  expect(output).toContain('RET_ERR')
2405
2602
  })
2406
2603
 
2604
+ test('c.error({ exitCode }) uses custom exit code', async () => {
2605
+ const cli = Cli.create('test')
2606
+ cli.command('fail', {
2607
+ run(c) {
2608
+ return c.error({ code: 'AUTH', message: 'not authed', exitCode: 10 })
2609
+ },
2610
+ })
2611
+
2612
+ const { output, exitCode } = await serve(cli, ['fail'])
2613
+ expect(exitCode).toBe(10)
2614
+ expect(output).toMatchInlineSnapshot(`
2615
+ "code: AUTH
2616
+ message: not authed
2617
+ "
2618
+ `)
2619
+ })
2620
+
2621
+ test('c.error() without exitCode defaults to 1', async () => {
2622
+ const cli = Cli.create('test')
2623
+ cli.command('fail', {
2624
+ run(c) {
2625
+ return c.error({ code: 'BAD', message: 'fail' })
2626
+ },
2627
+ })
2628
+
2629
+ const { output, exitCode } = await serve(cli, ['fail'])
2630
+ expect(exitCode).toBe(1)
2631
+ expect(output).toMatchInlineSnapshot(`
2632
+ "code: BAD
2633
+ message: fail
2634
+ "
2635
+ `)
2636
+ })
2637
+
2638
+ test('middleware c.error({ exitCode }) uses custom exit code', async () => {
2639
+ const cli = Cli.create('test')
2640
+ cli.use((c) => {
2641
+ return c.error({ code: 'MW_ERR', message: 'blocked', exitCode: 42 })
2642
+ })
2643
+ cli.command('anything', { run: () => ({}) })
2644
+
2645
+ const { output, exitCode } = await serve(cli, ['anything'])
2646
+ expect(exitCode).toBe(42)
2647
+ expect(output).toMatchInlineSnapshot(`
2648
+ "code: MW_ERR
2649
+ message: blocked
2650
+ "
2651
+ `)
2652
+ })
2653
+
2654
+ test('thrown IncurError with exitCode uses custom exit code', async () => {
2655
+ const cli = Cli.create('test')
2656
+ cli.command('fail', {
2657
+ run() {
2658
+ throw new Errors.IncurError({ code: 'RATE_LIMITED', message: 'too fast', exitCode: 99 })
2659
+ },
2660
+ })
2661
+
2662
+ const { output, exitCode } = await serve(cli, ['fail'])
2663
+ expect(exitCode).toBe(99)
2664
+ expect(output).toMatchInlineSnapshot(`
2665
+ "code: RATE_LIMITED
2666
+ message: too fast
2667
+ retryable: false
2668
+ "
2669
+ `)
2670
+ })
2671
+
2672
+ test('streaming: c.error({ exitCode }) in yield uses custom exit code', async () => {
2673
+ const cli = Cli.create('test')
2674
+ cli.command('fail', {
2675
+ async *run(c) {
2676
+ yield { step: 1 }
2677
+ yield c.error({ code: 'STREAM_ERR', message: 'mid-stream', exitCode: 77 })
2678
+ },
2679
+ })
2680
+
2681
+ const { output, exitCode } = await serve(cli, ['fail', '--format', 'jsonl'])
2682
+ expect(exitCode).toBe(77)
2683
+ expect(output).toContain('STREAM_ERR')
2684
+ })
2685
+
2407
2686
  test('deprecated short flag emits warning', async () => {
2408
2687
  const cli = Cli.create('app').command('deploy', {
2409
2688
  options: z.object({
@@ -2640,3 +2919,553 @@ describe('fetch', async () => {
2640
2919
  expect(output).toContain('Proxy to API')
2641
2920
  })
2642
2921
  })
2922
+
2923
+ describe('--filter-output', () => {
2924
+ test('selects specific keys', async () => {
2925
+ const cli = Cli.create('test')
2926
+ cli.command('user', {
2927
+ run() {
2928
+ return { name: 'alice', age: 30, email: 'alice@example.com' }
2929
+ },
2930
+ })
2931
+ const { output } = await serve(cli, ['user', '--filter-output', 'name,age'])
2932
+ expect(output).toMatchInlineSnapshot(`
2933
+ "name: alice
2934
+ age: 30
2935
+ "
2936
+ `)
2937
+ })
2938
+
2939
+ test('returns scalar for single key', async () => {
2940
+ const cli = Cli.create('test')
2941
+ cli.command('greet', {
2942
+ args: z.object({ name: z.string() }),
2943
+ run(c) {
2944
+ return { message: `hello ${c.args.name}` }
2945
+ },
2946
+ })
2947
+ const { output } = await serve(cli, ['greet', 'world', '--filter-output', 'message'])
2948
+ expect(output).toMatchInlineSnapshot(`
2949
+ "hello world
2950
+ "
2951
+ `)
2952
+ })
2953
+
2954
+ test('dot notation filters nested keys', async () => {
2955
+ const cli = Cli.create('test')
2956
+ cli.command('profile', {
2957
+ run() {
2958
+ return { user: { name: 'alice', email: 'a@b.com' }, status: 'active' }
2959
+ },
2960
+ })
2961
+ const { output } = await serve(cli, ['profile', '--filter-output', 'user.name'])
2962
+ expect(output).toMatchInlineSnapshot(`
2963
+ "user:
2964
+ name: alice
2965
+ "
2966
+ `)
2967
+ })
2968
+
2969
+ test('array slice', async () => {
2970
+ const cli = Cli.create('test')
2971
+ cli.command('list', {
2972
+ run() {
2973
+ return { items: [1, 2, 3, 4, 5] }
2974
+ },
2975
+ })
2976
+ const { output } = await serve(cli, ['list', '--filter-output', 'items[0,3]'])
2977
+ expect(output).toMatchInlineSnapshot(`
2978
+ "items[3]: 1,2,3
2979
+ "
2980
+ `)
2981
+ })
2982
+
2983
+ test('works with --format json', async () => {
2984
+ const cli = Cli.create('test')
2985
+ cli.command('user', {
2986
+ run() {
2987
+ return { name: 'alice', age: 30, email: 'alice@example.com' }
2988
+ },
2989
+ })
2990
+ const { output } = await serve(cli, ['user', '--filter-output', 'name,age', '--format', 'json'])
2991
+ const parsed = JSON.parse(output)
2992
+ expect(parsed).toEqual({ name: 'alice', age: 30 })
2993
+ })
2994
+ })
2995
+
2996
+ async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
2997
+ const res = await cli.fetch(req)
2998
+ const body = await res.json()
2999
+ body.meta.duration = '<stripped>'
3000
+ return { status: res.status, body }
3001
+ }
3002
+
3003
+ describe('fetch', () => {
3004
+ test('GET /health → 200', async () => {
3005
+ const cli = Cli.create('test')
3006
+ cli.command('health', { run: () => ({ ok: true }) })
3007
+ expect(await fetchJson(cli, new Request('http://localhost/health'))).toMatchInlineSnapshot(`
3008
+ {
3009
+ "body": {
3010
+ "data": {
3011
+ "ok": true,
3012
+ },
3013
+ "meta": {
3014
+ "command": "health",
3015
+ "duration": "<stripped>",
3016
+ },
3017
+ "ok": true,
3018
+ },
3019
+ "status": 200,
3020
+ }
3021
+ `)
3022
+ })
3023
+
3024
+ test('GET /unknown → 404', async () => {
3025
+ const cli = Cli.create('test')
3026
+ cli.command('health', { run: () => ({}) })
3027
+ expect(await fetchJson(cli, new Request('http://localhost/unknown'))).toMatchInlineSnapshot(`
3028
+ {
3029
+ "body": {
3030
+ "error": {
3031
+ "code": "COMMAND_NOT_FOUND",
3032
+ "message": "'unknown' is not a command for 'test'.",
3033
+ },
3034
+ "meta": {
3035
+ "command": "unknown",
3036
+ "duration": "<stripped>",
3037
+ },
3038
+ "ok": false,
3039
+ },
3040
+ "status": 404,
3041
+ }
3042
+ `)
3043
+ })
3044
+
3045
+ test('GET / with root command → 200', async () => {
3046
+ const cli = Cli.create('test', { run: () => ({ root: true }) })
3047
+ expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
3048
+ {
3049
+ "body": {
3050
+ "data": {
3051
+ "root": true,
3052
+ },
3053
+ "meta": {
3054
+ "command": "test",
3055
+ "duration": "<stripped>",
3056
+ },
3057
+ "ok": true,
3058
+ },
3059
+ "status": 200,
3060
+ }
3061
+ `)
3062
+ })
3063
+
3064
+ test('GET / without root command → 404', async () => {
3065
+ const cli = Cli.create('test')
3066
+ cli.command('health', { run: () => ({}) })
3067
+ expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
3068
+ {
3069
+ "body": {
3070
+ "error": {
3071
+ "code": "COMMAND_NOT_FOUND",
3072
+ "message": "No root command defined.",
3073
+ },
3074
+ "meta": {
3075
+ "command": "/",
3076
+ "duration": "<stripped>",
3077
+ },
3078
+ "ok": false,
3079
+ },
3080
+ "status": 404,
3081
+ }
3082
+ `)
3083
+ })
3084
+
3085
+ test('GET search params → options', async () => {
3086
+ const cli = Cli.create('test')
3087
+ cli.command('users', {
3088
+ options: z.object({ limit: z.coerce.number().default(10) }),
3089
+ run: (c) => ({ limit: c.options.limit }),
3090
+ })
3091
+ expect(await fetchJson(cli, new Request('http://localhost/users?limit=5'))).toMatchInlineSnapshot(`
3092
+ {
3093
+ "body": {
3094
+ "data": {
3095
+ "limit": 5,
3096
+ },
3097
+ "meta": {
3098
+ "command": "users",
3099
+ "duration": "<stripped>",
3100
+ },
3101
+ "ok": true,
3102
+ },
3103
+ "status": 200,
3104
+ }
3105
+ `)
3106
+ })
3107
+
3108
+ test('POST body → options', async () => {
3109
+ const cli = Cli.create('test')
3110
+ cli.command('users', {
3111
+ options: z.object({ name: z.string() }),
3112
+ run: (c) => ({ created: true, name: c.options.name }),
3113
+ })
3114
+ const req = new Request('http://localhost/users', {
3115
+ method: 'POST',
3116
+ headers: { 'content-type': 'application/json' },
3117
+ body: JSON.stringify({ name: 'Bob' }),
3118
+ })
3119
+ expect(await fetchJson(cli, req)).toMatchInlineSnapshot(`
3120
+ {
3121
+ "body": {
3122
+ "data": {
3123
+ "created": true,
3124
+ "name": "Bob",
3125
+ },
3126
+ "meta": {
3127
+ "command": "users",
3128
+ "duration": "<stripped>",
3129
+ },
3130
+ "ok": true,
3131
+ },
3132
+ "status": 200,
3133
+ }
3134
+ `)
3135
+ })
3136
+
3137
+ test('trailing path segments → positional args', async () => {
3138
+ const cli = Cli.create('test')
3139
+ cli.command('users', {
3140
+ args: z.object({ id: z.coerce.number() }),
3141
+ run: (c) => ({ id: c.args.id }),
3142
+ })
3143
+ expect(await fetchJson(cli, new Request('http://localhost/users/42'))).toMatchInlineSnapshot(`
3144
+ {
3145
+ "body": {
3146
+ "data": {
3147
+ "id": 42,
3148
+ },
3149
+ "meta": {
3150
+ "command": "users",
3151
+ "duration": "<stripped>",
3152
+ },
3153
+ "ok": true,
3154
+ },
3155
+ "status": 200,
3156
+ }
3157
+ `)
3158
+ })
3159
+
3160
+ test('nested command resolution', async () => {
3161
+ const sub = Cli.create('users')
3162
+ sub.command('list', { run: () => ({ users: [] }) })
3163
+ const cli = Cli.create('test')
3164
+ cli.command(sub)
3165
+ expect(await fetchJson(cli, new Request('http://localhost/users/list'))).toMatchInlineSnapshot(`
3166
+ {
3167
+ "body": {
3168
+ "data": {
3169
+ "users": [],
3170
+ },
3171
+ "meta": {
3172
+ "command": "users list",
3173
+ "duration": "<stripped>",
3174
+ },
3175
+ "ok": true,
3176
+ },
3177
+ "status": 200,
3178
+ }
3179
+ `)
3180
+ })
3181
+
3182
+ test('validation error → 400', async () => {
3183
+ const cli = Cli.create('test')
3184
+ cli.command('users', {
3185
+ args: z.object({ id: z.coerce.number() }),
3186
+ run: (c) => ({ id: c.args.id }),
3187
+ })
3188
+ const { status, body } = await fetchJson(cli, new Request('http://localhost/users'))
3189
+ expect(status).toBe(400)
3190
+ expect(body.ok).toBe(false)
3191
+ expect(body.error.code).toBe('VALIDATION_ERROR')
3192
+ })
3193
+
3194
+ test('thrown error → 500', async () => {
3195
+ const cli = Cli.create('test')
3196
+ cli.command('fail', {
3197
+ run() {
3198
+ throw new Error('boom')
3199
+ },
3200
+ })
3201
+ expect(await fetchJson(cli, new Request('http://localhost/fail'))).toMatchInlineSnapshot(`
3202
+ {
3203
+ "body": {
3204
+ "error": {
3205
+ "code": "UNKNOWN",
3206
+ "message": "boom",
3207
+ },
3208
+ "meta": {
3209
+ "command": "fail",
3210
+ "duration": "<stripped>",
3211
+ },
3212
+ "ok": false,
3213
+ },
3214
+ "status": 500,
3215
+ }
3216
+ `)
3217
+ })
3218
+
3219
+ test('async generator → NDJSON streaming response', async () => {
3220
+ const cli = Cli.create('test')
3221
+ cli.command('stream', {
3222
+ async *run() {
3223
+ yield { progress: 1 }
3224
+ yield { progress: 2 }
3225
+ return { done: true }
3226
+ },
3227
+ })
3228
+ const res = await cli.fetch(new Request('http://localhost/stream'))
3229
+ expect(res.status).toBe(200)
3230
+ expect(res.headers.get('content-type')).toBe('application/x-ndjson')
3231
+ const text = await res.text()
3232
+ const lines = text.trim().split('\n').map((l) => JSON.parse(l))
3233
+ expect(lines).toMatchInlineSnapshot(`
3234
+ [
3235
+ {
3236
+ "data": {
3237
+ "progress": 1,
3238
+ },
3239
+ "type": "chunk",
3240
+ },
3241
+ {
3242
+ "data": {
3243
+ "progress": 2,
3244
+ },
3245
+ "type": "chunk",
3246
+ },
3247
+ {
3248
+ "meta": {
3249
+ "command": "stream",
3250
+ },
3251
+ "ok": true,
3252
+ "type": "done",
3253
+ },
3254
+ ]
3255
+ `)
3256
+ })
3257
+
3258
+ test('middleware sets var → command sees it', async () => {
3259
+ const cli = Cli.create('test', {
3260
+ vars: z.object({ user: z.string().default('anonymous') }),
3261
+ })
3262
+ cli.use(async (c, next) => {
3263
+ c.set('user', 'alice')
3264
+ await next()
3265
+ })
3266
+ cli.command('whoami', {
3267
+ run: (c) => ({ user: c.var.user }),
3268
+ })
3269
+ expect(await fetchJson(cli, new Request('http://localhost/whoami'))).toMatchInlineSnapshot(`
3270
+ {
3271
+ "body": {
3272
+ "data": {
3273
+ "user": "alice",
3274
+ },
3275
+ "meta": {
3276
+ "command": "whoami",
3277
+ "duration": "<stripped>",
3278
+ },
3279
+ "ok": true,
3280
+ },
3281
+ "status": 200,
3282
+ }
3283
+ `)
3284
+ })
3285
+
3286
+ test('middleware error → error response', async () => {
3287
+ const cli = Cli.create('test')
3288
+ cli.use((c) => {
3289
+ c.error({ code: 'UNAUTHORIZED', message: 'not allowed' })
3290
+ })
3291
+ cli.command('secret', { run: () => ({ secret: true }) })
3292
+ expect(await fetchJson(cli, new Request('http://localhost/secret'))).toMatchInlineSnapshot(`
3293
+ {
3294
+ "body": {
3295
+ "error": {
3296
+ "code": "UNAUTHORIZED",
3297
+ "message": "not allowed",
3298
+ },
3299
+ "meta": {
3300
+ "command": "secret",
3301
+ "duration": "<stripped>",
3302
+ },
3303
+ "ok": false,
3304
+ },
3305
+ "status": 500,
3306
+ }
3307
+ `)
3308
+ })
3309
+
3310
+ test('fetch gateway → forwards request', async () => {
3311
+ const handler = (req: Request) => {
3312
+ const url = new URL(req.url)
3313
+ return new Response(JSON.stringify({ path: url.pathname }), {
3314
+ headers: { 'content-type': 'application/json' },
3315
+ })
3316
+ }
3317
+ const cli = Cli.create('test')
3318
+ cli.command('api', { fetch: handler })
3319
+ const res = await cli.fetch(new Request('http://localhost/api/users/list'))
3320
+ expect(res.status).toBe(200)
3321
+ const body = await res.json()
3322
+ expect(body).toMatchInlineSnapshot(`
3323
+ {
3324
+ "path": "/api/users/list",
3325
+ }
3326
+ `)
3327
+ })
3328
+
3329
+ describe('mcp over http', () => {
3330
+ function mcpCli() {
3331
+ const cli = Cli.create('test', { version: '1.0.0' })
3332
+ cli.command('greet', {
3333
+ description: 'Greet someone',
3334
+ args: z.object({ name: z.string() }),
3335
+ run: (c) => ({ message: `hello ${c.args.name}` }),
3336
+ })
3337
+ cli.command('ping', {
3338
+ description: 'Ping',
3339
+ run: () => ({ pong: true }),
3340
+ })
3341
+ return cli
3342
+ }
3343
+
3344
+ async function mcpRequest(cli: Cli.Cli<any, any, any>, body: unknown, sessionId?: string) {
3345
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }
3346
+ if (sessionId) headers['mcp-session-id'] = sessionId
3347
+ return cli.fetch(
3348
+ new Request('http://localhost/mcp', {
3349
+ method: 'POST',
3350
+ headers,
3351
+ body: JSON.stringify(body),
3352
+ }),
3353
+ )
3354
+ }
3355
+
3356
+ async function initSession(cli: Cli.Cli<any, any, any>) {
3357
+ const res = await mcpRequest(cli, {
3358
+ jsonrpc: '2.0',
3359
+ id: 1,
3360
+ method: 'initialize',
3361
+ params: {
3362
+ protocolVersion: '2025-03-26',
3363
+ capabilities: {},
3364
+ clientInfo: { name: 'test-client', version: '1.0.0' },
3365
+ },
3366
+ })
3367
+ const sessionId = res.headers.get('mcp-session-id')
3368
+ const body = await res.json()
3369
+ // Send initialized notification
3370
+ await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId!)
3371
+ return { sessionId: sessionId!, body }
3372
+ }
3373
+
3374
+ test('POST /mcp with initialize → valid MCP response', async () => {
3375
+ const cli = mcpCli()
3376
+ const res = await mcpRequest(cli, {
3377
+ jsonrpc: '2.0',
3378
+ id: 1,
3379
+ method: 'initialize',
3380
+ params: {
3381
+ protocolVersion: '2025-03-26',
3382
+ capabilities: {},
3383
+ clientInfo: { name: 'test-client', version: '1.0.0' },
3384
+ },
3385
+ })
3386
+ expect(res.status).toBe(200)
3387
+ const body = await res.json()
3388
+ expect({
3389
+ serverInfo: body.result.serverInfo,
3390
+ hasTools: 'tools' in (body.result.capabilities ?? {}),
3391
+ }).toMatchInlineSnapshot(`
3392
+ {
3393
+ "hasTools": true,
3394
+ "serverInfo": {
3395
+ "name": "test",
3396
+ "version": "1.0.0",
3397
+ },
3398
+ }
3399
+ `)
3400
+ })
3401
+
3402
+ test('POST /mcp with tools/list → returns registered tools', async () => {
3403
+ const cli = mcpCli()
3404
+ const { sessionId } = await initSession(cli)
3405
+ const res = await mcpRequest(
3406
+ cli,
3407
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
3408
+ sessionId,
3409
+ )
3410
+ expect(res.status).toBe(200)
3411
+ const body = await res.json()
3412
+ const tools = body.result.tools.map((t: any) => ({
3413
+ name: t.name,
3414
+ description: t.description,
3415
+ hasInputSchema: Object.keys(t.inputSchema?.properties ?? {}).length > 0,
3416
+ }))
3417
+ expect(tools).toMatchInlineSnapshot(`
3418
+ [
3419
+ {
3420
+ "description": "Greet someone",
3421
+ "hasInputSchema": true,
3422
+ "name": "greet",
3423
+ },
3424
+ {
3425
+ "description": "Ping",
3426
+ "hasInputSchema": false,
3427
+ "name": "ping",
3428
+ },
3429
+ ]
3430
+ `)
3431
+ })
3432
+
3433
+ test('POST /mcp with tools/call → executes command', async () => {
3434
+ const cli = mcpCli()
3435
+ const { sessionId } = await initSession(cli)
3436
+ const res = await mcpRequest(
3437
+ cli,
3438
+ {
3439
+ jsonrpc: '2.0',
3440
+ id: 3,
3441
+ method: 'tools/call',
3442
+ params: { name: 'greet', arguments: { name: 'world' } },
3443
+ },
3444
+ sessionId,
3445
+ )
3446
+ expect(res.status).toBe(200)
3447
+ const body = await res.json()
3448
+ expect({
3449
+ isError: body.result.isError,
3450
+ content: JSON.parse(body.result.content[0].text),
3451
+ }).toMatchInlineSnapshot(`
3452
+ {
3453
+ "content": {
3454
+ "message": "hello world",
3455
+ },
3456
+ "isError": undefined,
3457
+ }
3458
+ `)
3459
+ })
3460
+
3461
+ test('non-/mcp paths still route to command API', async () => {
3462
+ const cli = mcpCli()
3463
+ const { body } = await fetchJson(cli, new Request('http://localhost/ping'))
3464
+ expect(body.data).toMatchInlineSnapshot(`
3465
+ {
3466
+ "pong": true,
3467
+ }
3468
+ `)
3469
+ })
3470
+ })
3471
+ })