incur 0.1.17 → 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.
Files changed (52) hide show
  1. package/README.md +204 -9
  2. package/SKILL.md +173 -0
  3. package/dist/Cli.d.ts +39 -6
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +536 -43
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Errors.d.ts +4 -0
  8. package/dist/Errors.d.ts.map +1 -1
  9. package/dist/Errors.js +3 -0
  10. package/dist/Errors.js.map +1 -1
  11. package/dist/Fetch.d.ts +26 -0
  12. package/dist/Fetch.d.ts.map +1 -0
  13. package/dist/Fetch.js +150 -0
  14. package/dist/Fetch.js.map +1 -0
  15. package/dist/Filter.d.ts +14 -0
  16. package/dist/Filter.d.ts.map +1 -0
  17. package/dist/Filter.js +134 -0
  18. package/dist/Filter.js.map +1 -0
  19. package/dist/Help.js +2 -0
  20. package/dist/Help.js.map +1 -1
  21. package/dist/Mcp.d.ts +26 -0
  22. package/dist/Mcp.d.ts.map +1 -1
  23. package/dist/Mcp.js +2 -2
  24. package/dist/Mcp.js.map +1 -1
  25. package/dist/Openapi.d.ts +20 -0
  26. package/dist/Openapi.d.ts.map +1 -0
  27. package/dist/Openapi.js +136 -0
  28. package/dist/Openapi.js.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/middleware.d.ts +8 -2
  34. package/dist/middleware.d.ts.map +1 -1
  35. package/dist/middleware.js.map +1 -1
  36. package/package.json +4 -1
  37. package/src/Cli.test-d.ts +27 -2
  38. package/src/Cli.test.ts +1007 -0
  39. package/src/Cli.ts +676 -47
  40. package/src/Errors.ts +5 -0
  41. package/src/Fetch.test.ts +274 -0
  42. package/src/Fetch.ts +170 -0
  43. package/src/Filter.test.ts +237 -0
  44. package/src/Filter.ts +139 -0
  45. package/src/Help.test.ts +14 -0
  46. package/src/Help.ts +2 -0
  47. package/src/Mcp.ts +3 -3
  48. package/src/Openapi.test.ts +320 -0
  49. package/src/Openapi.ts +196 -0
  50. package/src/e2e.test.ts +778 -0
  51. package/src/index.ts +3 -0
  52. package/src/middleware.ts +9 -2
package/src/e2e.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Cli, Errors, Skill, Typegen, z } from 'incur'
2
+ import { app as honoApp } from '../test/fixtures/hono-api.js'
2
3
 
3
4
  let __mockSkillsHash: string | undefined
4
5
 
@@ -821,6 +822,7 @@ describe('help', () => {
821
822
  Usage: app <command>
822
823
 
823
824
  Commands:
825
+ api Proxy to HTTP API
824
826
  auth Authentication commands
825
827
  config Show current configuration
826
828
  echo Echo back arguments
@@ -843,10 +845,12 @@ describe('help', () => {
843
845
  skills add Sync skill files to your agent
844
846
 
845
847
  Global Options:
848
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
846
849
  --format <toon|json|yaml|md|jsonl> Output format
847
850
  --help Show help
848
851
  --llms Print LLM-readable manifest
849
852
  --mcp Start as MCP stdio server
853
+ --schema Show JSON Schema for a command
850
854
  --verbose Show full output envelope
851
855
  --version Show version
852
856
  "
@@ -872,9 +876,11 @@ describe('help', () => {
872
876
  status Show authentication status
873
877
 
874
878
  Global Options:
879
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
875
880
  --format <toon|json|yaml|md|jsonl> Output format
876
881
  --help Show help
877
882
  --llms Print LLM-readable manifest
883
+ --schema Show JSON Schema for a command
878
884
  --verbose Show full output envelope
879
885
  "
880
886
  `)
@@ -894,9 +900,11 @@ describe('help', () => {
894
900
  status Check deployment status
895
901
 
896
902
  Global Options:
903
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
897
904
  --format <toon|json|yaml|md|jsonl> Output format
898
905
  --help Show help
899
906
  --llms Print LLM-readable manifest
907
+ --schema Show JSON Schema for a command
900
908
  --verbose Show full output envelope
901
909
  "
902
910
  `)
@@ -915,9 +923,11 @@ describe('help', () => {
915
923
  --archived <boolean> Include archived (default: false)
916
924
 
917
925
  Global Options:
926
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
918
927
  --format <toon|json|yaml|md|jsonl> Output format
919
928
  --help Show help
920
929
  --llms Print LLM-readable manifest
930
+ --schema Show JSON Schema for a command
921
931
  --verbose Show full output envelope
922
932
  "
923
933
  `)
@@ -942,9 +952,11 @@ describe('help', () => {
942
952
  $ app project deploy create production --branch release --dryRun true # Dry run a production deploy
943
953
 
944
954
  Global Options:
955
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
945
956
  --format <toon|json|yaml|md|jsonl> Output format
946
957
  --help Show help
947
958
  --llms Print LLM-readable manifest
959
+ --schema Show JSON Schema for a command
948
960
  --verbose Show full output envelope
949
961
  "
950
962
  `)
@@ -980,6 +992,7 @@ describe('--llms', () => {
980
992
  const names = manifest.commands.map((c: any) => c.name)
981
993
  expect(names).toMatchInlineSnapshot(`
982
994
  [
995
+ "api",
983
996
  "auth login",
984
997
  "auth logout",
985
998
  "auth status",
@@ -1180,6 +1193,7 @@ describe('typegen', () => {
1180
1193
  "declare module 'incur' {
1181
1194
  interface Register {
1182
1195
  commands: {
1196
+ 'api': { args: {}; options: {} }
1183
1197
  'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } }
1184
1198
  'auth logout': { args: {}; options: {} }
1185
1199
  'auth status': { args: {}; options: {} }
@@ -1338,10 +1352,12 @@ describe('root command with subcommands', () => {
1338
1352
  skills add Sync skill files to your agent
1339
1353
 
1340
1354
  Global Options:
1355
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1341
1356
  --format <toon|json|yaml|md|jsonl> Output format
1342
1357
  --help Show help
1343
1358
  --llms Print LLM-readable manifest
1344
1359
  --mcp Start as MCP stdio server
1360
+ --schema Show JSON Schema for a command
1345
1361
  --verbose Show full output envelope
1346
1362
  --version Show version
1347
1363
  "
@@ -1510,9 +1526,11 @@ describe('env', () => {
1510
1526
  --scopes <array> OAuth scopes
1511
1527
 
1512
1528
  Global Options:
1529
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
1513
1530
  --format <toon|json|yaml|md|jsonl> Output format
1514
1531
  --help Show help
1515
1532
  --llms Print LLM-readable manifest
1533
+ --schema Show JSON Schema for a command
1516
1534
  --verbose Show full output envelope
1517
1535
 
1518
1536
  Environment Variables:
@@ -1764,6 +1782,765 @@ describe('deprecated flags', () => {
1764
1782
  })
1765
1783
  })
1766
1784
 
1785
+ describe('fetch gateway', () => {
1786
+ test('routes to fetch handler', async () => {
1787
+ const { output } = await serve(createApp(), ['api', 'health'])
1788
+ expect(output).toMatchInlineSnapshot(`
1789
+ "ok: true
1790
+ "
1791
+ `)
1792
+ })
1793
+
1794
+ test('path segments map to URL path', async () => {
1795
+ const { output } = await serve(createApp(), ['api', 'users'])
1796
+ expect(output).toContain('Alice')
1797
+ })
1798
+
1799
+ test('path segments with dynamic params', async () => {
1800
+ const { output } = await serve(createApp(), ['api', 'users', '42'])
1801
+ expect(output).toMatchInlineSnapshot(`
1802
+ "id: 42
1803
+ name: Alice
1804
+ "
1805
+ `)
1806
+ })
1807
+
1808
+ test('query params from --key value', async () => {
1809
+ const { output } = await serve(createApp(), ['api', 'users', '--limit', '5'])
1810
+ const { output: jsonOut } = await serve(createApp(), [
1811
+ 'api',
1812
+ 'users',
1813
+ '--limit',
1814
+ '5',
1815
+ '--format',
1816
+ 'json',
1817
+ ])
1818
+ expect(json(jsonOut).limit).toBe(5)
1819
+ })
1820
+
1821
+ test('POST with -X and -d', async () => {
1822
+ const { output } = await serve(createApp(), [
1823
+ 'api',
1824
+ 'users',
1825
+ '-X',
1826
+ 'POST',
1827
+ '-d',
1828
+ '{"name":"Bob"}',
1829
+ ])
1830
+ expect(output).toMatchInlineSnapshot(`
1831
+ "created: true
1832
+ name: Bob
1833
+ "
1834
+ `)
1835
+ })
1836
+
1837
+ test('implicit POST with --body', async () => {
1838
+ const { output } = await serve(createApp(), [
1839
+ 'api',
1840
+ 'users',
1841
+ '--body',
1842
+ '{"name":"Eve"}',
1843
+ ])
1844
+ expect(output).toMatchInlineSnapshot(`
1845
+ "created: true
1846
+ name: Eve
1847
+ "
1848
+ `)
1849
+ })
1850
+
1851
+ test('DELETE with --method', async () => {
1852
+ const { output } = await serve(createApp(), [
1853
+ 'api',
1854
+ 'users',
1855
+ '1',
1856
+ '--method',
1857
+ 'DELETE',
1858
+ ])
1859
+ expect(output).toMatchInlineSnapshot(`
1860
+ "deleted: true
1861
+ id: 1
1862
+ "
1863
+ `)
1864
+ })
1865
+
1866
+ test('error response produces error envelope', async () => {
1867
+ const { output, exitCode } = await serve(createApp(), ['api', 'error'])
1868
+ expect(exitCode).toBe(1)
1869
+ expect(output).toMatchInlineSnapshot(`
1870
+ "code: HTTP_404
1871
+ message: not found
1872
+ "
1873
+ `)
1874
+ })
1875
+
1876
+ test('text response', async () => {
1877
+ const { output } = await serve(createApp(), ['api', 'text'])
1878
+ expect(output).toMatchInlineSnapshot(`
1879
+ "hello world
1880
+ "
1881
+ `)
1882
+ })
1883
+
1884
+ test('--format json', async () => {
1885
+ const { output } = await serve(createApp(), ['api', 'health', '--format', 'json'])
1886
+ expect(json(output)).toEqual({ ok: true })
1887
+ })
1888
+
1889
+ test('--verbose wraps in envelope', async () => {
1890
+ const { output } = await serve(createApp(), [
1891
+ 'api',
1892
+ 'health',
1893
+ '--verbose',
1894
+ '--format',
1895
+ 'json',
1896
+ ])
1897
+ const parsed = json(output)
1898
+ expect(parsed.ok).toBe(true)
1899
+ expect(parsed.data).toEqual({ ok: true })
1900
+ expect(parsed.meta.command).toBe('api')
1901
+ expect(parsed.meta.duration).toBeDefined()
1902
+ })
1903
+
1904
+ test('--help shows curl-style flags', async () => {
1905
+ const { output } = await serve(createApp(), ['api', '--help'])
1906
+ expect(output).toContain('Proxy to HTTP API')
1907
+ expect(output).toContain('--method')
1908
+ expect(output).toContain('--header')
1909
+ expect(output).toContain('--body')
1910
+ expect(output).toContain('--data')
1911
+ })
1912
+
1913
+ test('appears in root --help', async () => {
1914
+ const { output } = await serve(createApp(), ['--help'])
1915
+ expect(output).toContain('api')
1916
+ expect(output).toContain('Proxy to HTTP API')
1917
+ })
1918
+
1919
+ test('appears in --llms', async () => {
1920
+ const { output } = await serve(createApp(), ['--llms'])
1921
+ expect(output).toContain('api')
1922
+ expect(output).toContain('Proxy to HTTP API')
1923
+ })
1924
+
1925
+ test('coexists with native commands', async () => {
1926
+ const { output: fetchOut } = await serve(createApp(), ['api', 'health'])
1927
+ expect(fetchOut).toContain('ok: true')
1928
+ const { output: nativeOut } = await serve(createApp(), ['ping'])
1929
+ expect(nativeOut).toContain('pong: true')
1930
+ })
1931
+
1932
+ test('streaming NDJSON response', async () => {
1933
+ const { output } = await serve(createApp(), ['api', 'stream'])
1934
+ expect(output).toMatchInlineSnapshot(`
1935
+ "progress: 1
1936
+ progress: 2
1937
+ "
1938
+ `)
1939
+ })
1940
+
1941
+ test('streaming NDJSON --format json buffers all chunks', async () => {
1942
+ const { output } = await serve(createApp(), ['api', 'stream', '--format', 'json'])
1943
+ expect(json(output)).toEqual([{ progress: 1 }, { progress: 2 }])
1944
+ })
1945
+
1946
+ test('streaming NDJSON --format jsonl', async () => {
1947
+ const { output } = await serve(createApp(), ['api', 'stream', '--format', 'jsonl'])
1948
+ const lines = output.trim().split('\n').map((l) => JSON.parse(l))
1949
+ expect(lines[0]).toEqual({ type: 'chunk', data: { progress: 1 } })
1950
+ expect(lines[1]).toEqual({ type: 'chunk', data: { progress: 2 } })
1951
+ expect(lines[2].type).toBe('done')
1952
+ })
1953
+ })
1954
+
1955
+ async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
1956
+ const res = await cli.fetch(req)
1957
+ const body = await res.json()
1958
+ if (body.meta?.duration) body.meta.duration = '<stripped>'
1959
+ return { status: res.status, body }
1960
+ }
1961
+
1962
+ describe('fetch api', () => {
1963
+ test('GET /ping → 200 with data', async () => {
1964
+ const cli = createApp()
1965
+ expect(await fetchJson(cli, new Request('http://localhost/ping'))).toMatchInlineSnapshot(`
1966
+ {
1967
+ "body": {
1968
+ "data": {
1969
+ "pong": true,
1970
+ },
1971
+ "meta": {
1972
+ "command": "ping",
1973
+ "duration": "<stripped>",
1974
+ },
1975
+ "ok": true,
1976
+ },
1977
+ "status": 200,
1978
+ }
1979
+ `)
1980
+ })
1981
+
1982
+ test('GET /unknown → 404', async () => {
1983
+ const cli = createApp()
1984
+ expect(await fetchJson(cli, new Request('http://localhost/unknown'))).toMatchInlineSnapshot(`
1985
+ {
1986
+ "body": {
1987
+ "error": {
1988
+ "code": "COMMAND_NOT_FOUND",
1989
+ "message": "'unknown' is not a command for 'app'.",
1990
+ },
1991
+ "meta": {
1992
+ "command": "unknown",
1993
+ "duration": "<stripped>",
1994
+ },
1995
+ "ok": false,
1996
+ },
1997
+ "status": 404,
1998
+ }
1999
+ `)
2000
+ })
2001
+
2002
+ test('GET / without root command → 404', async () => {
2003
+ const cli = createApp()
2004
+ expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
2005
+ {
2006
+ "body": {
2007
+ "error": {
2008
+ "code": "COMMAND_NOT_FOUND",
2009
+ "message": "No root command defined.",
2010
+ },
2011
+ "meta": {
2012
+ "command": "/",
2013
+ "duration": "<stripped>",
2014
+ },
2015
+ "ok": false,
2016
+ },
2017
+ "status": 404,
2018
+ }
2019
+ `)
2020
+ })
2021
+
2022
+ test('GET with query params → options', async () => {
2023
+ const cli = createApp()
2024
+ expect(await fetchJson(cli, new Request('http://localhost/echo/hi?prefix=yo'))).toMatchInlineSnapshot(`
2025
+ {
2026
+ "body": {
2027
+ "data": {
2028
+ "result": [
2029
+ "yo hi",
2030
+ ],
2031
+ },
2032
+ "meta": {
2033
+ "command": "echo",
2034
+ "duration": "<stripped>",
2035
+ },
2036
+ "ok": true,
2037
+ },
2038
+ "status": 200,
2039
+ }
2040
+ `)
2041
+ })
2042
+
2043
+ test('POST with JSON body → options', async () => {
2044
+ const cli = createApp()
2045
+ const req = new Request('http://localhost/project/create/MyProject', {
2046
+ method: 'POST',
2047
+ headers: { 'content-type': 'application/json' },
2048
+ body: JSON.stringify({ description: 'A test project' }),
2049
+ })
2050
+ expect(await fetchJson(cli, req)).toMatchInlineSnapshot(`
2051
+ {
2052
+ "body": {
2053
+ "data": {
2054
+ "id": "p-new",
2055
+ "url": "https://example.com/projects/p-new",
2056
+ },
2057
+ "meta": {
2058
+ "command": "project create",
2059
+ "duration": "<stripped>",
2060
+ },
2061
+ "ok": true,
2062
+ },
2063
+ "status": 200,
2064
+ }
2065
+ `)
2066
+ })
2067
+
2068
+ test('trailing path segments → positional args', async () => {
2069
+ const cli = createApp()
2070
+ expect(await fetchJson(cli, new Request('http://localhost/project/get/p1'))).toMatchInlineSnapshot(`
2071
+ {
2072
+ "body": {
2073
+ "data": {
2074
+ "description": "Main project",
2075
+ "id": "p1",
2076
+ "members": [
2077
+ {
2078
+ "role": "admin",
2079
+ "userId": "u1",
2080
+ },
2081
+ ],
2082
+ "name": "Alpha",
2083
+ },
2084
+ "meta": {
2085
+ "command": "project get",
2086
+ "duration": "<stripped>",
2087
+ },
2088
+ "ok": true,
2089
+ },
2090
+ "status": 200,
2091
+ }
2092
+ `)
2093
+ })
2094
+
2095
+ test('nested command (3 levels deep)', async () => {
2096
+ const cli = createApp()
2097
+ expect(await fetchJson(cli, new Request('http://localhost/project/deploy/status/d-456'))).toMatchInlineSnapshot(`
2098
+ {
2099
+ "body": {
2100
+ "data": {
2101
+ "deployId": "d-456",
2102
+ "progress": 75,
2103
+ "status": "running",
2104
+ },
2105
+ "meta": {
2106
+ "command": "project deploy status",
2107
+ "duration": "<stripped>",
2108
+ },
2109
+ "ok": true,
2110
+ },
2111
+ "status": 200,
2112
+ }
2113
+ `)
2114
+ })
2115
+
2116
+ test('thrown error → 500', async () => {
2117
+ const cli = createApp()
2118
+ expect(await fetchJson(cli, new Request('http://localhost/explode'))).toMatchInlineSnapshot(`
2119
+ {
2120
+ "body": {
2121
+ "error": {
2122
+ "code": "UNKNOWN",
2123
+ "message": "kaboom",
2124
+ },
2125
+ "meta": {
2126
+ "command": "explode",
2127
+ "duration": "<stripped>",
2128
+ },
2129
+ "ok": false,
2130
+ },
2131
+ "status": 500,
2132
+ }
2133
+ `)
2134
+ })
2135
+
2136
+ test('IncurError → 500 with code', async () => {
2137
+ const cli = createApp()
2138
+ expect(await fetchJson(cli, new Request('http://localhost/explode-clac'))).toMatchInlineSnapshot(`
2139
+ {
2140
+ "body": {
2141
+ "error": {
2142
+ "code": "QUOTA_EXCEEDED",
2143
+ "message": "Rate limit exceeded",
2144
+ },
2145
+ "meta": {
2146
+ "command": "explode-clac",
2147
+ "duration": "<stripped>",
2148
+ },
2149
+ "ok": false,
2150
+ },
2151
+ "status": 500,
2152
+ }
2153
+ `)
2154
+ })
2155
+
2156
+ test('validation error → 400', async () => {
2157
+ const cli = createApp()
2158
+ const { status, body } = await fetchJson(cli, new Request('http://localhost/validate-fail'))
2159
+ expect(status).toBe(400)
2160
+ expect(body.ok).toBe(false)
2161
+ expect(body.error.code).toBe('VALIDATION_ERROR')
2162
+ })
2163
+
2164
+ test('async generator → NDJSON streaming', async () => {
2165
+ const cli = createApp()
2166
+ const res = await cli.fetch(new Request('http://localhost/stream'))
2167
+ expect(res.status).toBe(200)
2168
+ expect(res.headers.get('content-type')).toBe('application/x-ndjson')
2169
+ const lines = (await res.text()).trim().split('\n').map((l) => JSON.parse(l))
2170
+ expect(lines).toMatchInlineSnapshot(`
2171
+ [
2172
+ {
2173
+ "data": {
2174
+ "content": "hello",
2175
+ },
2176
+ "type": "chunk",
2177
+ },
2178
+ {
2179
+ "data": {
2180
+ "content": "world",
2181
+ },
2182
+ "type": "chunk",
2183
+ },
2184
+ {
2185
+ "meta": {
2186
+ "command": "stream",
2187
+ },
2188
+ "ok": true,
2189
+ "type": "done",
2190
+ },
2191
+ ]
2192
+ `)
2193
+ })
2194
+
2195
+ test('fetch gateway → forwards request', async () => {
2196
+ const handler = (req: Request) => {
2197
+ const url = new URL(req.url)
2198
+ return new Response(JSON.stringify({ path: url.pathname }), {
2199
+ headers: { 'content-type': 'application/json' },
2200
+ })
2201
+ }
2202
+ const cli = Cli.create('test')
2203
+ cli.command('api', { fetch: handler })
2204
+ const res = await cli.fetch(new Request('http://localhost/api/users'))
2205
+ expect(res.status).toBe(200)
2206
+ expect(await res.json()).toMatchInlineSnapshot(`
2207
+ {
2208
+ "path": "/api/users",
2209
+ }
2210
+ `)
2211
+ })
2212
+
2213
+ test('middleware sets var → command sees it', async () => {
2214
+ const { cli } = createMiddlewareApp()
2215
+ expect(await fetchJson(cli, new Request('http://localhost/whoami'))).toMatchInlineSnapshot(`
2216
+ {
2217
+ "body": {
2218
+ "data": {
2219
+ "requestId": "req-default",
2220
+ "user": "alice",
2221
+ },
2222
+ "meta": {
2223
+ "command": "whoami",
2224
+ "duration": "<stripped>",
2225
+ },
2226
+ "ok": true,
2227
+ },
2228
+ "status": 200,
2229
+ }
2230
+ `)
2231
+ })
2232
+
2233
+ test('middleware error → error response', async () => {
2234
+ const cli = Cli.create('test')
2235
+ cli.use((c) => { c.error({ code: 'FORBIDDEN', message: 'nope' }) })
2236
+ cli.command('secret', { run: () => ({ secret: true }) })
2237
+ expect(await fetchJson(cli, new Request('http://localhost/secret'))).toMatchInlineSnapshot(`
2238
+ {
2239
+ "body": {
2240
+ "error": {
2241
+ "code": "FORBIDDEN",
2242
+ "message": "nope",
2243
+ },
2244
+ "meta": {
2245
+ "command": "secret",
2246
+ "duration": "<stripped>",
2247
+ },
2248
+ "ok": false,
2249
+ },
2250
+ "status": 500,
2251
+ }
2252
+ `)
2253
+ })
2254
+
2255
+ describe('mcp over http', () => {
2256
+ function mcpRequest(cli: Cli.Cli<any, any, any>, body: unknown, sessionId?: string) {
2257
+ const headers: Record<string, string> = {
2258
+ 'content-type': 'application/json',
2259
+ accept: 'application/json, text/event-stream',
2260
+ }
2261
+ if (sessionId) headers['mcp-session-id'] = sessionId
2262
+ return cli.fetch(
2263
+ new Request('http://localhost/mcp', {
2264
+ method: 'POST',
2265
+ headers,
2266
+ body: JSON.stringify(body),
2267
+ }),
2268
+ )
2269
+ }
2270
+
2271
+ async function initSession(cli: Cli.Cli<any, any, any>) {
2272
+ const res = await mcpRequest(cli, {
2273
+ jsonrpc: '2.0',
2274
+ id: 1,
2275
+ method: 'initialize',
2276
+ params: {
2277
+ protocolVersion: '2025-03-26',
2278
+ capabilities: {},
2279
+ clientInfo: { name: 'test-client', version: '1.0.0' },
2280
+ },
2281
+ })
2282
+ const sessionId = res.headers.get('mcp-session-id')!
2283
+ await mcpRequest(cli, { jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId)
2284
+ return sessionId
2285
+ }
2286
+
2287
+ test('initialize → returns server info and capabilities', async () => {
2288
+ const cli = createApp()
2289
+ const res = await mcpRequest(cli, {
2290
+ jsonrpc: '2.0',
2291
+ id: 1,
2292
+ method: 'initialize',
2293
+ params: {
2294
+ protocolVersion: '2025-03-26',
2295
+ capabilities: {},
2296
+ clientInfo: { name: 'test-client', version: '1.0.0' },
2297
+ },
2298
+ })
2299
+ expect(res.status).toBe(200)
2300
+ const body = await res.json()
2301
+ expect({
2302
+ serverInfo: body.result.serverInfo,
2303
+ hasTools: 'tools' in (body.result.capabilities ?? {}),
2304
+ }).toMatchInlineSnapshot(`
2305
+ {
2306
+ "hasTools": true,
2307
+ "serverInfo": {
2308
+ "name": "app",
2309
+ "version": "3.5.0",
2310
+ },
2311
+ }
2312
+ `)
2313
+ })
2314
+
2315
+ test('tools/list → lists all registered tools', async () => {
2316
+ const cli = createApp()
2317
+ const sessionId = await initSession(cli)
2318
+ const res = await mcpRequest(
2319
+ cli,
2320
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
2321
+ sessionId,
2322
+ )
2323
+ expect(res.status).toBe(200)
2324
+ const body = await res.json()
2325
+ const names = body.result.tools.map((t: any) => t.name).sort()
2326
+ expect(names).toMatchInlineSnapshot(`
2327
+ [
2328
+ "api",
2329
+ "auth_login",
2330
+ "auth_logout",
2331
+ "auth_status",
2332
+ "config",
2333
+ "echo",
2334
+ "explode",
2335
+ "explode-clac",
2336
+ "noop",
2337
+ "ping",
2338
+ "project_create",
2339
+ "project_delete",
2340
+ "project_deploy_create",
2341
+ "project_deploy_rollback",
2342
+ "project_deploy_status",
2343
+ "project_get",
2344
+ "project_list",
2345
+ "slow",
2346
+ "stream",
2347
+ "stream-error",
2348
+ "stream-ok",
2349
+ "stream-text",
2350
+ "stream-throw",
2351
+ "validate-fail",
2352
+ ]
2353
+ `)
2354
+ })
2355
+
2356
+ test('tools/call → executes command and returns result', async () => {
2357
+ const cli = createApp()
2358
+ const sessionId = await initSession(cli)
2359
+ const res = await mcpRequest(
2360
+ cli,
2361
+ {
2362
+ jsonrpc: '2.0',
2363
+ id: 3,
2364
+ method: 'tools/call',
2365
+ params: { name: 'echo', arguments: { message: 'hello' } },
2366
+ },
2367
+ sessionId,
2368
+ )
2369
+ expect(res.status).toBe(200)
2370
+ const body = await res.json()
2371
+ expect({
2372
+ isError: body.result.isError,
2373
+ content: JSON.parse(body.result.content[0].text),
2374
+ }).toMatchInlineSnapshot(`
2375
+ {
2376
+ "content": {
2377
+ "result": [
2378
+ "hello",
2379
+ ],
2380
+ },
2381
+ "isError": undefined,
2382
+ }
2383
+ `)
2384
+ })
2385
+
2386
+ test('tools/call with nested command', async () => {
2387
+ const cli = createApp()
2388
+ const sessionId = await initSession(cli)
2389
+ const res = await mcpRequest(
2390
+ cli,
2391
+ {
2392
+ jsonrpc: '2.0',
2393
+ id: 4,
2394
+ method: 'tools/call',
2395
+ params: { name: 'project_get', arguments: { id: 'p1' } },
2396
+ },
2397
+ sessionId,
2398
+ )
2399
+ expect(res.status).toBe(200)
2400
+ const body = await res.json()
2401
+ expect(JSON.parse(body.result.content[0].text)).toMatchInlineSnapshot(`
2402
+ {
2403
+ "description": "Main project",
2404
+ "id": "p1",
2405
+ "members": [
2406
+ {
2407
+ "role": "admin",
2408
+ "userId": "u1",
2409
+ },
2410
+ ],
2411
+ "name": "Alpha",
2412
+ }
2413
+ `)
2414
+ })
2415
+
2416
+ test('tools/call with error → isError true', async () => {
2417
+ const cli = createApp()
2418
+ const sessionId = await initSession(cli)
2419
+ const res = await mcpRequest(
2420
+ cli,
2421
+ {
2422
+ jsonrpc: '2.0',
2423
+ id: 5,
2424
+ method: 'tools/call',
2425
+ params: { name: 'explode', arguments: {} },
2426
+ },
2427
+ sessionId,
2428
+ )
2429
+ expect(res.status).toBe(200)
2430
+ const body = await res.json()
2431
+ expect({
2432
+ isError: body.result.isError,
2433
+ text: body.result.content[0].text,
2434
+ }).toMatchInlineSnapshot(`
2435
+ {
2436
+ "isError": true,
2437
+ "text": "kaboom",
2438
+ }
2439
+ `)
2440
+ })
2441
+
2442
+ test('non-/mcp paths still work alongside MCP', async () => {
2443
+ const cli = createApp()
2444
+ // Initialize MCP first
2445
+ await initSession(cli)
2446
+ // Regular fetch still works
2447
+ expect(await fetchJson(cli, new Request('http://localhost/ping'))).toMatchInlineSnapshot(`
2448
+ {
2449
+ "body": {
2450
+ "data": {
2451
+ "pong": true,
2452
+ },
2453
+ "meta": {
2454
+ "command": "ping",
2455
+ "duration": "<stripped>",
2456
+ },
2457
+ "ok": true,
2458
+ },
2459
+ "status": 200,
2460
+ }
2461
+ `)
2462
+ })
2463
+ })
2464
+ })
2465
+
2466
+ describe('.well-known/skills', () => {
2467
+ async function fetchSkills(cli: Cli.Cli<any, any, any>, path: string) {
2468
+ const res = await cli.fetch(new Request(`http://localhost${path}`))
2469
+ const contentType = res.headers.get('content-type')
2470
+ const body = contentType?.includes('json') ? await res.json() : await res.text()
2471
+ return { status: res.status, contentType, cacheControl: res.headers.get('cache-control'), body }
2472
+ }
2473
+
2474
+ test('GET /.well-known/skills/index.json returns skill index', async () => {
2475
+ const cli = createApp()
2476
+ const result = await fetchSkills(cli, '/.well-known/skills/index.json')
2477
+ expect(result.status).toBe(200)
2478
+ expect(result.contentType).toBe('application/json')
2479
+ expect(result.cacheControl).toBe('public, max-age=300')
2480
+ const names = result.body.skills.map((s: any) => s.name)
2481
+ expect(names).toMatchInlineSnapshot(`
2482
+ [
2483
+ "api",
2484
+ "auth",
2485
+ "config",
2486
+ "echo",
2487
+ "explode",
2488
+ "explode-clac",
2489
+ "noop",
2490
+ "ping",
2491
+ "project",
2492
+ "slow",
2493
+ "stream",
2494
+ "stream-error",
2495
+ "stream-ok",
2496
+ "stream-text",
2497
+ "stream-throw",
2498
+ "validate-fail",
2499
+ ]
2500
+ `)
2501
+ expect(result.body.skills[0]).toMatchInlineSnapshot(`
2502
+ {
2503
+ "description": "Proxy to HTTP API. Run \`app api --help\` for usage details.",
2504
+ "files": [
2505
+ "SKILL.md",
2506
+ ],
2507
+ "name": "api",
2508
+ }
2509
+ `)
2510
+ })
2511
+
2512
+ test('GET /.well-known/skills/{name}/SKILL.md returns skill markdown', async () => {
2513
+ const cli = createApp()
2514
+ const result = await fetchSkills(cli, '/.well-known/skills/ping/SKILL.md')
2515
+ expect(result.status).toBe(200)
2516
+ expect(result.contentType).toBe('text/markdown')
2517
+ expect(result.cacheControl).toBe('public, max-age=300')
2518
+ expect(result.body).toMatchInlineSnapshot(`
2519
+ "---
2520
+ name: app-ping
2521
+ description: Health check. Run \`app ping --help\` for usage details.
2522
+ command: app ping
2523
+ ---
2524
+
2525
+ # app ping
2526
+
2527
+ Health check"
2528
+ `)
2529
+ })
2530
+
2531
+ test('GET /.well-known/skills/unknown/SKILL.md → 404', async () => {
2532
+ const cli = createApp()
2533
+ const result = await fetchSkills(cli, '/.well-known/skills/nonexistent/SKILL.md')
2534
+ expect(result.status).toBe(404)
2535
+ })
2536
+
2537
+ test('GET /.well-known/skills/unknown-path → 404', async () => {
2538
+ const cli = createApp()
2539
+ const result = await fetchSkills(cli, '/.well-known/skills/bad-path')
2540
+ expect(result.status).toBe(404)
2541
+ })
2542
+ })
2543
+
1767
2544
  async function serve(
1768
2545
  cli: { serve: Cli.Cli['serve'] },
1769
2546
  argv: string[],
@@ -2174,6 +2951,7 @@ function createApp() {
2174
2951
  cli.command(auth)
2175
2952
  cli.command(project)
2176
2953
  cli.command(config)
2954
+ cli.command('api', { description: 'Proxy to HTTP API', fetch: honoApp.fetch })
2177
2955
 
2178
2956
  return cli
2179
2957
  }