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/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
+ })