Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-rivers-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Added a structured RPC endpoint for command execution over HTTP.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": "<stripped>",
},
"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', {
Expand Down
77 changes: 76 additions & 1 deletion src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,8 @@ declare namespace fetchImpl {
envSchema?: z.ZodObject<any> | undefined
/** Group-level middleware collected during command resolution. */
groupMiddlewares?: MiddlewareHandler[] | undefined
/** Structured args received from the RPC route. */
structuredArgs?: Record<string, unknown> | undefined
mcpHandler?:
| ((
req: Request,
Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -1776,6 +1786,69 @@ async function fetchImpl(
})
}

/** @internal Executes an RPC client request. */
async function executeRpcCommand(
commands: Map<string, CommandEntry>,
req: Request,
start: number,
options: fetchImpl.Options,
): Promise<Response> {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,15 @@ export async function execute(command: any, options: execute.Options): Promise<e
const parsed = Parser.parse(argv, { args: command.args })
args = parsed.args
parsedOptions = command.options ? Parser.zodParse(command.options, inputOptions) : {}
} else {
} else if (parseMode === 'flat') {
// MCP mode: all params come from inputOptions, split into args vs options
const split = splitParams(inputOptions, command)
args = command.args ? Parser.zodParse(command.args, split.args) : {}
parsedOptions = command.options ? Parser.zodParse(command.options, split.options) : {}
} else {
// RPC mode: args and options are already separated by the client
args = command.args ? Parser.zodParse(command.args, options.inputArgs ?? {}) : {}
parsedOptions = command.options ? Parser.zodParse(command.options, inputOptions) : {}
}

// Parse env
Expand Down Expand Up @@ -285,6 +289,8 @@ export declare namespace execute {
format: string
/** Whether the format was explicitly requested. */
formatExplicit: boolean
/** Raw parsed args for structured RPC input. */
inputArgs?: Record<string, unknown> | undefined
/** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */
inputOptions: Record<string, unknown>
/** Middleware handlers (root + group + command, already collected). */
Expand All @@ -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. */
Expand Down