diff --git a/.changeset/mcp-doctor-race.md b/.changeset/mcp-doctor-race.md
new file mode 100644
index 00000000..37c84e43
--- /dev/null
+++ b/.changeset/mcp-doctor-race.md
@@ -0,0 +1,5 @@
+---
+'incur': patch
+---
+
+Fixed an intermittent `mcp doctor` failure where the tools/list response could be missed under load.
diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md
new file mode 100644
index 00000000..7d5598c1
--- /dev/null
+++ b/.changeset/quiet-walls-share.md
@@ -0,0 +1,5 @@
+---
+'incur': patch
+---
+
+Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs.
diff --git a/.changeset/sour-dingos-shine.md b/.changeset/sour-dingos-shine.md
new file mode 100644
index 00000000..9fefa900
--- /dev/null
+++ b/.changeset/sour-dingos-shine.md
@@ -0,0 +1,7 @@
+---
+'incur': patch
+---
+
+Fixed streaming command terminal records so HTTP NDJSON responses preserve returned `c.ok()` CTA metadata, represent returned or yielded `c.error()` values as terminal errors, include terminal duration metadata, and unwind generators on response cancellation.
+
+Also preserves `IncurError.retryable` metadata in streaming machine-format errors.
diff --git a/.changeset/tame-pillows-accept.md b/.changeset/tame-pillows-accept.md
new file mode 100644
index 00000000..84bce733
--- /dev/null
+++ b/.changeset/tame-pillows-accept.md
@@ -0,0 +1,7 @@
+---
+'incur': patch
+---
+
+Fixed generated and synced skills to use the same command projection as CLI skill output.
+
+`Skillgen` and `SyncSkills` now avoid generating duplicate skills for command aliases, preserve output schemas and examples consistently, and include the fetch gateway skill hint for fetch-based commands.
diff --git a/README.md b/README.md
index 3f6fac5e..97a5324b 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
- Features · Quickprompt · Install · Usage · Walkthrough · License
+ Features · Quickprompt · Install · Usage · TypeScript Client · Walkthrough · License
## Features
@@ -411,6 +411,189 @@ POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "user
Non-`/mcp` paths continue routing to the command API as usual.
+## TypeScript Client
+
+Use the TypeScript client when another TypeScript program needs to call an incur CLI with typed commands, structured data, streaming, CTAs, and discovery resources. Use the CLI directly for shell workflows, Skills for agent discovery, and MCP when the caller is an MCP-capable agent.
+
+### Generate Command Types
+
+Export the CLI instance from your entrypoint:
+
+```ts
+import { Cli, z } from 'incur'
+
+const cli = Cli.create('acme', {
+ description: 'Acme operations CLI',
+}).command('project status', {
+ args: z.object({ projectId: z.string() }),
+ output: z.object({ status: z.enum(['ok', 'blocked']) }),
+ run(c) {
+ return { status: 'ok' as const }
+ },
+})
+
+cli.serve()
+
+export default cli
+```
+
+Generate the command map:
+
+```sh
+npx incur gen --entry ./src/cli.ts --output ./src/incur.generated.ts
+```
+
+Import the generated type where you create clients:
+
+```ts
+import { HttpClient, MemoryClient } from 'incur/client'
+import type { Commands } from './incur.generated.js'
+```
+
+`incur gen` also augments `incur` and `incur/client`, so clients can use registered command types without explicit generics after the generated file is included by TypeScript.
+
+### HTTP Client
+
+Serve the CLI with `cli.fetch`, then call it with `HttpClient.create()`:
+
+```ts
+import { HttpClient } from 'incur/client'
+import type { Commands } from './incur.generated.js'
+
+const client = HttpClient.create({
+ baseUrl: 'https://ops.acme.test',
+ headers: { authorization: `Bearer ${token}` },
+ outputFormat: 'toon',
+})
+
+const status = await client.run('project status', {
+ args: { projectId: 'proj_web_2026' },
+})
+
+status.data.status
+// ^? 'ok' | 'blocked'
+```
+
+`HttpClient` talks to the served CLI's `/_incur/rpc` endpoint for command runs and `/_incur/*` resource endpoints for discovery. You normally should not call those lower-level endpoints directly.
+
+### Memory Client
+
+Use `MemoryClient.create(cli)` for in-process callers, tests, local automation, and tools that need local-only actions:
+
+```ts
+import { MemoryClient } from 'incur/client'
+import cli from './cli.js'
+
+const client = MemoryClient.create(cli, {
+ env: { ACME_TOKEN: 'dev_secret_123' },
+})
+
+const result = await client.run('project status', {
+ args: { projectId: 'proj_web_2026' },
+})
+```
+
+Memory clients infer commands directly from a concrete CLI. They also expose filesystem actions that HTTP clients intentionally do not expose:
+
+```ts
+await client.skills.list()
+await client.skills.add({ global: true })
+await client.mcp.add({ agents: ['codex'] })
+```
+
+### Running Commands
+
+`client.run(command, input)` mirrors CLI invocation:
+
+```ts
+const report = await client.run('project report', {
+ args: { projectId: 'proj_web_2026' },
+ options: { includeClosed: false },
+ selection: ['summary', 'items[0:3]', 'nextCursor'],
+ outputFormat: 'md',
+ outputTokenCount: true,
+ outputTokenLimit: 128,
+})
+```
+
+The result contains typed structured data, optional rendered output text, and metadata:
+
+```ts
+report.ok
+report.data
+report.output?.text
+report.output?.next
+report.meta.cta
+```
+
+`selection` is equivalent to `--filter-output`. Because it changes the shape of `data`, selected results are typed as `unknown`. Pass `selection: undefined` on a call to clear a client-level default and recover the full output type.
+
+### Streaming
+
+Commands implemented with `async *run` return a stream wrapper:
+
+```ts
+const stream = await client.run('logs tail', {
+ args: { service: 'checkout-api' },
+})
+
+for await (const line of stream) {
+ console.log(line)
+}
+
+const final = await stream.final
+```
+
+Use `stream.records()` when you need raw chunk, done, and error records. A stream can be consumed once: either chunks, records, or final-only consumption.
+
+### CTAs and Errors
+
+CTAs returned by commands are runnable from the client:
+
+```ts
+const cta = report.meta.cta?.commands[0]
+if (cta) {
+ console.log(cta.cliCommand)
+ const next = await cta.run({ outputFormat: 'toon' })
+}
+```
+
+Failed command runs throw `Client.ClientError`:
+
+```ts
+import { Client } from 'incur/client'
+
+try {
+ await client.run('project deploy', {
+ args: { projectId: 'proj_web_2026' },
+ options: { environment: 'production' },
+ })
+} catch (error) {
+ if (error instanceof Client.ClientError) {
+ console.error(error.code, error.status, error.retryable)
+ console.error(error.meta?.cta)
+ }
+}
+```
+
+### Discovery Resources
+
+Clients can read the same discovery surfaces agents use:
+
+```ts
+await client.llms()
+await client.llms({ command: 'project', format: 'md' })
+await client.llmsFull()
+await client.schema('project report')
+await client.help('project report')
+await client.openapi()
+await client.skills.index()
+await client.skills.get('deploy')
+await client.mcp.tools()
+```
+
+Use these resource actions for documentation, SDK tooling, agent setup, tests, and UI generation. Use `client.run()` for actual command execution.
+
## Walkthrough
### Agent discovery
diff --git a/SKILL.md b/SKILL.md
index 6bb33bf8..ba1d3ec7 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -965,13 +965,45 @@ async *run({ ok }) {
## Type Generation
-Generate type definitions for your CLI's command map to get typed CTAs:
+Generate type definitions for your CLI's command map:
```sh
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.
+The CLI entrypoint must `export default cli` so `incur gen` can import it. The generated file exports `Commands` and augments both `incur` and `incur/client`, enabling typed CTAs while authoring a CLI and typed TypeScript clients when consuming one.
+
+```ts
+import { HttpClient } from 'incur/client'
+import type { Commands } from './incur.generated.js'
+
+const client = HttpClient.create({ baseUrl: 'https://ops.acme.test' })
+```
+
+## TypeScript Client
+
+Use `incur/client` when TypeScript code needs to consume an incur CLI programmatically. Prefer normal CLI commands for shell workflows, Skills for agent usage, and MCP for MCP-capable agents.
+
+```ts
+import { HttpClient, MemoryClient } from 'incur/client'
+import cli from './cli.js'
+import type { Commands } from './incur.generated.js'
+
+const http = HttpClient.create({
+ baseUrl: 'https://ops.acme.test',
+ outputFormat: 'toon',
+})
+
+const memory = MemoryClient.create(cli, {
+ env: { ACME_TOKEN: 'dev_secret_123' },
+})
+
+const result = await http.run('project status', {
+ args: { projectId: 'proj_web_2026' },
+})
+```
+
+Use the dedicated `incur-typescript-client` skill for exhaustive client usage: `HttpClient`, `MemoryClient`, lower-level transports, `client.run`, streaming, CTAs, `ClientError`, discovery resources, and memory-only local actions.
## Full Example
diff --git a/package.json b/package.json
index 0bae8bfc..3f805282 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"examples",
"dist",
"src",
+ "skills",
"SKILL.md"
],
"dependencies": {
@@ -70,6 +71,11 @@
"types": "./dist/index.d.ts",
"src": "./src/index.ts",
"default": "./dist/index.js"
+ },
+ "./client": {
+ "types": "./dist/client/index.d.ts",
+ "src": "./src/client/index.ts",
+ "default": "./dist/client/index.js"
}
}
}
diff --git a/skills/incur-typescript-client/SKILL.md b/skills/incur-typescript-client/SKILL.md
new file mode 100644
index 00000000..0321328a
--- /dev/null
+++ b/skills/incur-typescript-client/SKILL.md
@@ -0,0 +1,701 @@
+---
+name: incur-typescript-client
+description: Use when consuming an incur CLI from TypeScript with `incur/client`, including generated command types, `HttpClient`, `MemoryClient`, streaming, CTAs, resources, and client errors.
+command: incur
+---
+
+# incur TypeScript Client
+
+Use this skill when TypeScript code needs to call an incur CLI programmatically. Use the root `incur` skill when building the CLI itself. Use shell commands, generated Skills, or MCP when the caller is an agent or human operating outside TypeScript.
+
+The public client API lives in `incur/client`:
+
+```ts
+import {
+ Client,
+ HttpClient,
+ HttpTransport,
+ Local,
+ MemoryClient,
+ MemoryTransport,
+ Resources,
+ Run,
+} from 'incur/client'
+```
+
+## Setup
+
+The client is typed from a command map. Generate it from the CLI entrypoint:
+
+```ts
+// src/cli.ts
+import { Cli, z } from 'incur'
+
+const cli = Cli.create('acme', {
+ description: 'Acme operations CLI',
+})
+ .command('project status', {
+ args: z.object({ projectId: z.string() }),
+ output: z.object({ status: z.enum(['ok', 'blocked']) }),
+ run() {
+ return { status: 'ok' as const }
+ },
+ })
+ .command('logs tail', {
+ args: z.object({ service: z.string() }),
+ output: z.object({ line: z.string() }),
+ async *run() {
+ yield { line: 'ready' }
+ },
+ })
+
+cli.serve()
+
+export default cli
+```
+
+Run type generation:
+
+```sh
+npx incur gen --entry ./src/cli.ts --output ./src/incur.generated.ts
+```
+
+The generated file exports `Commands` and augments both `incur` and `incur/client`:
+
+```ts
+import type { Commands } from './incur.generated.js'
+```
+
+Command IDs are full command paths such as `'project status'` or `'logs tail'`. Command map entries have this shape:
+
+```ts
+type Commands = {
+ 'project status': {
+ args: { projectId: string }
+ options: {}
+ output: { status: 'ok' | 'blocked' }
+ }
+ 'logs tail': {
+ args: { service: string }
+ options: {}
+ output: { line: string }
+ stream: true
+ }
+}
+```
+
+## Creating Clients
+
+Use `HttpClient` for remote or served CLIs. The CLI must be exposed with `cli.fetch` in Bun, Deno, Cloudflare Workers, Hono, Next.js, or another Fetch-compatible runtime.
+
+```ts
+import { HttpClient } from 'incur/client'
+import type { Commands } from './incur.generated.js'
+
+const client = HttpClient.create({
+ baseUrl: 'https://ops.acme.test',
+ // Optional; defaults to globalThis.fetch.
+ fetch,
+ // Optional; merged into every request.
+ headers: { authorization: `Bearer ${token}` },
+ // Defaults for every client.run(). Per-call input overrides these.
+ outputFormat: 'toon',
+})
+```
+
+Use `MemoryClient` for in-process calls, tests, local automation, and local setup actions:
+
+```ts
+import { MemoryClient } from 'incur/client'
+import cli from './cli.js'
+
+const memoryClient = MemoryClient.create(cli, {
+ env: { ACME_TOKEN: 'dev_secret_123' },
+ outputFormat: 'toon',
+})
+```
+
+`MemoryClient.create(cli)` infers commands from a concrete CLI. You can still provide an explicit command map when needed:
+
+```ts
+const memoryClient = MemoryClient.create(cli)
+```
+
+Use `Client.create()` and transports only when composing lower-level client infrastructure:
+
+```ts
+const httpViaTransport = Client.create({
+ transport: HttpTransport.create({
+ baseUrl: 'https://ops.acme.test',
+ headers: { authorization: `Bearer ${token}` },
+ }),
+ outputFormat: 'toon',
+})
+
+const memoryViaTransport = Client.create({
+ transport: MemoryTransport.create(cli, {
+ env: { ACME_TOKEN: 'dev_secret_123' },
+ }),
+})
+```
+
+## Running Commands
+
+`client.run(command, input)` mirrors a CLI invocation. `args` are positional arguments, `options` are named flags, and output controls mirror global CLI flags.
+
+```ts
+const report = await client.run('project report', {
+ args: { projectId: 'proj_web_2026' },
+ options: { includeClosed: false },
+
+ // Equivalent to --filter-output. This changes result.data, so data is typed unknown.
+ selection: ['summary', 'items[0:3]', 'nextCursor'],
+
+ // These affect rendered result.output.text, not the server's original full output.
+ outputFormat: 'md',
+ outputTokenCount: true,
+ outputTokenLimit: 128,
+})
+```
+
+The returned value for non-streaming commands is `Run.Result`:
+
+```ts
+console.log(report)
+/// Run.Result
+// {
+// ok: true,
+// data: {
+// summary: 'Website refresh is on track',
+// items: [
+// { id: 'task_1', title: 'Finalize copy', status: 'done' },
+// { id: 'task_2', title: 'QA checkout flow', status: 'blocked' },
+// { id: 'task_3', title: 'Publish launch checklist', status: 'open' },
+// ],
+// nextCursor: 'task_4',
+// },
+// output: {
+// text: '## Website refresh is on track\n\n- done: Finalize copy\n- blocked: QA checkout flow',
+// format: 'md',
+// tokenCount: 37,
+// tokenLimit: 128,
+// tokenOffset: 0,
+// next: [Function],
+// },
+// meta: {
+// command: 'project report',
+// duration: '18ms',
+// cta: {
+// commands: [
+// {
+// command: 'project unblock',
+// cliCommand: 'project unblock task_2',
+// description: 'Unblock the blocked checkout QA task.',
+// args: { taskId: 'task_2' },
+// options: {},
+// raw: { command: 'project unblock', args: { taskId: 'task_2' } },
+// run: [Function],
+// },
+// ],
+// },
+// },
+// }
+```
+
+Because `selection` changes the shape of `data`, selected results are typed as `unknown`.
+
+If `output.next` exists, fetch the next rendered output page for the same command:
+
+```ts
+const nextPage = await report.output?.next?.()
+
+console.log(nextPage)
+/// Run.Result | undefined
+// {
+// ok: true,
+// data: { ... },
+// output: {
+// text: '- open: Publish launch checklist',
+// format: 'md',
+// tokenCount: 37,
+// tokenLimit: 128,
+// tokenOffset: 128,
+// },
+// meta: { command: 'project report', duration: '12ms' },
+// }
+```
+
+Input is strict. Required `args` and `options` make the input object required; unknown commands and extra keys are rejected by TypeScript when the command map is known.
+
+```ts
+await client.run('project status', {
+ args: { projectId: 'proj_web_2026' },
+})
+
+// Type error: unknown command.
+await client.run('project missing')
+
+// Type error: missing required args.
+await client.run('project status')
+```
+
+If the client has a default `selection`, result data is conservative `unknown`. Clear it for a call with `selection: undefined` to recover the full output type:
+
+```ts
+const selectedClient = HttpClient.create({
+ baseUrl: 'https://ops.acme.test',
+ selection: ['summary'],
+})
+
+const selected = await selectedClient.run('project report', {
+ args: { projectId: 'proj_web_2026' },
+})
+// selected.data is unknown
+
+const full = await selectedClient.run('project report', {
+ args: { projectId: 'proj_web_2026' },
+ selection: undefined,
+})
+
+console.log(full)
+/// Run.Result
+// {
+// ok: true,
+// data: {
+// summary: 'Website refresh is on track',
+// items: [
+// { id: 'task_1', title: 'Finalize copy', status: 'done' },
+// { id: 'task_2', title: 'QA checkout flow', status: 'blocked' },
+// { id: 'task_3', title: 'Publish launch checklist', status: 'open' },
+// ],
+// nextCursor: 'task_4',
+// },
+// output: {
+// text: 'summary: Website refresh is on track\nitems[3]{id,title,status}: ...',
+// format: 'toon',
+// },
+// meta: { command: 'project report', duration: '18ms' },
+// }
+```
+
+## CTAs
+
+Commands can return CTAs in `meta.cta`. Client CTAs are runnable:
+
+```ts
+const cta = report.meta.cta?.commands[0]
+
+console.log(cta)
+/// Run.Cta | undefined
+// {
+// command: 'project unblock',
+// cliCommand: 'project unblock task_2',
+// description: 'Unblock the blocked checkout QA task.',
+// args: { taskId: 'task_2' },
+// options: {},
+// raw: {
+// command: 'project unblock',
+// args: { taskId: 'task_2' },
+// options: {},
+// description: 'Unblock the blocked checkout QA task.',
+// },
+// run: [Function],
+// }
+
+if (cta) {
+ const result = await cta.run({
+ outputFormat: 'toon',
+ })
+
+ console.log(result)
+ /// Run.Result
+ // {
+ // ok: true,
+ // data: { unblocked: true, taskId: 'task_2' },
+ // output: {
+ // text: 'unblocked: true\ntaskId: task_2',
+ // format: 'toon',
+ // },
+ // meta: { command: 'project unblock', duration: '14ms' },
+ // }
+}
+```
+
+CTA `run()` does not inherit output controls from the original command result. Pass the controls you want for the CTA run.
+
+CTA objects have `command`, `cliCommand`, optional `description`, `args`, `options`, `raw`, and `run()`. Do not check for a `runnable` property.
+
+## Errors
+
+Failed command runs and malformed client responses throw `Client.ClientError`:
+
+```ts
+import { Client } from 'incur/client'
+
+try {
+ await client.run('project deploy', {
+ args: { projectId: 'proj_web_2026' },
+ options: { environment: 'production' },
+ })
+} catch (error) {
+ if (error instanceof Client.ClientError) {
+ console.log(error)
+ /// Client.ClientError
+ // Incur.ClientError: Login required before deploying.
+ // {
+ // message: 'Login required before deploying.',
+ // code: 'NOT_AUTHENTICATED',
+ // status: 401,
+ // retryable: false,
+ // fieldErrors: undefined,
+ // meta: {
+ // command: 'project deploy',
+ // duration: '4ms',
+ // cta: {
+ // description: 'Authenticate before deploying.',
+ // commands: [
+ // {
+ // command: 'auth login',
+ // cliCommand: 'auth login',
+ // description: 'Log in to Acme.',
+ // args: {},
+ // options: {},
+ // raw: { command: 'auth login', description: 'Log in to Acme.' },
+ // run: [Function],
+ // },
+ // ],
+ // },
+ // },
+ // error: {
+ // code: 'NOT_AUTHENTICATED',
+ // message: 'Login required before deploying.',
+ // retryable: false,
+ // },
+ // data: {
+ // ok: false,
+ // error: {
+ // code: 'NOT_AUTHENTICATED',
+ // message: 'Login required before deploying.',
+ // retryable: false,
+ // },
+ // meta: {
+ // command: 'project deploy',
+ // duration: '4ms',
+ // cta: { ... },
+ // },
+ // },
+ // }
+ }
+}
+```
+
+## Streaming
+
+Commands implemented with `async *run` return `Run.StreamResponse`.
+
+```ts
+const stream = await client.run('logs tail', {
+ args: { service: 'checkout-api' },
+})
+
+for await (const chunk of stream) {
+ console.log(chunk)
+ /// LogLine
+ // {
+ // timestamp: '2026-05-24T10:15:00Z',
+ // level: 'info',
+ // message: 'request completed',
+ // }
+}
+
+const final = await stream.final
+
+console.log(final)
+/// Run.StreamFinal
+// {
+// ok: true,
+// data: { lines: 124 },
+// output: {
+// text: 'lines: 124',
+// format: 'toon',
+// },
+// meta: {
+// command: 'logs tail',
+// duration: '30s',
+// },
+// }
+```
+
+Use `records()` when you need every stream record, including terminal error records:
+
+```ts
+const rawStream = await client.run('logs tail', {
+ args: { service: 'checkout-api' },
+})
+
+for await (const record of rawStream.records()) {
+ if (record.type === 'chunk') {
+ console.log(record)
+ /// Extract, { type: 'chunk' }>
+ // {
+ // type: 'chunk',
+ // data: {
+ // timestamp: '2026-05-24T10:15:00Z',
+ // level: 'info',
+ // message: 'request completed',
+ // },
+ // output: {
+ // text: 'timestamp: 2026-05-24T10:15:00Z\nlevel: info\nmessage: request completed',
+ // format: 'toon',
+ // },
+ // }
+ }
+
+ if (record.type === 'done') {
+ console.log(record)
+ /// Extract, { type: 'done' }>
+ // {
+ // type: 'done',
+ // ok: true,
+ // data: { lines: 124 },
+ // output: { text: 'lines: 124', format: 'toon' },
+ // meta: { command: 'logs tail', duration: '30s' },
+ // }
+ }
+
+ if (record.type === 'error') {
+ console.log(record)
+ /// Extract, { type: 'error' }>
+ // {
+ // type: 'error',
+ // ok: false,
+ // error: {
+ // code: 'LOG_STREAM_DISCONNECTED',
+ // message: 'Log stream disconnected.',
+ // retryable: true,
+ // },
+ // meta: { command: 'logs tail', duration: '30s' },
+ // }
+ }
+}
+```
+
+A stream can only be consumed once: use async iteration, `.records()`, or `.final` as the consumption mode. Streaming commands allow `selection` and `outputFormat`, but reject token pagination controls such as `outputTokenLimit`.
+
+## Discovery Resources
+
+Resource actions are read-only and available on both HTTP and memory clients:
+
+```ts
+const llms = await client.llms()
+const llmsMd = await client.llms({ command: 'project', format: 'md' })
+const full = await client.llmsFull()
+const schema = await client.schema('project report')
+const help = await client.help('project report')
+const openapi = await client.openapi()
+const skills = await client.skills.index()
+const deploySkill = await client.skills.get('deploy')
+const tools = await client.mcp.tools()
+
+console.log(llms)
+/// Resources.LlmsManifest
+// {
+// version: 'incur.v1',
+// commands: [
+// {
+// name: 'project report',
+// description: 'Summarize project progress.',
+// },
+// {
+// name: 'project status',
+// description: 'Show project status.',
+// },
+// ],
+// }
+
+console.log(llmsMd)
+/// string
+// '# acme project\n\n| Command | Description |\n|---------|-------------|\n| `acme project report ` | Summarize project progress. |'
+
+console.log(full)
+/// Resources.LlmsFullManifest
+// {
+// version: 'incur.v1',
+// commands: [
+// {
+// name: 'project report',
+// description: 'Summarize project progress.',
+// schema: {
+// args: {
+// type: 'object',
+// required: ['projectId'],
+// properties: { projectId: { type: 'string' } },
+// },
+// options: {
+// type: 'object',
+// properties: { includeClosed: { type: 'boolean' } },
+// },
+// output: {
+// type: 'object',
+// properties: { summary: { type: 'string' } },
+// },
+// },
+// },
+// ],
+// }
+
+console.log(schema)
+/// Resources.CommandSchema
+// {
+// args: {
+// type: 'object',
+// required: ['projectId'],
+// properties: { projectId: { type: 'string' } },
+// },
+// options: {
+// type: 'object',
+// properties: { includeClosed: { type: 'boolean' } },
+// },
+// output: {
+// type: 'object',
+// properties: { summary: { type: 'string' } },
+// },
+// }
+
+console.log(help)
+/// string
+// 'Usage: acme project report [--include-closed]\n\nSummarize project progress.'
+
+console.log(openapi)
+/// Resources.OpenApiDocument
+// {
+// openapi: '3.1.0',
+// info: { title: 'acme', version: '1.0.0' },
+// paths: { ... },
+// }
+
+console.log(skills)
+/// Resources.SkillsIndex
+// {
+// skills: [
+// {
+// name: 'acme-project',
+// description: 'Project commands. Run `acme project --help` for usage details.',
+// files: ['SKILL.md'],
+// },
+// ],
+// }
+
+console.log(deploySkill)
+/// string
+// '---\nname: acme-deploy\ndescription: Deploy safely. Run `acme deploy --help` for usage details.\n---\n\n# acme deploy\n\nDeploy safely.'
+
+console.log(tools)
+/// Resources.McpToolsResponse
+// {
+// tools: [
+// {
+// name: 'project_report',
+// description: 'Summarize project progress.',
+// inputSchema: {
+// type: 'object',
+// properties: {
+// projectId: { type: 'string' },
+// includeClosed: { type: 'boolean' },
+// },
+// required: ['projectId'],
+// },
+// outputSchema: {
+// type: 'object',
+// properties: { summary: { type: 'string' } },
+// },
+// },
+// ],
+// }
+```
+
+`llms()` and `llmsFull()` return structured data by default. Passing a non-JSON `format` returns a string.
+
+Use command-group scopes where accepted:
+
+```ts
+await client.llmsFull({ command: 'project' })
+await client.schema('project')
+await client.help('project report')
+```
+
+Use discovery resources for docs, SDK tooling, UI generation, tests, and agent setup. Use `client.run()` for command execution.
+
+## Memory-Only Local Actions
+
+Memory clients expose local setup actions that HTTP clients do not expose:
+
+```ts
+const localSkills = await memoryClient.skills.list()
+
+const syncedSkills = await memoryClient.skills.add({
+ depth: 1,
+ global: true,
+})
+
+const mcpRegistration = await memoryClient.mcp.add({
+ agents: ['codex'],
+})
+
+console.log(localSkills)
+/// Local.SkillsList
+// {
+// skills: [
+// {
+// name: 'acme-project',
+// description: 'Project commands. Run `acme project --help` for usage details.',
+// installed: false,
+// },
+// ],
+// }
+
+console.log(syncedSkills)
+/// Local.SyncedSkills
+// {
+// skills: [
+// {
+// name: 'acme-project',
+// description: 'Project commands. Run `acme project --help` for usage details.',
+// },
+// ],
+// paths: ['/Users/alice/.config/agents/skills/acme-project'],
+// agents: [
+// {
+// agent: 'Codex',
+// path: '/Users/alice/.codex/skills/acme-project',
+// },
+// ],
+// }
+
+console.log(mcpRegistration)
+/// Local.McpRegistration
+// {
+// command: 'acme --mcp',
+// agents: [
+// {
+// agent: 'Codex',
+// path: '/Users/alice/.codex/config.toml',
+// },
+// ],
+// }
+```
+
+These actions modify local agent configuration or local skill files. They are intentionally unavailable over HTTP, RPC, and MCP.
+
+```ts
+// Type error: HTTP clients do not expose local actions.
+client.skills.add()
+```
+
+## Lower-Level Notes
+
+Most code should use `HttpClient.create`, `MemoryClient.create`, and `client.run`. Reach for `Client.create` and transport factories when building reusable infrastructure around transports.
+
+HTTP clients call `/_incur/rpc` for command execution and `/_incur/*` discovery endpoints for resources. Memory clients call the CLI in-process.
+
+Fetch gateway commands mounted with `.command('api', { fetch })` are not part of the structured generated command map and cannot be called through typed structured RPC as ordinary commands. Call the served Fetch API routes directly for gateway routes.
diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts
index 88000402..dc73fc48 100644
--- a/src/Cli.test-d.ts
+++ b/src/Cli.test-d.ts
@@ -159,6 +159,28 @@ test('Cta accepts object form', () => {
expectTypeOf<{ command: 'auth login'; description: 'Log in' }>().toMatchTypeOf()
})
+test('OpenAPI-mounted operations are included in CLI command map type', () => {
+ const cli = Cli.create('test').command('api', {
+ fetch: () => new Response('{}'),
+ openapi: {
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'listUsers',
+ responses: { '200': { description: 'ok' } },
+ },
+ },
+ },
+ },
+ })
+
+ expectTypeOf().toMatchTypeOf<
+ Cli.Cli<{
+ 'api listUsers': { args: Record; options: Record }
+ }>
+ >()
+})
+
test('Cta narrows strings and objects to registered commands', () => {
type Commands = {
get: { args: { id: number }; options: {} }
diff --git a/src/Cli.test.ts b/src/Cli.test.ts
index b5d158b0..4dc1f4b2 100644
--- a/src/Cli.test.ts
+++ b/src/Cli.test.ts
@@ -50,10 +50,10 @@ async function serve(
function mockMcpServeResponses(responses: unknown[]) {
return vi.spyOn(Mcp, 'serve').mockImplementation(async (_name, _version, _commands, options) => {
+ const output = options!.output
for (const response of responses)
- options!.output?.write(
- `${typeof response === 'string' ? response : JSON.stringify(response)}\n`,
- )
+ output?.write(`${typeof response === 'string' ? response : JSON.stringify(response)}\n`)
+ output?.end()
})
}
@@ -4735,6 +4735,7 @@ describe('Command.execute', () => {
async function fetchJson(cli: Cli.Cli, req: Request) {
const res = await cli.fetch(req)
const body = await res.json()
+ expect(body.meta.duration).toMatch(/^\d+ms$/)
body.meta.duration = ''
return { status: res.status, body }
}
@@ -4803,6 +4804,179 @@ describe('fetch', () => {
expect(res.body.error.message).toContain("Did you mean 'health'?")
})
+ test('RPC route maps protocol failures to HTTP statuses', async () => {
+ const cli = Cli.create('app').command(
+ Cli.create('group').command('leaf', {
+ run() {
+ return null
+ },
+ }),
+ )
+ cli.command('raw', { fetch: () => new Response('{}') })
+
+ expect(
+ await fetchJson(
+ cli,
+ new Request('http://localhost/_incur/rpc', {
+ method: 'POST',
+ body: JSON.stringify({ command: '' }),
+ }),
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "INVALID_RPC_REQUEST",
+ "message": "RPC command is required.",
+ },
+ "meta": {
+ "command": "",
+ "duration": "",
+ },
+ "ok": false,
+ },
+ "status": 400,
+ }
+ `)
+
+ expect(
+ await fetchJson(
+ cli,
+ new Request('http://localhost/_incur/rpc', {
+ method: 'POST',
+ body: JSON.stringify({ command: 'group' }),
+ }),
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "COMMAND_GROUP",
+ "message": "'group' is a command group. Specify a subcommand.",
+ },
+ "meta": {
+ "command": "group",
+ "duration": "",
+ },
+ "ok": false,
+ },
+ "status": 400,
+ }
+ `)
+
+ expect(
+ await fetchJson(
+ cli,
+ new Request('http://localhost/_incur/rpc', {
+ method: 'POST',
+ body: JSON.stringify({ command: 'raw' }),
+ }),
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "FETCH_GATEWAY",
+ "message": "'raw' is a raw fetch gateway and cannot be called with structured RPC.",
+ },
+ "meta": {
+ "command": "raw",
+ "duration": "",
+ },
+ "ok": false,
+ },
+ "status": 400,
+ }
+ `)
+
+ expect(
+ await fetchJson(
+ cli,
+ new Request('http://localhost/_incur/rpc', {
+ method: 'POST',
+ body: JSON.stringify({ command: 'missing' }),
+ }),
+ ),
+ ).toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "COMMAND_NOT_FOUND",
+ "message": "'missing' is not a command for 'app'.",
+ },
+ "meta": {
+ "command": "missing",
+ "duration": "",
+ },
+ "ok": false,
+ },
+ "status": 404,
+ }
+ `)
+ })
+
+ test('discovery routes map failures to envelopes', async () => {
+ const cli = Cli.create('app').command('status', {
+ run() {
+ return { ok: true }
+ },
+ })
+
+ expect(await fetchJson(cli, new Request('http://localhost/_incur/help?command=missing')))
+ .toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "COMMAND_NOT_FOUND",
+ "message": "Unknown command 'missing'.",
+ },
+ "meta": {
+ "duration": "",
+ "resource": "help",
+ },
+ "ok": false,
+ },
+ "status": 404,
+ }
+ `)
+
+ expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=../x')))
+ .toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "INVALID_SKILL_NAME",
+ "message": "Unsafe skill name.",
+ },
+ "meta": {
+ "duration": "",
+ "resource": "skill",
+ },
+ "ok": false,
+ },
+ "status": 400,
+ }
+ `)
+
+ expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=missing')))
+ .toMatchInlineSnapshot(`
+ {
+ "body": {
+ "error": {
+ "code": "SKILL_NOT_FOUND",
+ "message": "Unknown skill 'missing'.",
+ },
+ "meta": {
+ "duration": "",
+ "resource": "skill",
+ },
+ "ok": false,
+ },
+ "status": 404,
+ }
+ `)
+ })
+
test('GET / with root command → 200', async () => {
const cli = Cli.create('test', { run: () => ({ root: true }) })
expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(`
diff --git a/src/Cli.ts b/src/Cli.ts
index 45976f3c..d38ec03e 100644
--- a/src/Cli.ts
+++ b/src/Cli.ts
@@ -22,9 +22,12 @@ import {
} from './internal/command.js'
import * as Command from './internal/command.js'
import { formatCtaBlock, type FormattedCta, type FormattedCtaBlock } from './internal/cta.js'
+import { createResourcesHandler, ResourcesError } from './internal/handlers/resources.js'
+import { createRpcHandler, getRpcStatus } from './internal/handlers/rpc.js'
import { isRecord, suggest, toKebab } from './internal/helpers.js'
import * as Json from './internal/json.js'
import { detectRunner } from './internal/pm.js'
+import * as RuntimeContext from './internal/runtime-context.js'
import type { OneOf } from './internal/types.js'
import * as Yaml from './internal/yaml.js'
import * as Mcp from './Mcp.js'
@@ -88,17 +91,17 @@ export type Cli<
globals
>
/** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */
- (
+ (
name: name,
definition: {
basePath?: string | undefined
description?: string | undefined
fetch: FetchSource
- openapi?: Openapi.OpenAPISource | undefined
+ openapi?: spec | undefined
openapiConfig?: Openapi.Config | undefined
outputPolicy?: OutputPolicy | undefined
},
- ): Cli
+ ): Cli, vars, env, globals>
}
/** A short description of the CLI. */
description?: string | undefined
@@ -250,15 +253,22 @@ export function create(
})
if (def.openapi && rootFetch) {
- pending.push(
- (async () => {
- const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl })
- const generated = await Openapi.generateCommands(spec, rootFetch, {
- config: def.openapiConfig,
- })
- for (const [name, command] of generated) commands.set(name, command)
- })(),
- )
+ if (isResolvedOpenapi(def.openapi)) {
+ const generated = Openapi.generateCommandsSync(def.openapi, rootFetch, {
+ config: def.openapiConfig,
+ })
+ for (const [name, command] of generated) commands.set(name, command)
+ } else {
+ pending.push(
+ (async () => {
+ const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl })
+ const generated = await Openapi.generateCommands(spec, rootFetch, {
+ config: def.openapiConfig,
+ })
+ for (const [name, command] of generated) commands.set(name, command)
+ })(),
+ )
+ }
}
const cli: Cli = {
@@ -273,25 +283,45 @@ export function create(
const fetch = resolveFetch(def.fetch)
// OpenAPI + fetch → generate typed command group (async, resolved before serve)
if (def.openapi) {
- pending.push(
- (async () => {
- const spec = await Openapi.resolve(def.openapi, {
- baseUrl: fetchBaseUrl(def.fetch),
- })
- const generated = await Openapi.generateCommands(spec, fetch, {
+ const setOpenapiGroup = (generated: Map) => {
+ commands.set(nameOrCli, {
+ _group: true,
+ description: def.description,
+ commands: generated as Map,
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
+ } as InternalGroup)
+ }
+ if (isResolvedOpenapi(def.openapi)) {
+ setOpenapiGroup(
+ Openapi.generateCommandsSync(def.openapi, fetch, {
basePath: def.basePath,
config: def.openapiConfig,
- })
- const entry = {
- _group: true,
- description: def.description,
- commands: generated as Map,
- ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
- } as InternalGroup
- assertNoGlobalOptionConflicts(nameOrCli, entry, toGlobals.get(cli))
- commands.set(nameOrCli, entry)
- })(),
- )
+ }),
+ )
+ assertNoGlobalOptionConflicts(
+ nameOrCli,
+ commands.get(nameOrCli)!,
+ toGlobals.get(cli),
+ )
+ } else
+ pending.push(
+ (async () => {
+ const spec = await Openapi.resolve(def.openapi, {
+ baseUrl: fetchBaseUrl(def.fetch),
+ })
+ setOpenapiGroup(
+ await Openapi.generateCommands(spec, fetch, {
+ basePath: def.basePath,
+ config: def.openapiConfig,
+ }),
+ )
+ assertNoGlobalOptionConflicts(
+ nameOrCli,
+ commands.get(nameOrCli)!,
+ toGlobals.get(cli),
+ )
+ })(),
+ )
return cli
}
commands.set(nameOrCli, {
@@ -383,7 +413,10 @@ export function create(
if (rootDef && def.aliases) toRootAliases.set(cli as unknown as Root, def.aliases)
if (def.options) toRootOptions.set(cli, def.options)
if (def.config !== undefined) toConfigEnabled.set(cli, true)
+ if (def.mcp) toMcpOptions.set(cli, def.mcp)
if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy)
+ if (def.sync) toSyncOptions.set(cli, def.sync)
+ if (def.version !== undefined) toVersion.set(cli, def.version)
if (def.globals) {
toGlobals.set(cli, { schema: def.globals, alias: def.globalAlias as any })
const builtinNames = [
@@ -634,7 +667,7 @@ async function serveImpl(
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (human) writeln(formatHumanError({ code: 'UNKNOWN', message }))
- else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon'))
+ else writeln(Formatter.format({ code: 'UNKNOWN', message }, Formatter.defaultFormat))
exit(1)
return
}
@@ -886,7 +919,10 @@ async function serveImpl(
if (human) {
writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
writeln(formatHumanCta(cta))
- } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
+ } else
+ writeln(
+ Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat),
+ )
exit(1)
return
}
@@ -933,7 +969,7 @@ async function serveImpl(
code: 'LIST_SKILLS_FAILED',
message: err instanceof Error ? err.message : String(err),
},
- formatExplicit ? formatFlag : 'toon',
+ formatExplicit ? formatFlag : Formatter.defaultFormat,
),
)
exit(1)
@@ -989,13 +1025,13 @@ async function serveImpl(
if (fullOutput || formatExplicit) {
const output: Record = { skills: result.paths }
if (fullOutput && result.agents.length > 0) output.agents = result.agents
- writeln(Formatter.format(output, formatExplicit ? formatFlag : 'toon'))
+ writeln(Formatter.format(output, formatExplicit ? formatFlag : Formatter.defaultFormat))
}
} catch (err) {
writeln(
Formatter.format(
{ code: 'SYNC_SKILLS_FAILED', message: err instanceof Error ? err.message : String(err) },
- formatExplicit ? formatFlag : 'toon',
+ formatExplicit ? formatFlag : Formatter.defaultFormat,
),
)
exit(1)
@@ -1028,7 +1064,10 @@ async function serveImpl(
if (human) {
writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message }))
writeln(formatHumanCta(cta))
- } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon'))
+ } else
+ writeln(
+ Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat),
+ )
exit(1)
return
}
@@ -1081,14 +1120,14 @@ async function serveImpl(
writeln(
Formatter.format(
{ name, command: result.command, agents: result.agents },
- formatExplicit ? formatFlag : 'toon',
+ formatExplicit ? formatFlag : Formatter.defaultFormat,
),
)
} catch (err) {
writeln(
Formatter.format(
{ code: 'MCP_ADD_FAILED', message: err instanceof Error ? err.message : String(err) },
- formatExplicit ? formatFlag : 'toon',
+ formatExplicit ? formatFlag : Formatter.defaultFormat,
),
)
exit(1)
@@ -1286,13 +1325,8 @@ async function serveImpl(
exit(1)
return
}
- const cmd = resolved.command
- const format = formatExplicit ? formatFlag : 'toon'
- const result: Record = {}
- if (cmd.args) result.args = Schema.toJsonSchema(cmd.args)
- if (cmd.env) result.env = Schema.toJsonSchema(cmd.env)
- if (cmd.options) result.options = Schema.toJsonSchema(cmd.options)
- if (cmd.output) result.output = Schema.toJsonSchema(cmd.output)
+ const format = formatExplicit ? formatFlag : Formatter.defaultFormat
+ const result: Record = buildCommandSchema(resolved.command) ?? {}
if (options.globals?.schema) result.globals = Schema.toJsonSchema(options.globals.schema)
writeln(Formatter.format(result, format))
return
@@ -1312,9 +1346,11 @@ async function serveImpl(
const start = performance.now()
- // Resolve effective format: explicit --format/--json → command default → CLI default → toon
+ // Resolve effective format: explicit --format/--json → command default → CLI default → Formatter.defaultFormat
const resolvedFormat = 'command' in resolved && (resolved as any).command.format
- const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon'
+ const format = formatExplicit
+ ? formatFlag
+ : resolvedFormat || options.format || Formatter.defaultFormat
if (format === 'yaml') await Yaml.load()
// Fall back to root fetch/command when no subcommand matches,
@@ -1893,6 +1929,112 @@ async function fetchImpl(
const url = new URL(req.url)
const segments = url.pathname.split('/').filter(Boolean)
+ if (segments[0] === '_incur') {
+ const ctx: RuntimeContext.RuntimeCliContext = {
+ commands,
+ ...(options.description ? { description: options.description } : undefined),
+ ...(options.envSchema ? { env: options.envSchema } : undefined),
+ middlewares: options.middlewares ?? [],
+ name,
+ ...(options.rootCommand ? { rootCommand: options.rootCommand as any } : undefined),
+ ...(options.vars ? { vars: options.vars } : undefined),
+ ...(options.version ? { version: options.version } : undefined),
+ }
+
+ if (segments[1] === 'rpc' && segments.length === 2 && req.method === 'POST') {
+ const client = createRpcHandler(ctx)
+ let body: unknown
+ try {
+ body = await req.json()
+ } catch {
+ const response = await client.request({})
+ return new Response(JSON.stringify(response), {
+ status: 400,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+ const response = await client.request(body)
+ if ('stream' in response) {
+ const records = response.records()
+ const encoder = new TextEncoder()
+ const stream = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const record of records)
+ controller.enqueue(encoder.encode(`${JSON.stringify(record)}\n`))
+ } finally {
+ controller.close()
+ }
+ },
+ async cancel() {
+ await records.return(undefined as any)
+ },
+ })
+ return new Response(stream, {
+ status: 200,
+ headers: { 'content-type': 'application/x-ndjson' },
+ })
+ }
+ return new Response(JSON.stringify(response), {
+ status: response.ok ? 200 : getRpcStatus(response.error.code),
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+
+ if (req.method === 'GET') {
+ const resource = (() => {
+ if (segments[1] === 'llms') return 'llms'
+ if (segments[1] === 'llms-full') return 'llmsFull'
+ if (segments[1] === 'schema') return 'schema'
+ if (segments[1] === 'help') return 'help'
+ if (segments[1] === 'openapi') return 'openapi'
+ if (segments[1] === 'skills') return 'skillsIndex'
+ if (segments[1] === 'skill') return 'skill'
+ if (segments[1] === 'mcp' && segments[2] === 'tools') return 'mcpTools'
+ return undefined
+ })()
+ if (resource) {
+ try {
+ const client = createResourcesHandler(ctx)
+ const discovery = await client.discover({
+ resource,
+ ...(url.searchParams.get('command')
+ ? { command: url.searchParams.get('command')! }
+ : undefined),
+ ...(url.searchParams.get('format')
+ ? { format: url.searchParams.get('format')! }
+ : undefined),
+ ...(url.searchParams.get('name') ? { name: url.searchParams.get('name')! } : undefined),
+ })
+ return new Response(
+ 'body' in discovery ? discovery.body : JSON.stringify(discovery.data),
+ {
+ status: 200,
+ headers: { 'content-type': discovery.contentType },
+ },
+ )
+ } catch (error) {
+ const status = error instanceof ResourcesError ? error.status : 500
+ const code = error instanceof ResourcesError ? error.code : 'DISCOVERY_ERROR'
+ return new Response(
+ JSON.stringify({
+ ok: false,
+ error: {
+ code,
+ message: error instanceof Error ? error.message : String(error),
+ },
+ meta: {
+ resource,
+ duration: `${Math.round(performance.now() - start)}ms`,
+ },
+ }),
+ { status, headers: { 'content-type': 'application/json' } },
+ )
+ }
+ }
+ }
+ }
+
// OpenAPI discovery: route /openapi.json, /openapi.yml, /openapi.yaml, and /.well-known/openapi.json
if (req.method === 'GET' && isOpenapiRoute(segments)) {
const spec = generatedOpenapi(name, commands, options)
@@ -1930,8 +2072,7 @@ async function fetchImpl(
if (segments[2] === 'index.json' && segments.length === 3) {
const files = Skill.split(name, cmds, 1, groups)
const skills = files.map((f) => {
- const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/)
- const meta = fmMatch ? (Yaml.loadSync().parse(fmMatch[1]!) as Record) : {}
+ const meta = parseSkillFrontmatter(f.content)
return {
name: f.dir || name,
description: meta.description ?? '',
@@ -2447,7 +2588,7 @@ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Option
let help = false
let version = false
let schema = false
- let format: Formatter.Format = 'toon'
+ let format: Formatter.Format = Formatter.defaultFormat
let formatExplicit = false
let configPath: string | undefined
let configDisabled = false
@@ -2740,7 +2881,6 @@ async function runMcpDoctor(
const input = new PassThrough()
const output = new PassThrough()
const chunks: string[] = []
- output.on('data', (chunk) => chunks.push(chunk.toString()))
let serveError: unknown
const done = Mcp.serve(name, options.version ?? '0.0.0', commands, {
@@ -2768,7 +2908,28 @@ async function runMcpDoctor(
})}\n`,
)
input.write(`${Json.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`)
- await new Promise((resolve) => setTimeout(resolve, 20))
+ // Wait for both responses instead of a fixed 20ms delay, which raced the
+ // in-process server under CI load and dropped the tools/list response. The
+ // stream-end branch terminates the mocked negative cases; the serve-failure
+ // branch terminates when the server itself rejects before responding.
+ await new Promise((resolve) => {
+ let seen = 0
+ output.on('data', (chunk) => {
+ chunks.push(chunk.toString())
+ try {
+ const { id } = JSON.parse(chunk.toString()) as { id?: number }
+ if (id === 1) seen |= 1
+ if (id === 2) seen |= 2
+ if (seen === 3) resolve()
+ } catch {
+ // Malformed entries are validated when chunks are parsed below.
+ }
+ })
+ output.once('end', resolve)
+ done.then(() => {
+ if (serveError !== undefined) resolve()
+ })
+ })
input.end()
await done
@@ -2878,8 +3039,8 @@ export type CommandsMap = Record<
>
/** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */
-type CommandEntry =
- | CommandDefinition
+export type CommandEntry =
+ | CommandDefinition
| InternalGroup
| InternalFetchGateway
| InternalAlias
@@ -2894,7 +3055,7 @@ export type FetchHandler = Fetch.Handler
export type FetchSource = Fetch.Source
/** @internal A command group's internal storage. */
-type InternalGroup = {
+export type InternalGroup = {
_group: true
description?: string | undefined
middlewares?: MiddlewareHandler[] | undefined
@@ -2903,7 +3064,7 @@ type InternalGroup = {
}
/** @internal A fetch gateway entry. */
-type InternalFetchGateway = {
+export type InternalFetchGateway = {
_fetch: true
basePath?: string | undefined
description?: string | undefined
@@ -2928,30 +3089,34 @@ function fetchBaseUrl(source: FetchSource) {
return typeof source === 'function' ? undefined : source.url
}
+function isResolvedOpenapi(source: Openapi.OpenAPISource): source is Openapi.OpenAPISpec {
+ return typeof source !== 'string' && !(source instanceof URL)
+}
+
/** @internal Type guard for command groups. */
-function isGroup(entry: CommandEntry): entry is InternalGroup {
+export function isGroup(entry: CommandEntry): entry is InternalGroup {
return '_group' in entry
}
/** @internal Type guard for fetch gateways. */
-function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
+export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
return '_fetch' in entry
}
/** @internal An alias entry that points to another command by name. */
-type InternalAlias = {
+export type InternalAlias = {
_alias: true
/** The canonical command name this alias resolves to. */
target: string
}
/** @internal Type guard for alias entries. */
-function isAlias(entry: CommandEntry): entry is InternalAlias {
+export function isAlias(entry: CommandEntry): entry is InternalAlias {
return '_alias' in entry
}
/** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */
-function resolveAlias(
+export function resolveAlias(
commands: Map,
entry: CommandEntry,
): Exclude {
@@ -2998,7 +3163,7 @@ function assertNoGlobalOptionConflicts(
export const toCommands = new WeakMap>()
/** @internal Maps CLI instances to their middleware arrays. */
-const toMiddlewares = new WeakMap()
+export const toMiddlewares = new WeakMap()
/** @internal Maps root CLI instances to their command definitions. */
export const toRootDefinition = new WeakMap>()
@@ -3012,6 +3177,26 @@ export const toConfigEnabled = new WeakMap()
/** @internal Maps CLI instances to their output policy. */
const toOutputPolicy = new WeakMap()
+/** @internal Maps CLI instances to MCP setup options. */
+export const toMcpOptions = new WeakMap<
+ Cli,
+ { agents?: string[] | undefined; command?: string | undefined; stateless?: boolean | undefined }
+>()
+
+/** @internal Maps CLI instances to skill sync options. */
+export const toSyncOptions = new WeakMap<
+ Cli,
+ {
+ cwd?: string | undefined
+ depth?: number | undefined
+ include?: string[] | undefined
+ suggestions?: string[] | undefined
+ }
+>()
+
+/** @internal Maps CLI instances to their version strings. */
+export const toVersion = new WeakMap()
+
/** Descriptor for a CLI's custom global options schema and aliases. */
export type GlobalsDescriptor = {
schema: z.ZodObject
@@ -3108,7 +3293,7 @@ async function handleStreaming(
// Incremental: no explicit format (default toon), or explicit jsonl
// Buffered: explicit json/yaml/toon/md
const useJsonl = ctx.format === 'jsonl'
- const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === 'toon')
+ const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === Formatter.defaultFormat)
if (incremental) {
// Incremental output: write each chunk as it arrives
@@ -3291,7 +3476,7 @@ async function handleStreaming(
}
/** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */
-function buildIndexManifest(
+export function buildIndexManifest(
commands: Map,
prefix: string[] = [],
globalsSchema?: z.ZodObject,
@@ -3326,7 +3511,7 @@ function collectIndexCommands(
}
/** @internal Builds the `--llms` manifest from the command tree. */
-function buildManifest(
+export function buildManifest(
commands: Map,
prefix: string[] = [],
globalsSchema?: z.ZodObject,
@@ -3362,14 +3547,13 @@ function collectCommands(
const cmd: (typeof result)[number] = { name: path.join(' ') }
if (entry.description) cmd.description = entry.description
- const inputSchema = buildInputSchema(entry.args, entry.env, entry.options)
- const outputSchema = entry.output ? Schema.toJsonSchema(entry.output) : undefined
- if (inputSchema || outputSchema) {
+ const schema = buildCommandSchema(entry)
+ if (schema) {
cmd.schema = {}
- if (inputSchema?.args) cmd.schema.args = inputSchema.args
- if (inputSchema?.env) cmd.schema.env = inputSchema.env
- if (inputSchema?.options) cmd.schema.options = inputSchema.options
- if (outputSchema) cmd.schema.output = outputSchema
+ if (schema.args) cmd.schema.args = schema.args
+ if (schema.env) cmd.schema.env = schema.env
+ if (schema.options) cmd.schema.options = schema.options
+ if (schema.output) cmd.schema.output = schema.output
}
const examples = formatExamples(entry.examples)
@@ -3463,7 +3647,6 @@ function appendDestructiveHint(hint: string | undefined): string {
if (hint.includes(destructiveCommandHint)) return hint
return `${hint} ${destructiveCommandHint}`
}
-
/** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */
export function formatExamples(
examples: Example[] | undefined,
@@ -3492,32 +3675,37 @@ export function parseSkillFrontmatter(content: string): {
return meta as { description?: string | undefined; name?: string | undefined }
}
-/** @internal Builds separate args, env, and options JSON Schemas. */
-function buildInputSchema(
- args: z.ZodObject | undefined,
- env: z.ZodObject | undefined,
- options: z.ZodObject | undefined,
+/** @internal Builds separate command JSON Schemas. */
+export function buildCommandSchema(
+ command: Pick<
+ CommandDefinition,
+ 'args' | 'env' | 'options' | 'output'
+ >,
):
| {
args?: Record | undefined
env?: Record | undefined
options?: Record | undefined
+ output?: Record | undefined
}
| undefined {
- if (!args && !env && !options) return undefined
+ const { args, env, options, output } = command
+ if (!args && !env && !options && !output) return undefined
const result: {
args?: Record | undefined
env?: Record | undefined
options?: Record | undefined
+ output?: Record | undefined
} = {}
if (args) result.args = Schema.toJsonSchema(args)
if (env) result.env = Schema.toJsonSchema(env)
if (options) result.options = Schema.toJsonSchema(options)
+ if (output) result.output = Schema.toJsonSchema(output)
return result
}
/** @internal A usage example for a command, typed against its args and options schemas. */
-type Example<
+export type Example<
args extends z.ZodObject | undefined,
options extends z.ZodObject | undefined,
> = {
@@ -3530,7 +3718,7 @@ type Example<
}
/** @internal A usage pattern shown in help output. */
-type Usage<
+export type Usage<
args extends z.ZodObject | undefined,
options extends z.ZodObject | undefined,
> = {
@@ -3606,7 +3794,7 @@ declare namespace Output {
}
/** @internal Defines a command's schema, handler, and metadata. */
-type CommandDefinition<
+export type CommandDefinition<
args extends z.ZodObject | undefined = undefined,
env extends z.ZodObject | undefined = undefined,
options extends z.ZodObject | undefined = undefined,
diff --git a/src/Formatter.ts b/src/Formatter.ts
index b0abe45c..4253e065 100644
--- a/src/Formatter.ts
+++ b/src/Formatter.ts
@@ -6,8 +6,11 @@ import * as Yaml from './internal/yaml.js'
/** Supported output formats. */
export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl'
+/** Default rendered output format. */
+export const defaultFormat = 'toon' satisfies Format
+
/** Serializes a value to the specified format. Defaults to TOON. */
-export function format(value: unknown, fmt: Format = 'toon'): string {
+export function format(value: unknown, fmt: Format = defaultFormat): string {
if (value == null) return ''
if (fmt === 'json') {
if (typeof value === 'string') {
diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts
index 50ad108e..b6d9bc8e 100644
--- a/src/Openapi.test.ts
+++ b/src/Openapi.test.ts
@@ -162,6 +162,36 @@ describe('generateCommands', () => {
expect(limitSchema.description).toBe('Max results')
})
+ test('infers output from JSON response schemas', async () => {
+ const commands = await Openapi.generateCommands(
+ {
+ paths: {
+ '/users/posts': {
+ get: {
+ operationId: 'listPosts',
+ responses: {
+ '200': {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: { ok: { type: 'boolean' } },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ () => new Response(JSON.stringify({ ok: true })),
+ )
+ const command = commands.get('listPosts')!
+ if ('_group' in command) throw new Error('expected listPosts command')
+ expect(command.output).toBeDefined()
+ })
+
test('generates namespace command groups from paths', async () => {
const commands = await Openapi.generateCommands(spec, app.fetch, {
config: { mode: 'namespace' },
diff --git a/src/Openapi.ts b/src/Openapi.ts
index a2d73d64..08d8bf98 100644
--- a/src/Openapi.ts
+++ b/src/Openapi.ts
@@ -36,6 +36,44 @@ export type Config = {
mode?: Mode | undefined
}
+/** Inferred command map for operation commands generated from a literal OpenAPI spec. */
+export type Commands<
+ name extends string,
+ spec extends OpenAPISource | undefined,
+> = spec extends OpenAPISpec
+ ? {
+ [path in keyof NonNullable & string as OperationCommandName<
+ name,
+ NonNullable[path]
+ >]: {
+ args: Record
+ options: Record
+ output: unknown
+ }
+ }
+ : {}
+
+type OperationCommandName = item extends object
+ ? {
+ [method in keyof item & string]: method extends OperationMethod
+ ? item[method] extends { operationId: infer id extends string }
+ ? `${name} ${id}`
+ : `${name} ${method} ${string}`
+ : never
+ }[keyof item & string]
+ : never
+
+type OperationMethod =
+ | 'delete'
+ | 'get'
+ | 'head'
+ | 'options'
+ | 'patch'
+ | 'post'
+ | 'put'
+ | 'query'
+ | 'trace'
+
/** Options for generating an OpenAPI document from an incur CLI. */
export type GenerateOptions = {
/** API description. Defaults to the CLI description. */
@@ -121,6 +159,7 @@ type GeneratedCommand = {
args?: z.ZodObject | undefined
description?: string | undefined
options?: z.ZodObject | undefined
+ output?: z.ZodType | undefined
run: (context: any) => any
}
@@ -362,6 +401,15 @@ export async function generateCommands(
fetch: FetchHandler,
options: generateCommands.Options = {},
): Promise> {
+ return generateCommandsSync(spec, fetch, options)
+}
+
+/** Synchronously generates incur command entries from an already-loaded OpenAPI spec. */
+export function generateCommandsSync(
+ spec: OpenAPISpec,
+ fetch: FetchHandler,
+ options: generateCommands.Options = {},
+): Map {
const resolved = dereference(structuredClone(spec)) as OpenAPISpec
const commands = new Map()
const paths = (resolved.paths ?? {}) as Record>
@@ -389,6 +437,7 @@ export async function generateCommands(
const bodySchema = op.requestBody?.content?.['application/json']?.schema
const bodyProps = (bodySchema?.properties ?? {}) as Record>
const bodyRequired = new Set((bodySchema?.required as string[]) ?? [])
+ const outputSchema = responseSchema(op.responses)
// Build args Zod schema from path params
let argsSchema: z.ZodObject | undefined
@@ -434,6 +483,7 @@ export async function generateCommands(
description: op.summary ?? op.description,
args: argsSchema,
options: optionsSchema,
+ ...(outputSchema ? { output: toZod(outputSchema) } : undefined),
run: createHandler({
basePath: options.basePath,
fetch,
@@ -778,3 +828,15 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType {
const desc = (schema as any).description ?? (inner as any).description
return desc ? coerced.describe(desc) : coerced
}
+
+function responseSchema(responses: Record | undefined) {
+ if (!responses) return undefined
+ const entries = Object.entries(responses)
+ const preferred =
+ entries.find(([status]) => status === '200') ??
+ entries.find(([status]) => /^2\d\d$/.test(status))
+ const response = preferred?.[1] as
+ | { content?: Record | undefined }> | undefined }
+ | undefined
+ return response?.content?.['application/json']?.schema
+}
diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts
index e6402c0a..e34640c9 100644
--- a/src/Typegen.test.ts
+++ b/src/Typegen.test.ts
@@ -13,12 +13,20 @@ describe('fromCli', () => {
})
expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(`
- "declare module 'incur' {
+ "export type Commands = {
+ get: { args: { id: number }; options: {} }
+ list: { args: {}; options: { limit: number } }
+ }
+
+ declare module 'incur' {
+ interface Register {
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
interface Register {
- commands: {
- 'get': { args: { id: number }; options: {} }
- 'list': { args: {}; options: { limit: number } }
- }
+ commands: Commands
}
}
"
@@ -29,11 +37,19 @@ describe('fromCli', () => {
const cli = Cli.create('test').command('ping', { run: () => ({}) })
expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(`
- "declare module 'incur' {
+ "export type Commands = {
+ ping: { args: {}; options: {} }
+ }
+
+ declare module 'incur' {
interface Register {
- commands: {
- 'ping': { args: {}; options: {} }
- }
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
+ interface Register {
+ commands: Commands
}
}
"
@@ -54,12 +70,20 @@ describe('fromCli', () => {
cli.command(pr)
expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(`
- "declare module 'incur' {
+ "export type Commands = {
+ "pr create": { args: { title: string }; options: {} }
+ "pr list": { args: {}; options: { state: string } }
+ }
+
+ declare module 'incur' {
interface Register {
- commands: {
- 'pr create': { args: { title: string }; options: {} }
- 'pr list': { args: {}; options: { state: string } }
- }
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
+ interface Register {
+ commands: Commands
}
}
"
@@ -77,11 +101,19 @@ describe('fromCli', () => {
cli.command(pr)
expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(`
- "declare module 'incur' {
+ "export type Commands = {
+ "pr review approve": { args: { id: number }; options: {} }
+ }
+
+ declare module 'incur' {
+ interface Register {
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
interface Register {
- commands: {
- 'pr review approve': { args: { id: number }; options: {} }
- }
+ commands: Commands
}
}
"
@@ -118,6 +150,38 @@ describe('fromCli', () => {
expect(output).toContain('tags: string[]')
})
+ test('emits scalar and array output schemas', () => {
+ const cli = Cli.create('test')
+ .command('read', {
+ output: z.string(),
+ run: () => 'content',
+ })
+ .command('list', {
+ output: z.array(z.object({ id: z.string(), active: z.boolean() })),
+ run: () => [{ id: 'one', active: true }],
+ })
+
+ const output = Typegen.fromCli(cli)
+ expect(output).toContain('read: { args: {}; options: {}; output: string }')
+ expect(output).toContain(
+ 'list: { args: {}; options: {}; output: { id: string; active: boolean }[] }',
+ )
+ })
+
+ test('marks async generator commands as streams', () => {
+ const cli = Cli.create('test').command('tail', {
+ output: z.object({ line: z.string() }),
+ async *run() {
+ yield { line: 'ok' }
+ },
+ })
+
+ const output = Typegen.fromCli(cli)
+ expect(output).toContain(
+ 'tail: { args: {}; options: {}; output: { line: string }; stream: true }',
+ )
+ })
+
test('commands are sorted alphabetically', () => {
const cli = Cli.create('test')
.command('zebra', { run: () => ({}) })
@@ -125,7 +189,7 @@ describe('fromCli', () => {
.command('middle', { run: () => ({}) })
const output = Typegen.fromCli(cli)
- const commandOrder = [...output.matchAll(/^ {6}'(\w+)':/gm)].map((m) => m[1])
+ const commandOrder = [...output.matchAll(/^ {2}(\w+):/gm)].map((m) => m[1])
expect(commandOrder).toEqual(['alpha', 'middle', 'zebra'])
})
@@ -169,7 +233,7 @@ describe('fromCli', () => {
expect(output).toContain('config: { host: string; port: number }')
})
- test('optional properties use optional modifier', () => {
+ test('optional properties include undefined for exact optional property types', () => {
const cli = Cli.create('test').command('create', {
args: z.object({ name: z.string() }),
options: z.object({
@@ -180,7 +244,7 @@ describe('fromCli', () => {
})
const output = Typegen.fromCli(cli)
- expect(output).toContain('verbose?: boolean')
+ expect(output).toContain('verbose?: boolean | undefined')
expect(output).toContain('output: string')
})
@@ -191,15 +255,53 @@ describe('fromCli', () => {
cli.command(pr)
expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(`
- "declare module 'incur' {
+ "export type Commands = {
+ ping: { args: {}; options: {} }
+ "pr list": { args: {}; options: {} }
+ }
+
+ declare module 'incur' {
+ interface Register {
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
interface Register {
- commands: {
- 'ping': { args: {}; options: {} }
- 'pr list': { args: {}; options: {} }
- }
+ commands: Commands
}
}
"
`)
})
+
+ test('includes root commands and excludes raw fetch gateways', () => {
+ const cli = Cli.create('status', {
+ run: () => ({ ok: true }),
+ }).command('raw', {
+ fetch: () => new Response('{}'),
+ })
+
+ const output = Typegen.fromCli(cli)
+ expect(output).toContain('status: { args: {}; options: {} }')
+ expect(output).not.toContain("'raw'")
+ expect(output).toContain("declare module 'incur/client'")
+ })
+
+ test('escapes command and property keys', () => {
+ const cli = Cli.create('test').command('bad key "quoted"', {
+ options: z.object({
+ 'bad-key': z.string().optional(),
+ 'quote"key': z.number(),
+ nested: z.object({ 'child-key': z.string().optional() }),
+ }),
+ run: () => ({}),
+ })
+
+ const output = Typegen.fromCli(cli)
+ expect(output).toContain('"bad key \\"quoted\\""')
+ expect(output).toContain('"bad-key"?: string | undefined')
+ expect(output).toContain('"quote\\"key": number')
+ expect(output).toContain('nested: { "child-key"?: string | undefined }')
+ })
})
diff --git a/src/Typegen.ts b/src/Typegen.ts
index 2bed6a8f..0903fe63 100644
--- a/src/Typegen.ts
+++ b/src/Typegen.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
import { z } from 'zod'
import * as Cli from './Cli.js'
+import * as RuntimeContext from './internal/runtime-context.js'
import { importCli } from './internal/utils.js'
/** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */
@@ -12,48 +13,45 @@ export async function generate(input: string, output: string): Promise {
/** Generates a `.d.ts` declaration string for the `incur` module augmentation. */
export function fromCli(cli: Cli.Cli): string {
- const commands = Cli.toCommands.get(cli)
- if (!commands) throw new Error('No commands registered on this CLI instance')
+ const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli))
- const entries = collectEntries(commands, [])
+ const lines: string[] = ['export type Commands = {']
- const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {']
-
- for (const { name, args, options } of entries)
+ for (const { id, command } of entries)
lines.push(
- ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`,
+ ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`,
)
- lines.push(' }', ' }', '}', '')
+ lines.push(
+ '}',
+ '',
+ "declare module 'incur' {",
+ ' interface Register {',
+ ' commands: Commands',
+ ' }',
+ '}',
+ '',
+ "declare module 'incur/client' {",
+ ' interface Register {',
+ ' commands: Commands',
+ ' }',
+ '}',
+ '',
+ )
return lines.join('\n')
}
-/** Recursively collects leaf commands with their full paths and schemas. */
-function collectEntries(
- commands: Map,
- prefix: string[],
-): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] {
- const result: ReturnType = []
- for (const [name, entry] of commands) {
- const path = [...prefix, name]
- if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path))
- else result.push({ name: path.join(' '), args: entry.args, options: entry.options })
- }
- return result.sort((a, b) => a.name.localeCompare(b.name))
-}
-
/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */
-function schemaToType(schema: z.ZodObject | undefined): string {
+function objectSchemaToType(schema: z.ZodObject | undefined): string {
if (!schema) return '{}'
+ return schemaToType(schema)
+}
+
+/** Converts a Zod schema to a TypeScript type string. */
+function schemaToType(schema: z.ZodType): string {
const json = z.toJSONSchema(schema) as Record
const defs = (json.$defs ?? {}) as Record>
- const properties = json.properties as Record> | undefined
- if (!properties || Object.keys(properties).length === 0) return '{}'
- const required = new Set((json.required as string[] | undefined) ?? [])
- const entries = Object.entries(properties).map(
- ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`,
- )
- return `{ ${entries.join('; ')} }`
+ return resolveType(json, defs)
}
/** Recursively resolves a JSON Schema node to a TypeScript type string. */
@@ -98,12 +96,22 @@ function resolveType(
const properties = schema.properties as Record> | undefined
if (!properties || Object.keys(properties).length === 0) return '{}'
const required = new Set((schema.required as string[] | undefined) ?? [])
- const entries = Object.entries(properties).map(
- ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`,
- )
+ const entries = Object.entries(properties).map(([key, value]) => {
+ const type = resolveType(value, defs)
+ if (required.has(key)) return `${propertyKey(key)}: ${type}`
+ return `${propertyKey(key)}?: ${type} | undefined`
+ })
return `{ ${entries.join('; ')} }`
}
default:
return 'unknown'
}
}
+
+function propertyKey(key: string) {
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)
+}
+
+function isStream(command: Cli.CommandDefinition) {
+ return command.run.constructor.name === 'AsyncGeneratorFunction'
+}
diff --git a/src/bin.ts b/src/bin.ts
index f53b6e79..63c88357 100755
--- a/src/bin.ts
+++ b/src/bin.ts
@@ -12,7 +12,7 @@ const cli = Cli.create('incur', {
description: 'CLI for incur',
sync: {
depth: 1,
- include: ['_root'],
+ include: ['_root', 'skills/*'],
suggestions: ['build a cli with incur', 'generate incur types'],
},
}).command('gen', {
diff --git a/src/client/Client.test.ts b/src/client/Client.test.ts
new file mode 100644
index 00000000..aa7fd5a6
--- /dev/null
+++ b/src/client/Client.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, test, vi } from 'vitest'
+
+import * as Cli from '../Cli.js'
+import * as Client from './Client.js'
+import * as HttpClient from './HttpClient.js'
+import * as MemoryClient from './MemoryClient.js'
+import * as HttpTransport from './transports/HttpTransport.js'
+
+describe('Client.create', () => {
+ test('resolves the transport factory exactly once and keeps resolved capabilities', async () => {
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(String(input))
+ if (init?.method === 'POST' && url.pathname === '/_incur/rpc')
+ return new Response(
+ JSON.stringify({ ok: true, data: { ok: true }, meta: { command: 'status' } }),
+ { headers: { 'content-type': 'application/json' } },
+ )
+ return new Response('help', { headers: { 'content-type': 'text/plain' } })
+ }) as typeof globalThis.fetch
+ const transport = vi.fn(
+ HttpTransport.create({ baseUrl: 'https://example.com', fetch }),
+ ) satisfies HttpTransport.HttpTransport
+
+ const client = Client.create({ transport })
+
+ expect(transport).toHaveBeenCalledTimes(1)
+ await client.run('status' as never)
+ await client.help()
+ expect(fetch).toHaveBeenCalledTimes(2)
+ expect(fetch).toHaveBeenNthCalledWith(
+ 1,
+ new URL('https://example.com/_incur/rpc'),
+ expect.objectContaining({ method: 'POST' }),
+ )
+ expect(fetch).toHaveBeenNthCalledWith(
+ 2,
+ new URL('https://example.com/_incur/help'),
+ expect.objectContaining({ method: 'GET' }),
+ )
+ })
+
+ test('propagates transport factory errors', () => {
+ const transport = (() => {
+ throw new Error('cannot connect')
+ }) as HttpTransport.HttpTransport
+
+ expect(() => Client.create({ transport })).toThrow('cannot connect')
+ })
+
+ test('resolves memory transport, preserves defaults, and binds actions', async () => {
+ const cli = Cli.create('app').command('status', {
+ run() {
+ return { ok: true }
+ },
+ })
+ const client = MemoryClient.create(cli, {
+ outputFormat: 'toon',
+ })
+
+ expect(client).toMatchObject({
+ defaults: { outputFormat: 'toon' },
+ transport: { key: 'memory', name: 'Memory', type: 'memory' },
+ type: 'client',
+ })
+ await expect(client.run('status')).resolves.toMatchObject({
+ ok: true,
+ data: { ok: true },
+ })
+ })
+
+ test('HttpClient.create is a thin wrapper over HttpTransport.create', async () => {
+ const fetch = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({ ok: true, data: 1, meta: { command: 'status', duration: '1ms' } }),
+ { headers: { 'content-type': 'application/json' } },
+ ),
+ ) as typeof globalThis.fetch
+
+ const client = HttpClient.create({ baseUrl: 'https://example.com/api', fetch })
+ expect(client.transport.baseUrl.href).toBe('https://example.com/api')
+ await client.run('status' as never)
+ expect(fetch).toHaveBeenCalledWith(
+ new URL('https://example.com/api/_incur/rpc'),
+ expect.objectContaining({ method: 'POST' }),
+ )
+ })
+
+ test('MemoryClient.create uses memory transport and exposes local actions', () => {
+ const cli = Cli.create('app')
+ const client = MemoryClient.create(cli)
+
+ expect(client.transport.type).toBe('memory')
+ expect(typeof client.skills.add).toBe('function')
+ expect(typeof client.skills.list).toBe('function')
+ expect(typeof client.mcp.add).toBe('function')
+ })
+
+ test('http client has no runtime local action methods', () => {
+ const client = Client.create({
+ transport: HttpTransport.create({ baseUrl: 'https://example.com' }),
+ })
+ expect('add' in client.skills).toBe(false)
+ expect('list' in client.skills).toBe(false)
+ expect('add' in client.mcp).toBe(false)
+ })
+
+ test('memory clients merge resource and local methods in shared namespaces', async () => {
+ const cli = Cli.create('app').command('status', {
+ description: 'Show status',
+ run() {
+ return { ok: true }
+ },
+ })
+ const client = MemoryClient.create(cli)
+
+ await expect(client.skills.index()).resolves.toMatchObject({
+ skills: [expect.objectContaining({ name: 'status' })],
+ })
+ expect(typeof client.skills.add).toBe('function')
+ expect(typeof client.skills.list).toBe('function')
+ expect(typeof client.mcp.tools).toBe('function')
+ expect(typeof client.mcp.add).toBe('function')
+ })
+
+ test('missing fetch implementation throws ClientError', () => {
+ const original = globalThis.fetch
+ Object.defineProperty(globalThis, 'fetch', { configurable: true, value: undefined })
+ try {
+ expect(() => HttpClient.create({ baseUrl: 'https://example.com' })).toThrow(
+ Client.ClientError,
+ )
+ } finally {
+ Object.defineProperty(globalThis, 'fetch', { configurable: true, value: original })
+ }
+ })
+})
diff --git a/src/client/Client.ts b/src/client/Client.ts
new file mode 100644
index 00000000..29e91f40
--- /dev/null
+++ b/src/client/Client.ts
@@ -0,0 +1,138 @@
+import * as LocalActions from './actions/LocalActions.js'
+import * as ResourcesActions from './actions/ResourcesActions.js'
+import * as RunActions from './actions/RunActions.js'
+export { ClientError } from './ClientError.js'
+import type * as Formatter from '../Formatter.js'
+import type { ActionClient } from './actions/ActionClient.js'
+import type * as Local from './Local.js'
+import type * as Resources from './Resources.js'
+import type * as Run from './Run.js'
+import type { HttpTransport } from './transports/HttpTransport.js'
+import type { MemoryTransport } from './transports/MemoryTransport.js'
+
+/** Type-safe client registration interface populated by generated client maps. */
+// biome-ignore lint/suspicious/noEmptyInterface: populated via declaration merging
+export interface Register {}
+
+/** Default command map registered for typed clients. */
+export type Commands = Register extends { commands: infer commands extends CommandsMap }
+ ? commands
+ : {}
+
+/** Command map entry shape. */
+export type CommandEntry = {
+ /** Structured positional arguments. */
+ args: unknown
+ /** Structured named options. */
+ options: unknown
+ /** Structured command output. */
+ output?: unknown | undefined
+ /** Whether the command streams chunk outputs. */
+ stream?: true | undefined
+}
+
+/** Command map shape used by typed clients. */
+export type CommandsMap = Record
+
+/** Supported client transport factories. */
+export type Transport = HttpTransport | MemoryTransport
+
+/** Resolved transport value attached to a client. */
+export type ResolvedTransport = ReturnType['config'] &
+ Omit, 'config'>
+
+/** Defaults used by run actions. */
+export type Defaults = {
+ /** Rendered output format for command output text. */
+ outputFormat?: Formatter.Format | undefined
+ /** Structured output selection paths. */
+ selection?: string[] | undefined
+ /** Whether token metadata should be included. */
+ outputTokenCount?: boolean | undefined
+ /** Maximum rendered output tokens. */
+ outputTokenLimit?: number | undefined
+ /** Rendered output token offset. */
+ outputTokenOffset?: number | undefined
+}
+
+/** Base client fields. */
+export type Base = {
+ /** Defaults applied by actions before transport requests. */
+ defaults: defaults
+ /** Resolved transport metadata and capabilities. */
+ transport: ResolvedTransport
+ /** Client discriminator. */
+ type: 'client'
+}
+
+/** Typed client instance. */
+export type Client<
+ commands = Commands,
+ transport extends Transport = Transport,
+ defaults extends Defaults = {},
+> = Base &
+ Run.Actions &
+ Resources.Actions &
+ ([transport] extends [MemoryTransport] ? Local.Methods : {})
+
+/** Options for `Client.create()`. */
+export type CreateOptions = defaults &
+ Defaults & {
+ /** Transport factory to resolve. */
+ transport: transport
+ }
+
+/** Canonical command id. */
+export type CommandId = keyof commands & string
+
+/** Command prefix usable by resources actions. */
+export type CommandPrefix = command extends `${infer head} ${infer tail}`
+ ? head | `${head} ${CommandPrefix}`
+ : never
+
+/** Command or command-group scope usable by resources actions. */
+export type CommandScope = CommandId | CommandPrefix>
+
+/** Creates a typed client from a transport factory. */
+export function create<
+ const commands = Commands,
+ const transport extends Transport = Transport,
+ const defaults extends Defaults = {},
+>(options: CreateOptions): Client {
+ const { transport, ...defaults } = options
+ const resolved = transport()
+ const { config, ...capabilities } = resolved
+ const client = {
+ defaults,
+ transport: { ...config, ...capabilities },
+ type: 'client',
+ } satisfies ActionClient & { type: 'client' }
+
+ return {
+ ...client,
+ ...actions(client),
+ } as unknown as Client
+}
+
+function actions(client: ActionClient) {
+ const base = {
+ ...RunActions.actions(client),
+ ...ResourcesActions.actions(client),
+ }
+
+ if (!client.transport.local) return base
+ const memory = LocalActions.actions(client)
+
+ return {
+ ...base,
+ ...memory,
+ skills: {
+ ...base.skills,
+ ...memory.skills,
+ },
+ mcp: {
+ ...base.mcp,
+ ...memory.mcp,
+ },
+ }
+}
diff --git a/src/client/ClientError.ts b/src/client/ClientError.ts
new file mode 100644
index 00000000..37651817
--- /dev/null
+++ b/src/client/ClientError.ts
@@ -0,0 +1,52 @@
+import { BaseError } from '../Errors.js'
+import type * as Rpc from './Rpc.js'
+
+/** Error thrown by client transports. */
+export class ClientError extends BaseError {
+ override name = 'Incur.ClientError'
+ /** Machine-readable error code. */
+ code: string | undefined
+ /** Full error envelope or diagnostic payload. */
+ data: unknown | undefined
+ /** RPC error object. */
+ error: Rpc.Error | undefined
+ /** Field validation errors. */
+ fieldErrors: Rpc.Error['fieldErrors'] | undefined
+ /** Response metadata. */
+ meta: Rpc.Meta | undefined
+ /** Whether the operation can be retried. */
+ retryable: boolean | undefined
+ /** HTTP status when available. */
+ status: number | undefined
+
+ constructor(message: string, options: ClientError.Options = {}) {
+ super(message, options.cause ? { cause: options.cause } : undefined)
+ this.code = options.code
+ this.data = options.data
+ this.error = options.error
+ this.fieldErrors = options.fieldErrors
+ this.meta = options.meta
+ this.retryable = options.retryable
+ this.status = options.status
+ }
+}
+
+export declare namespace ClientError {
+ /** Client error constructor options. */
+ type Options = BaseError.Options & {
+ /** Machine-readable error code. */
+ code?: string | undefined
+ /** Full error envelope or diagnostic payload. */
+ data?: unknown | undefined
+ /** RPC error object. */
+ error?: Rpc.Error | undefined
+ /** Field validation errors. */
+ fieldErrors?: Rpc.Error['fieldErrors'] | undefined
+ /** Response metadata. */
+ meta?: Rpc.Meta | undefined
+ /** Whether the operation can be retried. */
+ retryable?: boolean | undefined
+ /** HTTP status when available. */
+ status?: number | undefined
+ }
+}
diff --git a/src/client/HttpClient.test-d.ts b/src/client/HttpClient.test-d.ts
new file mode 100644
index 00000000..fa48e918
--- /dev/null
+++ b/src/client/HttpClient.test-d.ts
@@ -0,0 +1,83 @@
+import { HttpClient, Run } from 'incur/client'
+import { expectTypeOf, test } from 'vitest'
+
+type Commands = {
+ status: { args: {}; options: {}; output: { ok: boolean } }
+ report: {
+ args: { id: string }
+ options: { verbose?: boolean | undefined }
+ output: { title: string }
+ }
+ deploy: {
+ args: { id: string }
+ options: { environment: 'production' | 'staging' }
+ output: { deployId: string }
+ }
+ logs: {
+ args: { service: string }
+ options: {}
+ output: { line: string }
+ stream: true
+ }
+}
+
+test('http client preserves transport, defaults, and command types', async () => {
+ const fetch = (() => Promise.resolve(new Response('{}'))) as typeof globalThis.fetch
+ const client = HttpClient.create({
+ baseUrl: 'https://example.com',
+ fetch,
+ headers: { authorization: 'Bearer token' },
+ outputFormat: 'toon',
+ selection: ['title'],
+ })
+
+ expectTypeOf(client).toExtend<
+ HttpClient.HttpClient
+ >()
+ expectTypeOf(client.defaults).toEqualTypeOf<{ selection: string[]; outputFormat: 'toon' }>()
+ expectTypeOf(client.transport.type).toEqualTypeOf<'http'>()
+ expectTypeOf(client.transport.baseUrl).toEqualTypeOf()
+ // @ts-expect-error HTTP clients do not expose memory-local methods.
+ client.skills.add()
+ // @ts-expect-error transport options are not client defaults.
+ void client.defaults.baseUrl
+ // @ts-expect-error transport options are not client defaults.
+ void client.defaults.headers
+
+ expectTypeOf(await client.run('report', { args: { id: 'p1' } })).toEqualTypeOf<
+ Run.Result
+ >()
+ expectTypeOf(
+ await client.run('report', {
+ args: { id: 'p1' },
+ selection: undefined,
+ }),
+ ).toEqualTypeOf>()
+ expectTypeOf(await client.run('logs', { args: { service: 'api' } })).toEqualTypeOf<
+ Run.StreamResponse
+ >()
+ expectTypeOf(
+ await client.run('logs', { args: { service: 'api' }, selection: undefined }),
+ ).toEqualTypeOf>()
+ // @ts-expect-error required options make input required.
+ await client.run('deploy', { args: { id: 'p1' } })
+ // @ts-expect-error unknown commands are rejected.
+ await client.run('missing')
+})
+
+test('http client can use registered commands without explicit generics', async () => {
+ const client = HttpClient.create({ baseUrl: 'https://example.com' })
+ const result = await client.run('registered')
+
+ expectTypeOf(result).toEqualTypeOf>()
+})
+
+type RegisteredCommands = {
+ registered: { args: {}; options: {}; output: { ok: true } }
+}
+
+declare module 'incur/client' {
+ interface Register {
+ commands: RegisteredCommands
+ }
+}
diff --git a/src/client/HttpClient.test.ts b/src/client/HttpClient.test.ts
new file mode 100644
index 00000000..7f61e734
--- /dev/null
+++ b/src/client/HttpClient.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test, vi } from 'vitest'
+
+import * as Client from './Client.js'
+import * as HttpClient from './HttpClient.js'
+
+describe('HttpClient.create', () => {
+ test('creates an HTTP client, strips transport options from defaults, and forwards run defaults', async () => {
+ const fetch = vi.fn(
+ async (_input: RequestInfo | URL, _init?: RequestInit) =>
+ new Response(
+ JSON.stringify({
+ ok: true,
+ data: { ok: true },
+ meta: { command: 'status', duration: '1ms' },
+ }),
+ { headers: { 'content-type': 'application/json' } },
+ ),
+ )
+
+ const client = HttpClient.create({
+ baseUrl: 'https://example.com/api',
+ fetch,
+ headers: { authorization: 'Bearer token' },
+ outputFormat: 'toon',
+ outputTokenCount: true,
+ selection: ['ok'],
+ })
+
+ expect(client).toMatchObject({
+ defaults: {
+ outputFormat: 'toon',
+ outputTokenCount: true,
+ selection: ['ok'],
+ },
+ transport: {
+ key: 'http',
+ name: 'HTTP',
+ type: 'http',
+ },
+ type: 'client',
+ })
+ expect(client.defaults).not.toHaveProperty('baseUrl')
+ expect(client.defaults).not.toHaveProperty('fetch')
+ expect(client.defaults).not.toHaveProperty('headers')
+
+ await expect(client.run('status' as never)).resolves.toMatchObject({
+ data: { ok: true },
+ ok: true,
+ })
+ const [input, init] = fetch.mock.calls[0]!
+ expect(input).toEqual(new URL('https://example.com/api/_incur/rpc'))
+ expect(init).toMatchObject({ method: 'POST' })
+ expect(JSON.parse(String(init?.body))).toEqual({
+ args: {},
+ command: 'status',
+ options: {},
+ outputFormat: 'toon',
+ outputTokenCount: true,
+ selection: ['ok'],
+ })
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer token')
+ })
+
+ test('does not expose memory-only local methods', () => {
+ const client = HttpClient.create({
+ baseUrl: 'https://example.com',
+ })
+
+ expect('add' in client.skills).toBe(false)
+ expect('list' in client.skills).toBe(false)
+ expect('add' in client.mcp).toBe(false)
+ })
+
+ test('throws when neither an explicit fetch nor global fetch exists', () => {
+ const original = globalThis.fetch
+ Object.defineProperty(globalThis, 'fetch', { configurable: true, value: undefined })
+ try {
+ expect(() => HttpClient.create({ baseUrl: 'https://example.com' })).toThrow(
+ Client.ClientError,
+ )
+ } finally {
+ Object.defineProperty(globalThis, 'fetch', { configurable: true, value: original })
+ }
+ })
+})
diff --git a/src/client/HttpClient.ts b/src/client/HttpClient.ts
new file mode 100644
index 00000000..94dfe116
--- /dev/null
+++ b/src/client/HttpClient.ts
@@ -0,0 +1,24 @@
+import * as Client from './Client.js'
+import * as HttpTransport from './transports/HttpTransport.js'
+
+/** HTTP client instance. */
+export type HttpClient<
+ commands = Client.Commands,
+ defaults extends Client.Defaults = {},
+> = Client.Client
+
+/** Creates an HTTP typed client. */
+export function create<
+ const commands = Client.Commands,
+ const defaults extends Client.Defaults = {},
+>(options: HttpTransport.Options & defaults & Client.Defaults): HttpClient {
+ const { baseUrl, fetch, headers, ...defaults } = options
+ return Client.create({
+ ...defaults,
+ transport: HttpTransport.create({
+ baseUrl,
+ ...(fetch ? { fetch } : undefined),
+ ...(headers ? { headers } : undefined),
+ }),
+ } as HttpTransport.Options & defaults & { transport: HttpTransport.HttpTransport })
+}
diff --git a/src/client/Local.test-d.ts b/src/client/Local.test-d.ts
new file mode 100644
index 00000000..eb37f6b7
--- /dev/null
+++ b/src/client/Local.test-d.ts
@@ -0,0 +1,24 @@
+import { Local } from 'incur/client'
+import { expectTypeOf, test } from 'vitest'
+
+test('local methods expose precise option and result types', async () => {
+ const local = undefined as unknown as Local.Methods
+
+ expectTypeOf(await local.skills.add()).toEqualTypeOf()
+ expectTypeOf(await local.skills.list()).toEqualTypeOf()
+ expectTypeOf(await local.mcp.add()).toEqualTypeOf()
+
+ await local.skills.add({ depth: 2, global: undefined })
+ await local.skills.list({ depth: undefined })
+ await local.mcp.add({ agents: ['codex'], command: undefined, global: false })
+ // @ts-expect-error depth must be a number.
+ await local.skills.add({ depth: '2' })
+ // @ts-expect-error global must be a boolean.
+ await local.skills.add({ global: 'yes' })
+ // @ts-expect-error agents must be an array of strings.
+ await local.mcp.add({ agents: [1] })
+ // @ts-expect-error command must be a string.
+ await local.mcp.add({ command: 123 })
+ // @ts-expect-error extra option keys are rejected.
+ await local.skills.list({ depth: 1, extra: true })
+})
diff --git a/src/client/Local.ts b/src/client/Local.ts
new file mode 100644
index 00000000..265ead2d
--- /dev/null
+++ b/src/client/Local.ts
@@ -0,0 +1,54 @@
+import type * as SyncMcp from '../SyncMcp.js'
+import type * as SyncSkills from '../SyncSkills.js'
+
+/** Options for `local.skills.add()`. */
+export type SkillsAddOptions = {
+ /** Grouping depth. */
+ depth?: number | undefined
+ /** Install globally instead of project-local. */
+ global?: boolean | undefined
+}
+
+/** Options for `local.skills.list()`. */
+export type SkillsListOptions = {
+ /** Grouping depth. */
+ depth?: number | undefined
+}
+
+/** Options for `local.mcp.add()`. */
+export type McpAddOptions = {
+ /** Target agents. */
+ agents?: string[] | undefined
+ /** Command agents should run. */
+ command?: string | undefined
+ /** Install globally instead of project-local. */
+ global?: boolean | undefined
+}
+
+/** Synced skills result. */
+export type SyncedSkills = SyncSkills.sync.Result
+
+/** Skills list result. */
+export type SkillsList = {
+ /** Listed skills. */
+ skills: SyncSkills.list.Skill[]
+}
+
+/** MCP registration result. */
+export type McpRegistration = SyncMcp.register.Result
+
+/** Memory-only local methods exposed by memory transports and clients. */
+export type Methods = {
+ /** Skill setup actions. */
+ skills: {
+ /** Sync generated skill files. */
+ add(options?: SkillsAddOptions | undefined): Promise
+ /** List generated skill files without writing them. */
+ list(options?: SkillsListOptions | undefined): Promise
+ }
+ /** MCP setup actions. */
+ mcp: {
+ /** Register the CLI as an MCP server. */
+ add(options?: McpAddOptions | undefined): Promise
+ }
+}
diff --git a/src/client/MemoryClient.test-d.ts b/src/client/MemoryClient.test-d.ts
new file mode 100644
index 00000000..bc41db4d
--- /dev/null
+++ b/src/client/MemoryClient.test-d.ts
@@ -0,0 +1,85 @@
+import { Cli, z } from 'incur'
+import { MemoryClient, Run } from 'incur/client'
+import { expectTypeOf, test } from 'vitest'
+
+type Commands = {
+ report: {
+ args: { id: string }
+ options: { verbose?: boolean | undefined }
+ output: { title: string }
+ }
+ logs: {
+ args: { service: string }
+ options: {}
+ output: { line: string }
+ stream: true
+ }
+}
+
+test('memory client infers command maps from concrete CLIs', async () => {
+ const cli = Cli.create('app')
+ .command('status', {
+ args: z.object({ id: z.string() }),
+ options: z.object({ verbose: z.boolean().optional() }),
+ run(c) {
+ expectTypeOf(c.args).toEqualTypeOf<{ id: string }>()
+ expectTypeOf(c.options).toEqualTypeOf<{ verbose?: boolean | undefined }>()
+ return { ok: true as const }
+ },
+ })
+ .command('logs', {
+ args: z.object({ service: z.string() }),
+ async *run() {
+ yield { line: 'ready' }
+ },
+ })
+
+ const client = MemoryClient.create(cli, { outputFormat: 'json' })
+ type InferredCommands =
+ typeof client extends MemoryClient.MemoryClient ? commands : never
+
+ expectTypeOf(client).toExtend<
+ MemoryClient.MemoryClient<{
+ logs: { args: { service: string }; options: {}; output: { line: string }; stream: true }
+ status: {
+ args: { id: string }
+ options: { verbose?: boolean | undefined }
+ output: { ok: true }
+ }
+ }>
+ >()
+ expectTypeOf(client.defaults).toExtend<{ outputFormat?: 'json' | undefined }>()
+ expectTypeOf(client.transport.type).toEqualTypeOf<'memory'>()
+ expectTypeOf(client.skills.add).toBeFunction()
+ expectTypeOf(client.skills.list).toBeFunction()
+ expectTypeOf(client.mcp.add).toBeFunction()
+
+ expectTypeOf(await client.run('status', { args: { id: 'p1' } })).toEqualTypeOf<
+ Run.Result
+ >()
+ expectTypeOf(await client.run('logs', { args: { service: 'api' } })).toEqualTypeOf<
+ Run.Result
+ >()
+ // @ts-expect-error inferred args are required.
+ await client.run('status')
+ // @ts-expect-error unknown options are rejected.
+ await client.run('status', { args: { id: 'p1' }, options: { extra: true } })
+})
+
+test('memory client supports explicit command maps and keeps env out of defaults', async () => {
+ const client = MemoryClient.create(Cli.create('app'), {
+ env: { TOKEN: 'secret' },
+ outputTokenLimit: 32,
+ })
+
+ expectTypeOf(client).toExtend>()
+ expectTypeOf(client.defaults).toEqualTypeOf<{ outputTokenLimit: number }>()
+ // @ts-expect-error transport env is not a client default.
+ void client.defaults.env
+ expectTypeOf(await client.run('report', { args: { id: 'p1' } })).toEqualTypeOf<
+ Run.Result<{ title: string }, Commands>
+ >()
+ expectTypeOf(await client.run('logs', { args: { service: 'api' } })).toEqualTypeOf<
+ Run.StreamResponse<{ line: string }, unknown, Commands>
+ >()
+})
diff --git a/src/client/MemoryClient.test.ts b/src/client/MemoryClient.test.ts
new file mode 100644
index 00000000..d10539f4
--- /dev/null
+++ b/src/client/MemoryClient.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test } from 'vitest'
+import { z } from 'zod'
+
+import * as Cli from '../Cli.js'
+import * as MemoryClient from './MemoryClient.js'
+
+describe('MemoryClient.create', () => {
+ test('creates a memory client, strips transport options from defaults, and executes in process', async () => {
+ const cli = Cli.create('app', {
+ env: z.object({ TOKEN: z.string() }),
+ }).command('status', {
+ env: z.object({ TOKEN: z.string() }),
+ run(c) {
+ return { token: c.env.TOKEN }
+ },
+ })
+ cli.fetch = async () => {
+ throw new Error('fetch should not be called')
+ }
+
+ const client = MemoryClient.create(cli, {
+ env: { TOKEN: 'secret' },
+ outputFormat: 'json',
+ outputTokenCount: true,
+ })
+
+ expect(client).toMatchObject({
+ defaults: {
+ outputFormat: 'json',
+ outputTokenCount: true,
+ },
+ transport: {
+ key: 'memory',
+ name: 'Memory',
+ type: 'memory',
+ },
+ type: 'client',
+ })
+ expect(client.defaults).not.toHaveProperty('env')
+ await expect(client.run('status')).resolves.toMatchObject({
+ data: { token: 'secret' },
+ ok: true,
+ })
+ })
+
+ test('exposes memory-only local methods alongside shared resource methods', () => {
+ const client = MemoryClient.create(Cli.create('app'))
+
+ expect(typeof client.run).toBe('function')
+ expect(typeof client.llms).toBe('function')
+ expect(typeof client.llmsFull).toBe('function')
+ expect(typeof client.schema).toBe('function')
+ expect(typeof client.help).toBe('function')
+ expect(typeof client.openapi).toBe('function')
+ expect(typeof client.skills.index).toBe('function')
+ expect(typeof client.skills.get).toBe('function')
+ expect(typeof client.skills.add).toBe('function')
+ expect(typeof client.skills.list).toBe('function')
+ expect(typeof client.mcp.tools).toBe('function')
+ expect(typeof client.mcp.add).toBe('function')
+ })
+
+ test('works without options', async () => {
+ const cli = Cli.create('app').command('status', {
+ run() {
+ return { ok: true }
+ },
+ })
+ const client = MemoryClient.create(cli)
+
+ expect(client.defaults).toEqual({})
+ await expect(client.run('status')).resolves.toMatchObject({
+ data: { ok: true },
+ ok: true,
+ })
+ })
+})
diff --git a/src/client/MemoryClient.ts b/src/client/MemoryClient.ts
new file mode 100644
index 00000000..7177b8f7
--- /dev/null
+++ b/src/client/MemoryClient.ts
@@ -0,0 +1,36 @@
+import type * as Cli from '../Cli.js'
+import * as Client from './Client.js'
+import * as MemoryTransport from './transports/MemoryTransport.js'
+
+/** Memory client instance. */
+export type MemoryClient<
+ commands = Client.Commands,
+ defaults extends Client.Defaults = {},
+> = Client.Client
+
+/** Creates a memory typed client and infers commands from a concrete CLI. */
+export function create<
+ const inferredCommands extends Cli.CommandsMap,
+ const defaults extends Client.Defaults = {},
+>(
+ cli: Cli.Cli,
+ options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined,
+): MemoryClient
+/** Creates a memory typed client with an explicit command map. */
+export function create<
+ const commands extends Client.CommandsMap = Client.Commands,
+ const defaults extends Client.Defaults = {},
+>(
+ cli: Cli.Cli,
+ options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined,
+): MemoryClient
+export function create(
+ cli: Cli.Cli,
+ options: MemoryTransport.Options & Client.Defaults = {},
+): MemoryClient {
+ const { env, ...defaults } = options
+ return Client.create({
+ ...defaults,
+ transport: MemoryTransport.create(cli, { env }),
+ })
+}
diff --git a/src/client/Resources.test-d.ts b/src/client/Resources.test-d.ts
new file mode 100644
index 00000000..818d7c47
--- /dev/null
+++ b/src/client/Resources.test-d.ts
@@ -0,0 +1,65 @@
+import { Client, Resources } from 'incur/client'
+import { expectTypeOf, test } from 'vitest'
+
+type Commands = {
+ 'project report': { args: {}; options: {}; output: {} }
+ 'project deploy': { args: {}; options: {}; output: {} }
+ 'auth login': { args: {}; options: {}; output: {} }
+}
+
+test('resources conditional types preserve structured and rendered formats', () => {
+ expectTypeOf>().toEqualTypeOf<{ commands: [] }>()
+ expectTypeOf>().toEqualTypeOf<{ commands: [] }>()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toEqualTypeOf<
+ string | { commands: [] }
+ >()
+})
+
+test('resources scopes narrow command names and reject invalid scopes', () => {
+ expectTypeOf>().toEqualTypeOf<
+ 'auth' | 'auth login' | 'project' | 'project deploy' | 'project report'
+ >()
+ expectTypeOf['name']>().toEqualTypeOf()
+ expectTypeOf['name']>().toEqualTypeOf<
+ 'project deploy' | 'project report'
+ >()
+ expectTypeOf<
+ Resources.LlmsCommand['name']
+ >().toEqualTypeOf<'project report'>()
+
+ const client = undefined as unknown as Resources.Actions
+ client.schema('project')
+ client.help('project report')
+ client.llms({ command: 'auth', format: 'yaml' })
+ // @ts-expect-error invalid resources scope.
+ client.schema('missing')
+ // @ts-expect-error invalid resources scope.
+ client.help('project missing')
+ // @ts-expect-error invalid llms format.
+ client.llms({ format: 'html' })
+})
+
+test('resources request and response unions enforce resource-specific fields', () => {
+ const skill = { resource: 'skill', name: 'deploy' } satisfies Resources.Request
+ const openapi = { resource: 'openapi', format: 'yaml' } satisfies Resources.Request
+ const body = { contentType: 'text/plain', body: 'ok' } satisfies Resources.Response
+ const data = { contentType: 'application/json', data: { ok: true } } satisfies Resources.Response
+
+ expectTypeOf(skill.resource).toEqualTypeOf<'skill'>()
+ expectTypeOf(openapi.format).toEqualTypeOf<'yaml'>()
+ expectTypeOf(body.body).toEqualTypeOf()
+ expectTypeOf(data.data).toEqualTypeOf<{ ok: boolean }>()
+ // @ts-expect-error skill requests require a name.
+ const missingSkill = { resource: 'skill' } satisfies Resources.Request
+ void missingSkill
+ // @ts-expect-error openapi supports only json or yaml formats.
+ const invalidOpenapi = { resource: 'openapi', format: 'md' } satisfies Resources.Request
+ void invalidOpenapi
+ // @ts-expect-error invalid resource names are rejected.
+ const invalidResource = { resource: 'docs' } satisfies Resources.Request
+ void invalidResource
+})
diff --git a/src/client/Resources.ts b/src/client/Resources.ts
new file mode 100644
index 00000000..8bec9ac1
--- /dev/null
+++ b/src/client/Resources.ts
@@ -0,0 +1,133 @@
+import type * as Formatter from '../Formatter.js'
+import type * as Client from './Client.js'
+
+/** Resources format. */
+export type Format = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon'
+
+/** Resources result for a structured type and format option. */
+export type Result = [format] extends [undefined]
+ ? structured
+ : [format] extends ['json']
+ ? structured
+ : undefined extends format
+ ? structured | string
+ : string
+
+/** Resource request accepted by `transport.discover()`. */
+export type Request =
+ | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined }
+ | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined }
+ | { resource: 'schema'; command?: string | undefined }
+ | { resource: 'help'; command?: string | undefined }
+ | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined }
+ | { resource: 'skillsIndex' }
+ | { resource: 'skill'; name: string }
+ | { resource: 'mcpTools' }
+
+/** Resource response returned by `transport.discover()`. */
+export type Response =
+ | { contentType: string; body: string }
+ | { contentType: string; data: unknown }
+
+/** LLM manifest. */
+export type LlmsManifest<
+ commands = Client.Commands,
+ scope extends Client.CommandScope | undefined = undefined,
+> = {
+ /** Manifest version. */
+ version: string
+ /** Available commands. */
+ commands: LlmsCommand[]
+}
+
+/** Full LLM manifest. */
+export type LlmsFullManifest<
+ commands = Client.Commands,
+ scope extends Client.CommandScope | undefined = undefined,
+> = LlmsManifest
+
+/** LLM command entry. */
+export type LlmsCommand<
+ commands = Client.Commands,
+ scope extends Client.CommandScope | undefined = undefined,
+> = {
+ /** Command name. */
+ name: scope extends undefined
+ ? Client.CommandId
+ : Extract, `${scope}` | `${scope} ${string}`>
+ /** Command description. */
+ description?: string | undefined
+ /** Command schemas. */
+ schema?: CommandSchema> | undefined
+}
+
+/** JSON-ish command schema. */
+export type CommandSchema<_commands = Client.Commands, _command extends string = string> = Record<
+ string,
+ unknown
+> & {
+ /** Args schema. */
+ args?: Record | undefined
+ /** Options schema. */
+ options?: Record | undefined
+ /** Env schema. */
+ env?: Record | undefined
+ /** Output schema. */
+ output?: Record | undefined
+}
+
+/** OpenAPI document. */
+export type OpenApiDocument = Record & {
+ /** OpenAPI version. */
+ openapi?: string | undefined
+ /** OpenAPI info object. */
+ info?: Record | undefined
+}
+
+/** Skills index. */
+export type SkillsIndex = {
+ /** Generated skills. */
+ skills: { name: string; description: string; files: string[] }[]
+}
+
+/** MCP tool descriptor response. */
+export type McpToolsResponse<_commands = Client.Commands> = {
+ /** MCP tools. */
+ tools: Record[]
+}
+
+/** Resources action set. */
+export type Actions = {
+ llms: LlmsAction
+ llmsFull: LlmsFullAction
+ schema(command?: Client.CommandScope | undefined): Promise>
+ help(command?: Client.CommandScope | undefined): Promise
+ openapi(): Promise
+ skills: {
+ index(): Promise
+ get(name: string): Promise
+ }
+ mcp: {
+ tools(): Promise>
+ }
+}
+
+/** Compact LLM resources action. */
+export type LlmsAction = {
+ <
+ const scope extends Client.CommandScope | undefined = undefined,
+ const format extends Format | undefined = undefined,
+ >(
+ options?: { command?: scope | undefined; format?: format | undefined } | undefined,
+ ): Promise, format>>
+}
+
+/** Full LLM resources action. */
+export type LlmsFullAction = {
+ <
+ const scope extends Client.CommandScope | undefined = undefined,
+ const format extends Format | undefined = undefined,
+ >(
+ options?: { command?: scope | undefined; format?: format | undefined } | undefined,
+ ): Promise, format>>
+}
diff --git a/src/client/Rpc.ts b/src/client/Rpc.ts
new file mode 100644
index 00000000..2d376a94
--- /dev/null
+++ b/src/client/Rpc.ts
@@ -0,0 +1,89 @@
+import type { FieldError } from '../Errors.js'
+import type * as Formatter from '../Formatter.js'
+
+/** RPC request accepted by `transport.request()`. */
+export type Request = {
+ /** Canonical command ID. */
+ command: string
+ /** Structured positional arguments. */
+ args?: Record | undefined
+ /** Structured named options. */
+ options?: Record | undefined
+ /** Output format for rendered text. */
+ outputFormat?: Formatter.Format | undefined
+ /** Output selection paths. */
+ selection?: string[] | undefined
+ /** Whether token metadata should be included. */
+ outputTokenCount?: boolean | undefined
+ /** Maximum rendered output tokens to return. */
+ outputTokenLimit?: number | undefined
+ /** Rendered output token offset. */
+ outputTokenOffset?: number | undefined
+}
+
+/** Rendered output payload. */
+export type Output = {
+ /** Rendered output text. */
+ text: string
+ /** Rendered format. */
+ format?: Formatter.Format | undefined
+ /** Offset to request for the next token window. */
+ nextOffset?: number | undefined
+ /** Rendered token count before truncation. */
+ tokenCount?: number | undefined
+ /** Requested token limit. */
+ tokenLimit?: number | undefined
+ /** Requested token offset. */
+ tokenOffset?: number | undefined
+ /** Whether text was truncated by token controls. */
+ truncated?: boolean | undefined
+}
+
+/** RPC response metadata. */
+export type Meta = {
+ /** Canonical command ID. */
+ command: string
+ /** Suggested next commands. */
+ cta?: unknown | undefined
+ /** Wall-clock duration. */
+ duration: string
+}
+
+/** Full RPC success/error envelope. */
+export type Envelope =
+ | {
+ ok: true
+ data: unknown
+ output?: Output | undefined
+ meta: Meta
+ }
+ | {
+ ok: false
+ error: {
+ code: string
+ fieldErrors?: FieldError[] | undefined
+ message: string
+ retryable?: boolean | undefined
+ }
+ meta: Meta
+ /** HTTP status when the response came from an HTTP transport. */
+ status?: number | undefined
+ }
+
+/** RPC error object. */
+export type Error = Extract['error']
+
+/** Non-streaming RPC response. */
+export type Response = Envelope
+
+/** Streaming RPC record. */
+export type StreamRecord =
+ | { type: 'chunk'; data: unknown }
+ | ({ type: 'done' } & Extract)
+ | ({ type: 'error' } & Extract)
+
+/** Streaming RPC response. */
+export type StreamResponse = {
+ stream: true
+ records(): AsyncGenerator
+}
diff --git a/src/client/Run.test-d.ts b/src/client/Run.test-d.ts
new file mode 100644
index 00000000..5cc7bcbf
--- /dev/null
+++ b/src/client/Run.test-d.ts
@@ -0,0 +1,101 @@
+import { Client, HttpTransport, Run } from 'incur/client'
+import { expectTypeOf, test } from 'vitest'
+
+type Commands = {
+ status: { args: {}; options: {}; output: { ok: boolean } }
+ optional: {
+ args: { id?: string | undefined }
+ options: { verbose?: boolean | undefined }
+ output: { ok: true }
+ }
+ report: {
+ args: { id: string }
+ options: { verbose?: boolean | undefined }
+ output: { title: string }
+ }
+ deploy: {
+ args: { id: string }
+ options: { environment: 'production' | 'staging' }
+ output: { deployId: string }
+ }
+ missingOutput: { args: {}; options: {} }
+ logs: {
+ args: { service: string }
+ options: {}
+ output: { line: string }
+ stream: true
+ }
+}
+
+test('run helper types resolve command fields and input requirements', async () => {
+ expectTypeOf>().toEqualTypeOf<{ id: string }>()
+ expectTypeOf>().toEqualTypeOf<{
+ environment: 'production' | 'staging'
+ }>()
+ expectTypeOf>().toEqualTypeOf()
+ expectTypeOf>().toExtend<{
+ args?: { id?: string | undefined } | undefined
+ options?: { verbose?: boolean | undefined } | undefined
+ }>()
+
+ const client = Client.create({
+ transport: HttpTransport.create({ baseUrl: 'https://example.com' }),
+ })
+ await client.run('status')
+ await client.run('optional')
+ await client.run('report', { args: { id: 'p1' } })
+ // @ts-expect-error required args make input required.
+ await client.run('report')
+ // @ts-expect-error invalid literal option is rejected.
+ await client.run('deploy', { args: { id: 'p1' }, options: { environment: 'dev' } })
+ // @ts-expect-error extra top-level input keys are rejected.
+ await client.run('report', { args: { id: 'p1' }, unknown: true })
+ // @ts-expect-error extra args keys are rejected.
+ await client.run('report', { args: { id: 'p1', extra: true } })
+})
+
+test('run return types follow selection and streaming controls', async () => {
+ const selected = Client.create({
+ selection: ['title'],
+ transport: HttpTransport.create({ baseUrl: 'https://example.com' }),
+ })
+
+ expectTypeOf(await selected.run('report', { args: { id: 'p1' } })).toEqualTypeOf<
+ Run.Result
+ >()
+ expectTypeOf(
+ await selected.run('report', { args: { id: 'p1' }, selection: undefined }),
+ ).toEqualTypeOf>()
+ expectTypeOf(
+ await selected.run('logs', { args: { service: 'api' }, outputFormat: 'json' }),
+ ).toEqualTypeOf>()
+ expectTypeOf(
+ await selected.run('logs', { args: { service: 'api' }, selection: undefined }),
+ ).toEqualTypeOf>()
+ // @ts-expect-error streaming commands reject token count controls.
+ await selected.run('logs', { args: { service: 'api' }, outputTokenCount: true })
+ // @ts-expect-error streaming commands reject token limit controls.
+ await selected.run('logs', { args: { service: 'api' }, outputTokenLimit: 10 })
+ // @ts-expect-error streaming commands reject token offset controls.
+ await selected.run('logs', { args: { service: 'api' }, outputTokenOffset: 10 })
+})
+
+test('run output, CTA, and stream records preserve command maps', async () => {
+ type Result = Run.Result<{ title: string }, Commands>
+ expectTypeOf().toEqualTypeOf<
+ Run.Output<{ title: string }, Commands> | undefined
+ >()
+ expectTypeOf['next']>>().toEqualTypeOf<
+ () => Promise>
+ >()
+ expectTypeOf['run']>().toBeFunction()
+ expectTypeOf>().toExtend<
+ AsyncIterable<{ line: string }>
+ >()
+ expectTypeOf<
+ Awaited['final']>
+ >().toEqualTypeOf>()
+ expectTypeOf<
+ ReturnType['records']>
+ >().toEqualTypeOf>>()
+})
diff --git a/src/client/Run.ts b/src/client/Run.ts
new file mode 100644
index 00000000..70d466ae
--- /dev/null
+++ b/src/client/Run.ts
@@ -0,0 +1,236 @@
+import type * as Formatter from '../Formatter.js'
+import type * as Client from './Client.js'
+import type * as Rpc from './Rpc.js'
+
+/** Command args type. */
+export type Args> = commands[command] extends {
+ args: infer args
+}
+ ? args
+ : unknown
+
+/** Command options type. */
+export type Options<
+ commands,
+ command extends Client.CommandId,
+> = commands[command] extends {
+ options: infer options
+}
+ ? options
+ : unknown
+
+/** Command output data type. */
+export type Data> = commands[command] extends {
+ output: infer output
+}
+ ? output
+ : unknown
+
+/** Required keys in an object-like type. */
+export type RequiredKeys = type extends object
+ ? {
+ [key in keyof type]-?: {} extends Pick ? never : key
+ }[keyof type]
+ : never
+
+/** Conditional input field. */
+export type Field =
+ RequiredKeys extends never
+ ? { [key in name]?: value | undefined }
+ : { [key in name]: value }
+
+/** Run input for a command. */
+export type Input> = Field<
+ 'args',
+ Args
+> &
+ Field<'options', Options> &
+ (commands[command] extends { stream: true }
+ ? Omit
+ : Client.Defaults)
+
+/** Run input parameter tuple. */
+export type InputParameters<
+ commands,
+ command extends Client.CommandId,
+ input extends Input | undefined,
+> =
+ RequiredKeys > extends never
+ ? [input?: StrictInput > | undefined]
+ : [input: StrictInput > & Input]
+
+/** Rejects keys outside an expected input shape. */
+export type StrictInput = input extends undefined
+ ? undefined
+ : input & { [key in Exclude]: never } & {
+ [key in keyof input & keyof shape]: key extends 'args' | 'options'
+ ? StrictField