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 8d7c658..84c94ed 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"examples",
"dist",
"src",
+ "skills",
"SKILL.md"
],
"dependencies": {
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/Typegen.test.ts b/src/Typegen.test.ts
index 466cd59..e34640c 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: Commands
+ }
+ }
+
+ declare module 'incur/client' {
interface Register {
- commands: {
- "pr create": { args: { title: string }; options: {} }
- "pr list": { args: {}; options: { state: string } }
- }
+ 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: {
- "pr review approve": { args: { id: number }; options: {} }
- }
+ commands: Commands
+ }
+ }
+
+ declare module 'incur/client' {
+ interface Register {
+ commands: Commands
}
}
"
@@ -157,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'])
})
@@ -223,12 +255,20 @@ 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
}
}
"
@@ -245,6 +285,7 @@ describe('fromCli', () => {
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', () => {
diff --git a/src/Typegen.ts b/src/Typegen.ts
index 3d92af7..0903fe6 100644
--- a/src/Typegen.ts
+++ b/src/Typegen.ts
@@ -15,14 +15,29 @@ export async function generate(input: string, output: string): Promise {
export function fromCli(cli: Cli.Cli): string {
const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli))
- const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {']
+ const lines: string[] = ['export type Commands = {']
for (const { id, command } of entries)
lines.push(
- ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`,
+ ` ${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')
}
diff --git a/src/bin.ts b/src/bin.ts
index f53b6e7..63c8835 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 0000000..aa7fd5a
--- /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 0000000..29e91f4
--- /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/HttpClient.test-d.ts b/src/client/HttpClient.test-d.ts
new file mode 100644
index 0000000..fa48e91
--- /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 0000000..7f61e73
--- /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 0000000..94dfe11
--- /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 0000000..eb37f6b
--- /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/MemoryClient.test-d.ts b/src/client/MemoryClient.test-d.ts
new file mode 100644
index 0000000..bc41db4
--- /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 0000000..d10539f
--- /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 0000000..7177b8f
--- /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 0000000..818d7c4
--- /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
index 62fc641..8bec9ac 100644
--- a/src/client/Resources.ts
+++ b/src/client/Resources.ts
@@ -1,4 +1,17 @@
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 =
@@ -15,3 +28,106 @@ export type Request =
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/Run.test-d.ts b/src/client/Run.test-d.ts
new file mode 100644
index 0000000..5cc7bcb
--- /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 0000000..70d466a
--- /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
+ : input[key]
+ }
+
+/** Rejects keys outside expected `args` or `options` objects. */
+export type StrictField =
+ IsUnknown extends true
+ ? value
+ : NonNullable extends object
+ ? value & { [key in Exclude>]: never }
+ : value
+
+/** Returns true when a type is exactly unknown. */
+export type IsUnknown = unknown extends type
+ ? [keyof type] extends [never]
+ ? true
+ : false
+ : false
+
+/** Effective output type after selection controls. */
+export type EffectiveOutput