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.
- package/README.md +204 -9
- package/SKILL.md +173 -0
- package/dist/Cli.d.ts +39 -6
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +536 -43
- 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/Fetch.d.ts +26 -0
- package/dist/Fetch.d.ts.map +1 -0
- package/dist/Fetch.js +150 -0
- package/dist/Fetch.js.map +1 -0
- 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/Openapi.d.ts +20 -0
- package/dist/Openapi.d.ts.map +1 -0
- package/dist/Openapi.js +136 -0
- package/dist/Openapi.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +4 -1
- package/src/Cli.test-d.ts +27 -2
- package/src/Cli.test.ts +1007 -0
- package/src/Cli.ts +676 -47
- package/src/Errors.ts +5 -0
- package/src/Fetch.test.ts +274 -0
- package/src/Fetch.ts +170 -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/Openapi.test.ts +320 -0
- package/src/Openapi.ts +196 -0
- package/src/e2e.test.ts +778 -0
- package/src/index.ts +3 -0
- 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
|
}
|