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/quiet-walls-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"incur": patch
---

Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs.
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.
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@

- **`z.output<>` over `z.infer<>`** — use `z.output<schema>` for types after transforms/defaults are applied (what `schema.parse()` returns at runtime). Use `z.input<schema>` only when representing pre-validation types.
- **`const` generics on definitions** — any function that accepts Zod schemas and passes them to callbacks must use `const` generic parameters to preserve literal types (e.g. `<const args extends z.ZodObject<any>>`).
- **Streaming commands use `async *run`** — typed client/typegen stream detection is based on the handler being an async generator function. Do not hide streaming behind `run() { return stream() }` when generated client types should be streaming-aware.
- **CLI-value stream inference is structural** — TypeScript cannot reliably distinguish an actual `async *run` handler from `run() { return stream() }` after contextual typing. `Cli` value command maps treat handlers returning `AsyncGenerator` as streaming; runtime/typegen still detect actual async generator functions.
- **Flow schemas through generics** — when a factory function accepts Zod schemas, use generics to flow `z.output<>` through to callbacks (`run`, `next`), return types, and constraint types (`alias`). Never fall back to `any` in callback signatures.
- **Type tests in `.test-d.ts`** — use vitest's `expectTypeOf` in colocated `.test-d.ts` files to assert generic inference works. Type tests are first-class — write them alongside implementation, not as an afterthought.
- **Avoid global declaration merging in type tests** — module augmentation in `.test-d.ts` affects the full `tsc -b` project, so prefer explicit generics/local helper types unless the test is specifically about global registration behavior.
- **No `any` leakage** — Zod schemas may use `z.ZodObject<any>` as a generic bound, but inferred types flowing to user-facing callbacks must be narrowed via `z.output<typeof schema>`. The user should never see `any` in their IDE.
- **Type inference after every feature** — after implementing any feature, check if new types can be narrowed. If a new property, callback, or return type touches a Zod schema, add generics to flow the inferred type through. Add or update `.test-d.ts` type tests alongside.

Expand Down
23 changes: 22 additions & 1 deletion 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 Expand Up @@ -555,7 +576,7 @@ cli.command('deploy', {

### Streaming

Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text:
Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape:

```ts
cli.command('logs', {
Expand Down
11 changes: 9 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ Bun.serve(cli)

## Streaming

Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text:
Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape:

```ts
cli.command('logs', {
Expand Down Expand Up @@ -971,7 +971,14 @@ Generate type definitions for your CLI's command map to get typed CTAs:
incur gen
```

This creates a `incur.generated.ts` file that registers your commands on the `Cli.Commands` type, enabling autocomplete on CTA command names, args, and options.
This creates a `incur.generated.ts` file that registers your commands on the `Cli.Commands` type, enabling autocomplete on CTA command names, args, and options. It also exports a `Commands` type you can import and pass to `createClient` when calling the CLI over RPC:

```ts
import { createClient } from 'incur'
import type { Commands } from './incur.generated.js'

const client = createClient<Commands>({ baseUrl: 'https://api.example.com' })
```

## Full Example

Expand Down
250 changes: 249 additions & 1 deletion src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,256 @@ test('command() accumulates command types through chaining', () => {
expectTypeOf<Commands['get']>().toEqualTypeOf<{
args: { id: number }
options: { verbose: boolean }
output: unknown
}>()
expectTypeOf<Commands['list']>().toEqualTypeOf<{
args: {}
options: { limit: number }
output: unknown
}>()
})

test('command() accumulates output and structural stream metadata', () => {
const cli = Cli.create('test')
.command('inspect', {
output: z.object({ id: z.string(), ok: z.boolean() }),
run: () => ({ id: 'p1', ok: true }),
})
.command('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['inspect']>().toEqualTypeOf<{
args: {}
options: {}
output: { id: string; ok: boolean }
}>()
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('run() returning a generator keeps output metadata', () => {
async function* logs() {
yield { line: 'one' }
}
const cli = Cli.create('test').command('logs', {
output: z.object({ line: z.string() }),
run() {
return logs()
},
})

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('root CLIs preserve output and structural stream metadata when mounted', () => {
const status = Cli.create('status', {
output: z.object({ ok: z.boolean() }),
run: () => ({ ok: true }),
})
const logs = Cli.create('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})
const cli = Cli.create('test').command(status).command(logs)

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['status']>().toEqualTypeOf<{
args: {}
options: {}
output: { ok: boolean }
}>()
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('mounted sub-CLIs preserve output and structural stream metadata', () => {
const project = Cli.create('project')
.command('inspect', {
output: z.object({ id: z.string() }),
run: () => ({ id: 'p1' }),
})
.command('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})
const cli = Cli.create('test').command(project)

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['project inspect']>().toEqualTypeOf<{
args: {}
options: {}
output: { id: string }
}>()
expectTypeOf<Commands['project logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('OpenAPI mounted fetch accumulates operation command types', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Test', version: '1.0.0' },
paths: {
'/users': {
get: {
operationId: 'listUsers',
parameters: [
{
name: 'limit',
in: 'query',
schema: { type: 'number' },
},
],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id', 'name'],
},
},
},
required: ['users'],
},
},
},
},
},
},
post: {
operationId: 'createUser',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: { name: { type: 'string' }, active: { type: 'boolean' } },
required: ['name'],
},
},
},
},
responses: {
201: {
description: 'Created',
content: {
'application/json': {
schema: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id', 'name'],
},
},
},
},
},
},
},
'/users/{id}': {
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'number' },
},
],
get: {
operationId: 'getUser',
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id', 'name'],
},
},
},
},
},
},
},
},
} as const

const cli = Cli.create('test').command('api', {
fetch: () => new Response(),
openapi: spec,
})

type Commands = typeof cli extends Cli.Cli<infer commands> ? commands : never
expectTypeOf<Commands['api listUsers']>().toExtend<{
args: {}
options: { limit?: number | undefined }
output: { users: { id: number; name: string }[] }
}>()
expectTypeOf<Commands['api createUser']>().toExtend<{
args: {}
options: { name?: string | undefined; active?: boolean | undefined }
output: { id: number; name: string }
}>()
expectTypeOf<Commands['api getUser']>().toExtend<{
args: { id: number }
options: {}
output: { id: number; name: string }
}>()

// @ts-expect-error raw gateway name is not a generated command
type RawGateway = Commands['api']
void (undefined as unknown as RawGateway)
})

test('mounted root CLI preserves output type in command map', () => {
const deploy = Cli.create('deploy', {
output: z.object({ id: z.string(), status: z.literal('queued') }),
run: () => ({ id: 'dep_123', status: 'queued' as const }),
})

const cli = Cli.create('test').command(deploy)

type Commands = typeof cli extends Cli.Cli<infer commands> ? commands : never
expectTypeOf<Commands['deploy']>().toExtend<{
args: {}
options: {}
output: { id: string; status: 'queued' }
}>()
expectTypeOf<Commands['list']>().toEqualTypeOf<{ args: {}; options: { limit: number } }>()
})

test('middleware<typeof cli.vars>() infers vars types', () => {
Expand Down
Loading