diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md
new file mode 100644
index 0000000..7d5598c
--- /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 0000000..9fefa90
--- /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 0000000..84bce73
--- /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 3f6fac5..97a5324 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 6bb33bf..ba1d3ec 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 8f218be..84c94ed 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 0000000..0321328
--- /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 ee46568..dc44c93 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 bd55fc6..5dc5d17 100644
--- a/src/Cli.test.ts
+++ b/src/Cli.test.ts
@@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
+import * as Command from './internal/command.js'
+
const originalIsTTY = process.stdout.isTTY
beforeAll(() => {
;(process.stdout as any).isTTY = false
@@ -3654,6 +3656,57 @@ test('streaming: generator throws in buffered mode', async () => {
expect(output).toContain('generator exploded')
})
+test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => {
+ const cli = Cli.create('test')
+ cli.command('limited', {
+ async *run() {
+ yield { step: 1 }
+ throw new Errors.IncurError({
+ code: 'RATE_LIMITED',
+ message: 'too fast',
+ retryable: true,
+ })
+ },
+ })
+
+ const jsonl = await serve(cli, ['limited', '--format', 'jsonl'])
+ const jsonlLines = jsonl.output
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line))
+ expect(jsonl.exitCode).toBe(1)
+ expect(jsonlLines[1]).toMatchInlineSnapshot(`
+ {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "too fast",
+ "retryable": true,
+ },
+ "ok": false,
+ "type": "error",
+ }
+ `)
+
+ const json = await serve(cli, ['limited', '--full-output', '--format', 'json'])
+ const body = JSON.parse(json.output)
+ body.meta.duration = ''
+ expect(json.exitCode).toBe(1)
+ expect(body).toMatchInlineSnapshot(`
+ {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "too fast",
+ "retryable": true,
+ },
+ "meta": {
+ "command": "limited",
+ "duration": "",
+ },
+ "ok": false,
+ }
+ `)
+})
+
test('streaming: generator returns error in buffered mode', async () => {
const cli = Cli.create('test')
cli.command('fail', {
@@ -4051,13 +4104,96 @@ describe('--filter-output', () => {
})
})
+describe('Command.execute', () => {
+ test.each([
+ {
+ name: 'split',
+ command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) },
+ inputOptions: { name: 123 },
+ path: 'name',
+ parseMode: 'split' as const,
+ },
+ {
+ name: 'flat',
+ command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) },
+ inputOptions: { id: 123 },
+ path: 'id',
+ parseMode: 'flat' as const,
+ },
+ ])('$name mode returns validation fieldErrors for invalid command input', async (c) => {
+ const result = await Command.execute(c.command, {
+ agent: true,
+ argv: [],
+ format: 'json',
+ formatExplicit: false,
+ inputOptions: c.inputOptions,
+ name: 'test',
+ parseMode: c.parseMode,
+ path: 'users',
+ version: undefined,
+ })
+
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ code: 'VALIDATION_ERROR',
+ fieldErrors: [
+ {
+ code: 'invalid_type',
+ missing: false,
+ path: c.path,
+ },
+ ],
+ },
+ })
+ })
+
+ test('does not normalize handler-thrown Zod errors as command input', async () => {
+ const result = await Command.execute(
+ {
+ run() {
+ z.object({ name: z.string() }).parse({ name: 123 })
+ },
+ },
+ {
+ agent: true,
+ argv: [],
+ format: 'json',
+ formatExplicit: false,
+ inputOptions: {},
+ name: 'test',
+ path: 'users',
+ version: undefined,
+ },
+ )
+
+ expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } })
+ expect(result).not.toHaveProperty('error.fieldErrors')
+ })
+})
+
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 }
}
+async function fetchNdjson(cli: Cli.Cli, req: Request) {
+ const res = await cli.fetch(req)
+ const lines = (await res.text())
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line))
+ for (const line of lines)
+ if (line.meta?.duration) {
+ expect(line.meta.duration).toMatch(/^\d+ms$/)
+ line.meta.duration = ''
+ }
+ return { status: res.status, contentType: res.headers.get('content-type'), lines }
+}
+
describe('fetch', () => {
test('GET /health → 200', async () => {
const cli = Cli.create('test')
@@ -4108,6 +4244,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(`
@@ -4292,36 +4601,356 @@ describe('fetch', () => {
return { done: true }
},
})
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
+ },
+ {
+ "data": {
+ "progress": 2,
+ },
+ "type": "chunk",
+ },
+ {
+ "meta": {
+ "command": "stream",
+ "duration": "",
+ },
+ "ok": true,
+ "type": "done",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ })
+
+ test('streaming response preserves returned ok CTA through middleware', async () => {
+ const cli = Cli.create('test')
+ cli.use(async (_c, next) => {
+ await next()
+ })
+ cli.command('stream', {
+ async *run(c) {
+ yield { progress: 1 }
+ return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } })
+ },
+ })
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
+ },
+ {
+ "meta": {
+ "command": "stream",
+ "cta": {
+ "commands": [
+ {
+ "command": "test next",
+ },
+ ],
+ "description": "Next steps:",
+ },
+ "duration": "",
+ },
+ "ok": true,
+ "type": "done",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ })
+
+ test('streaming response handles terminal-only sentinel returns through middleware', async () => {
+ const order: string[] = []
+ const cli = Cli.create('test')
+ cli.use(async (c, next) => {
+ order.push(`before:${c.command}`)
+ await next()
+ order.push(`after:${c.command}`)
+ })
+ const sub = Cli.create('ops')
+ sub.command('ok', {
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
+ async *run(c) {
+ return c.ok(
+ { ignored: true },
+ { cta: { commands: [{ command: 'next', description: 'Continue' }] } },
+ )
+ },
+ })
+ sub.command('fail', {
+ // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding.
+ async *run(c) {
+ return c.error({
+ code: 'EMPTY_FAIL',
+ cta: { commands: ['retry'], description: 'Recover with:' },
+ message: 'failed before chunks',
+ retryable: true,
+ })
+ },
+ })
+ cli.command(sub)
+
+ const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok'))
+ expect(ok).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "meta": {
+ "command": "ops ok",
+ "cta": {
+ "commands": [
+ {
+ "command": "test next",
+ "description": "Continue",
+ },
+ ],
+ "description": "Suggested command:",
+ },
+ "duration": "",
+ },
+ "ok": true,
+ "type": "done",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ expect(ok.lines[0]).not.toHaveProperty('data')
+
+ expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "error": {
+ "code": "EMPTY_FAIL",
+ "message": "failed before chunks",
+ "retryable": true,
+ },
+ "meta": {
+ "command": "ops fail",
+ "cta": {
+ "commands": [
+ {
+ "command": "test retry",
+ },
+ ],
+ "description": "Recover with:",
+ },
+ "duration": "",
+ },
+ "ok": false,
+ "type": "error",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail'])
+ })
+
+ test('streaming response represents returned error as terminal error', async () => {
+ const cli = Cli.create('test')
+ cli.command('stream', {
+ async *run(c) {
+ yield { progress: 1 }
+ return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true })
+ },
+ })
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
+ },
+ {
+ "error": {
+ "code": "STREAM_FAIL",
+ "message": "failed late",
+ "retryable": true,
+ },
+ "meta": {
+ "command": "stream",
+ "duration": "",
+ },
+ "ok": false,
+ "type": "error",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ })
+
+ test('streaming response represents yielded error as terminal error', async () => {
+ let closed = false
+ const cli = Cli.create('test')
+ cli.command('stream', {
+ async *run(c) {
+ try {
+ yield { progress: 1 }
+ yield c.error({ code: 'STREAM_FAIL', message: 'failed now' })
+ yield { progress: 2 }
+ } finally {
+ closed = true
+ }
+ },
+ })
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
+ },
+ {
+ "error": {
+ "code": "STREAM_FAIL",
+ "message": "failed now",
+ },
+ "meta": {
+ "command": "stream",
+ "duration": "",
+ },
+ "ok": false,
+ "type": "error",
+ },
+ ],
+ "status": 200,
+ }
+ `)
+ expect(closed).toBe(true)
+ })
+
+ test('streaming response cancellation unwinds generator and middleware', async () => {
+ let resolveAfter = () => {}
+ const after = new Promise((resolve) => {
+ resolveAfter = resolve
+ })
+ const order: string[] = []
+ const cli = Cli.create('test')
+ cli.use(async (_c, next) => {
+ order.push('mw:before')
+ await next()
+ order.push('mw:after')
+ resolveAfter()
+ })
+ cli.command('stream', {
+ async *run() {
+ try {
+ order.push('stream:yield')
+ yield { progress: 1 }
+ while (true) yield { progress: 2 }
+ } finally {
+ order.push('stream:finally')
+ }
+ },
+ })
const res = await cli.fetch(new Request('http://localhost/stream'))
- expect(res.status).toBe(200)
- expect(res.headers.get('content-type')).toBe('application/x-ndjson')
- const text = await res.text()
- const lines = text
- .trim()
- .split('\n')
- .map((l) => JSON.parse(l))
- expect(lines).toMatchInlineSnapshot(`
- [
- {
- "data": {
- "progress": 1,
+ const reader = res.body!.getReader()
+ await reader.read()
+ await reader.cancel()
+ await after
+ expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after'])
+ })
+
+ test('streaming response thrown error includes terminal duration metadata', async () => {
+ const cli = Cli.create('test')
+ cli.command('stream', {
+ async *run() {
+ yield { progress: 1 }
+ throw new Error('boom')
+ },
+ })
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
},
- "type": "chunk",
- },
- {
- "data": {
- "progress": 2,
+ {
+ "error": {
+ "code": "UNKNOWN",
+ "message": "boom",
+ },
+ "meta": {
+ "command": "stream",
+ "duration": "",
+ },
+ "ok": false,
+ "type": "error",
},
- "type": "chunk",
- },
- {
- "meta": {
- "command": "stream",
+ ],
+ "status": 200,
+ }
+ `)
+ })
+
+ test('streaming response thrown IncurError preserves code and retryable metadata', async () => {
+ const cli = Cli.create('test')
+ cli.command('stream', {
+ async *run() {
+ yield { progress: 1 }
+ throw new Errors.IncurError({
+ code: 'RATE_LIMITED',
+ message: 'too fast',
+ retryable: true,
+ })
+ },
+ })
+ expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(`
+ {
+ "contentType": "application/x-ndjson",
+ "lines": [
+ {
+ "data": {
+ "progress": 1,
+ },
+ "type": "chunk",
},
- "ok": true,
- "type": "done",
- },
- ]
+ {
+ "error": {
+ "code": "RATE_LIMITED",
+ "message": "too fast",
+ "retryable": true,
+ },
+ "meta": {
+ "command": "stream",
+ "duration": "",
+ },
+ "ok": false,
+ "type": "error",
+ },
+ ],
+ "status": 200,
+ }
`)
})
diff --git a/src/Cli.ts b/src/Cli.ts
index d8efef9..3318c5d 100644
--- a/src/Cli.ts
+++ b/src/Cli.ts
@@ -21,8 +21,11 @@ import {
shells,
} from './internal/command.js'
import * as Command from './internal/command.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 { detectRunner } from './internal/pm.js'
+import * as RuntimeContext from './internal/runtime-context.js'
import type { OneOf } from './internal/types.js'
import * as Mcp from './Mcp.js'
import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from './middleware.js'
@@ -75,17 +78,17 @@ export type Cli<
env
>
/** 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>
}
/** A short description of the CLI. */
description?: string | undefined
@@ -217,15 +220,22 @@ export function create(
const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0')
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 = {
@@ -240,23 +250,35 @@ 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,
- })
- commands.set(nameOrCli, {
- _group: true,
- description: def.description,
- commands: generated as Map,
- ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
- } as InternalGroup)
- })(),
- )
+ }),
+ )
+ } 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,
+ }),
+ )
+ })(),
+ )
return cli
}
commands.set(nameOrCli, {
@@ -339,7 +361,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)
toMiddlewares.set(cli, middlewares)
toCommands.set(cli, commands)
return cli
@@ -510,7 +535,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
}
@@ -713,7 +738,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
}
@@ -760,7 +788,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)
@@ -816,13 +844,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)
@@ -851,7 +879,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
}
@@ -900,14 +931,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)
@@ -1097,14 +1128,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)
- writeln(Formatter.format(result, format))
+ const format = formatExplicit ? formatFlag : Formatter.defaultFormat
+ writeln(Formatter.format(buildCommandSchema(resolved.command) ?? {}, format))
return
}
@@ -1121,9 +1146,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
// Fall back to root fetch/command when no subcommand matches,
// but only if the token doesn't look like a typo of a known command.
@@ -1673,6 +1700,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)
@@ -1708,8 +1841,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 ? (yamlParse(fmMatch[1]!) as Record) : {}
+ const meta = parseSkillFrontmatter(f.content)
return {
name: f.dir || name,
description: meta.description ?? '',
@@ -1854,24 +1986,61 @@ async function executeCommand(
// Streaming path — async generator → NDJSON response
if ('stream' in result) {
+ const iterator = result.stream
+ const encoder = new TextEncoder()
+ const meta = (cta?: FormattedCtaBlock | undefined) => ({
+ command: path,
+ duration: `${Math.round(performance.now() - start)}ms`,
+ ...(cta ? { cta } : undefined),
+ })
+ const errorRecord = (err: ErrorResult) => ({
+ type: 'error',
+ ok: false,
+ error: {
+ code: err.code,
+ message: err.message,
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
+ },
+ meta: meta(formatCtaBlock(options.name ?? path, err.cta)),
+ })
const stream = new ReadableStream({
- async start(controller) {
- const encoder = new TextEncoder()
+ async cancel() {
+ await iterator.return(undefined)
+ },
+ async pull(controller) {
try {
- for await (const value of result.stream) {
+ const { value, done } = await iterator.next()
+ if (done) {
+ if (isSentinel(value) && value[sentinel] === 'error') {
+ controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n'))
+ controller.close()
+ return
+ }
+ const cta =
+ isSentinel(value) && value[sentinel] === 'ok'
+ ? formatCtaBlock(options.name ?? path, value.cta)
+ : undefined
controller.enqueue(
- encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'),
+ encoder.encode(
+ JSON.stringify({
+ type: 'done',
+ ok: true,
+ meta: meta(cta),
+ }) + '\n',
+ ),
)
+ controller.close()
+ return
}
- controller.enqueue(
- encoder.encode(
- JSON.stringify({
- type: 'done',
- ok: true,
- meta: { command: path },
- }) + '\n',
- ),
- )
+
+ if (isSentinel(value) && value[sentinel] === 'error') {
+ controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n'))
+ await iterator.return(undefined)
+ controller.close()
+ return
+ }
+
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'))
} catch (error) {
controller.enqueue(
encoder.encode(
@@ -1879,14 +2048,16 @@ async function executeCommand(
type: 'error',
ok: false,
error: {
- code: 'UNKNOWN',
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
message: error instanceof Error ? error.message : String(error),
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
},
+ meta: meta(),
}) + '\n',
),
)
+ controller.close()
}
- controller.close()
},
})
return new Response(stream, {
@@ -2146,7 +2317,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
@@ -2447,8 +2618,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
@@ -2463,7 +2634,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
@@ -2472,7 +2643,7 @@ type InternalGroup = {
}
/** @internal A fetch gateway entry. */
-type InternalFetchGateway = {
+export type InternalFetchGateway = {
_fetch: true
basePath?: string | undefined
description?: string | undefined
@@ -2497,30 +2668,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 {
@@ -2532,7 +2707,7 @@ function resolveAlias(
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>()
@@ -2546,6 +2721,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 }
+>()
+
+/** @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()
+
/** @internal Maps root CLI instances to their command aliases. */
const toRootAliases = new WeakMap()
@@ -2633,7 +2828,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
@@ -2719,6 +2914,7 @@ async function handleStreaming(
error: {
code: error instanceof IncurError ? error.code : 'UNKNOWN',
message: error instanceof Error ? error.message : String(error),
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
},
}),
)
@@ -2802,6 +2998,7 @@ async function handleStreaming(
error: {
code: error instanceof IncurError ? error.code : 'UNKNOWN',
message: error instanceof Error ? error.message : String(error),
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
},
meta: {
command: ctx.path,
@@ -2839,7 +3036,7 @@ function formatCta(name: string, cta: Cta): FormattedCta {
}
/** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */
-function buildIndexManifest(commands: Map, prefix: string[] = []) {
+export function buildIndexManifest(commands: Map, prefix: string[] = []) {
return {
version: 'incur.v1',
commands: collectIndexCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)),
@@ -2869,7 +3066,7 @@ function collectIndexCommands(
}
/** @internal Builds the `--llms` manifest from the command tree. */
-function buildManifest(commands: Map, prefix: string[] = []) {
+export function buildManifest(commands: Map, prefix: string[] = []) {
return {
version: 'incur.v1',
commands: collectCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)),
@@ -2900,14 +3097,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)
@@ -2925,11 +3121,11 @@ function collectCommands(
}
/** @internal Recursively collects leaf commands as `Skill.CommandInfo` for `--llms --format md`. */
-function collectSkillCommands(
+export function collectSkillCommands(
commands: Map,
prefix: string[],
groups: Map,
- rootCommand?: CommandDefinition | undefined,
+ rootCommand?: SkillCommandSource | undefined,
): Skill.CommandInfo[] {
const result: Skill.CommandInfo[] = []
if (rootCommand) {
@@ -2977,6 +3173,11 @@ function collectSkillCommands(
return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
}
+type SkillCommandSource = Pick<
+ CommandDefinition,
+ 'args' | 'description' | 'env' | 'examples' | 'hint' | 'options' | 'output'
+>
+
/** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */
export function formatExamples(
examples: Example[] | undefined,
@@ -2993,32 +3194,49 @@ export function formatExamples(
})
}
-/** @internal Builds separate args, env, and options JSON Schemas. */
-function buildInputSchema(
- args: z.ZodObject | undefined,
- env: z.ZodObject | undefined,
- options: z.ZodObject | undefined,
+/** @internal Parses YAML frontmatter from generated skill Markdown. */
+export function parseSkillFrontmatter(content: string): {
+ description?: string | undefined
+ name?: string | undefined
+} {
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ if (!match) return {}
+ const meta = yamlParse(match[1]!)
+ if (!meta || typeof meta !== 'object') return {}
+ return meta as { description?: string | undefined; name?: string | 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,
> = {
@@ -3031,7 +3249,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,
> = {
@@ -3107,7 +3325,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 21bfbdd..2685d8f 100644
--- a/src/Formatter.ts
+++ b/src/Formatter.ts
@@ -4,8 +4,11 @@ import { stringify as yamlStringify } from 'yaml'
/** 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 9bdf996..6b9a732 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 0a862d8..44c593f 100644
--- a/src/Openapi.ts
+++ b/src/Openapi.ts
@@ -26,6 +26,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. */
@@ -96,6 +134,7 @@ type GeneratedCommand = {
args?: z.ZodObject | undefined
description?: string | undefined
options?: z.ZodObject | undefined
+ output?: z.ZodType | undefined
run: (context: any) => any
}
@@ -337,6 +376,15 @@ export async function generateCommands(
fetch: FetchHandler,
options: generateCommands.Options = {},
): Promise