diff --git a/.changeset/sharp-rivers-notice.md b/.changeset/sharp-rivers-notice.md new file mode 100644 index 0000000..a0d82c4 --- /dev/null +++ b/.changeset/sharp-rivers-notice.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Added a structured RPC endpoint for command execution over HTTP. diff --git a/README.md b/README.md index af1f717..444ca12 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,27 @@ Responses use the same JSON envelope as `--full-output --format json`: Async generator commands stream as NDJSON (`application/x-ndjson`). Middleware runs the same as in `serve()`. +#### RPC + +The `fetch` handler also exposes `POST /_incur/rpc` for structured command calls. Use it when a client already has separated `args` and `options` objects and should not encode positional args into the URL path: + +```http +POST /_incur/rpc +content-type: application/json + +{ + "command": "users", + "args": { "id": 42 }, + "options": { "limit": 5 } +} +``` + +The response uses the same JSON envelope as command API responses. + +Raw fetch gateways mounted with `command('api', { fetch })` are intentionally not supported by +structured RPC because they accept arbitrary HTTP requests instead of a known command schema. Mount +the gateway with an OpenAPI spec to generate typed operations, or call the HTTP route directly. + #### MCP over HTTP The `fetch` handler automatically exposes an MCP endpoint at `/mcp`. Agents can discover and call your CLI's commands as MCP tools over HTTP — no stdio required: diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 86cd794..d89a8c1 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4069,12 +4069,21 @@ describe('Command.execute', () => { path: 'id', parseMode: 'flat' as const, }, + { + name: 'structured', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputArgs: { id: 123 }, + inputOptions: {}, + path: 'id', + parseMode: 'structured' as const, + }, ])('$name mode returns validation fieldErrors for invalid command input', async (c) => { const result = await Command.execute(c.command, { agent: true, argv: [], format: 'json', formatExplicit: false, + inputArgs: 'inputArgs' in c ? c.inputArgs : undefined, inputOptions: c.inputOptions, name: 'test', parseMode: c.parseMode, @@ -4271,6 +4280,98 @@ describe('fetch', () => { `) }) + test('POST /_incur/rpc executes command with structured args and options', async () => { + const cli = Cli.create('test') + cli.command('math/sum', { + args: z.object({ left: z.number() }), + options: z.object({ right: z.number() }), + run: (c) => ({ value: c.args.left + c.options.right }), + }) + + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + command: 'math/sum', + args: { left: 1 }, + options: { right: 2 }, + }), + }) + + expect(await fetchJson(cli, req)).toMatchInlineSnapshot(` + { + "body": { + "data": { + "value": 3, + }, + "meta": { + "command": "math/sum", + "duration": "", + }, + "ok": true, + }, + "status": 200, + } + `) + }) + + test('POST /_incur/rpc rejects non-object args and options', async () => { + const cli = Cli.create('test').command('ping', { run: () => ({ ok: true }) }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'ping', args: [] }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error).toEqual({ + code: 'VALIDATION_ERROR', + message: '`args` and `options` must be objects.', + }) + }) + + test('POST /_incur/rpc rejects raw fetch gateways', async () => { + const cli = Cli.create('test').command('api', { + fetch: () => new Response(JSON.stringify({ ok: true })), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'api' }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error).toEqual({ + code: 'FETCH_GATEWAY_UNSUPPORTED', + message: + 'Raw fetch gateways cannot be called through structured RPC. Mount the gateway with an OpenAPI spec to generate typed commands, or call the HTTP route directly.', + }) + }) + + test('POST /_incur/rpc returns validation field errors', async () => { + const cli = Cli.create('test').command('sum', { + args: z.object({ left: z.number() }), + run: (c) => ({ value: c.args.left }), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'sum', args: {} }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.fieldErrors).toMatchObject([ + { + missing: true, + path: 'left', + }, + ]) + }) + test('trailing path segments → positional args', async () => { const cli = Cli.create('test') cli.command('users', { diff --git a/src/Cli.ts b/src/Cli.ts index 8af1e64..ce70033 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1523,6 +1523,8 @@ declare namespace fetchImpl { envSchema?: z.ZodObject | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined + /** Structured args received from the RPC route. */ + structuredArgs?: Record | undefined mcpHandler?: | (( req: Request, @@ -1657,6 +1659,14 @@ async function fetchImpl( vars: options.vars, }) + if ( + req.method === 'POST' && + segments[0] === '_incur' && + segments[1] === 'rpc' && + segments.length === 2 + ) + return executeRpcCommand(commands, req, start, options) + // .well-known/skills/ — Agent Skills Discovery (RFC) if ( segments[0] === '.well-known' && @@ -1776,6 +1786,69 @@ async function fetchImpl( }) } +/** @internal Executes an RPC client request. */ +async function executeRpcCommand( + commands: Map, + req: Request, + start: number, + options: fetchImpl.Options, +): Promise { + function jsonResponse(body: unknown, status: number) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + } + + function error(code: string, message: string, status: number, command = '/_incur/rpc') { + return jsonResponse( + { + ok: false, + error: { code, message }, + meta: { command, duration: `${Math.round(performance.now() - start)}ms` }, + }, + status, + ) + } + + let body: unknown + try { + body = await req.json() + } catch { + return error('VALIDATION_ERROR', 'Request body must be JSON.', 400) + } + + if (!isRecord(body)) return error('VALIDATION_ERROR', 'Request body must be an object.', 400) + + if (typeof body.command !== 'string') + return error('VALIDATION_ERROR', '`command` must be a string.', 400) + const command = body.command.trim() + if (!command) return error('VALIDATION_ERROR', '`command` must be a non-empty string.', 400) + + const args = body.args ?? {} + const rpcOptions = body.options ?? {} + if (!isRecord(args) || !isRecord(rpcOptions)) + return error('VALIDATION_ERROR', '`args` and `options` must be objects.', 400) + + const tokens = command.split(/\s+/) + const resolved = resolveCommand(commands, tokens) + if ('fetchGateway' in resolved) + return error( + 'FETCH_GATEWAY_UNSUPPORTED', + 'Raw fetch gateways cannot be called through structured RPC. Mount the gateway with an OpenAPI spec to generate typed commands, or call the HTTP route directly.', + 400, + command, + ) + if (!('command' in resolved) || resolved.rest.length > 0) + return error('COMMAND_NOT_FOUND', 'Command not found.', 404, command) + + return executeCommand(resolved.path, resolved.command, [], rpcOptions, start, { + ...options, + groupMiddlewares: resolved.middlewares, + structuredArgs: args, + }) +} + /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */ async function executeCommand( path: string, @@ -1804,10 +1877,11 @@ async function executeCommand( env: options.envSchema, format: 'json', formatExplicit: true, + inputArgs: options.structuredArgs, inputOptions, middlewares: allMiddleware, name: options.name ?? path, - parseMode: 'split', + parseMode: options.structuredArgs === undefined ? 'split' : 'structured', path, vars: options.vars, version: options.version, @@ -1869,6 +1943,7 @@ async function executeCommand( ...(result.error.retryable !== undefined ? { retryable: result.error.retryable } : undefined), + ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined), }, meta: { command: path, diff --git a/src/internal/command.ts b/src/internal/command.ts index c1103b9..5ffad77 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -82,11 +82,15 @@ export async function execute(command: any, options: execute.Options): Promise | undefined /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */ inputOptions: Record /** Middleware handlers (root + group + command, already collected). */ @@ -296,8 +302,9 @@ export declare namespace execute { * - `'argv'` (default): parse both args and options from argv tokens (CLI mode) * - `'split'`: args from argv, options from inputOptions (HTTP mode) * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode) + * - `'structured'`: args from inputArgs, options from inputOptions (RPC mode) */ - parseMode?: 'argv' | 'split' | 'flat' | undefined + parseMode?: 'argv' | 'split' | 'flat' | 'structured' | undefined /** The resolved command path. */ path: string /** Vars schema for middleware variables. */