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> { + return generateCommandsSync(spec, fetch, options) +} + +/** Synchronously generates incur command entries from an already-loaded OpenAPI spec. */ +export function generateCommandsSync( + spec: OpenAPISpec, + fetch: FetchHandler, + options: generateCommands.Options = {}, +): Map { const resolved = dereference(structuredClone(spec)) as OpenAPISpec const commands = new Map() const paths = (resolved.paths ?? {}) as Record> @@ -360,6 +408,7 @@ export async function generateCommands( const bodySchema = op.requestBody?.content?.['application/json']?.schema const bodyProps = (bodySchema?.properties ?? {}) as Record> const bodyRequired = new Set((bodySchema?.required as string[]) ?? []) + const outputSchema = responseSchema(op.responses) // Build args Zod schema from path params let argsSchema: z.ZodObject | undefined @@ -393,6 +442,7 @@ export async function generateCommands( description: op.summary ?? op.description, args: argsSchema, options: optionsSchema, + ...(outputSchema ? { output: toZod(outputSchema) } : undefined), run: createHandler({ basePath: options.basePath, fetch, @@ -658,3 +708,15 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType { const desc = (schema as any).description ?? (inner as any).description return desc ? coerced.describe(desc) : coerced } + +function responseSchema(responses: Record | undefined) { + if (!responses) return undefined + const entries = Object.entries(responses) + const preferred = + entries.find(([status]) => status === '200') ?? + entries.find(([status]) => /^2\d\d$/.test(status)) + const response = preferred?.[1] as + | { content?: Record | undefined }> | undefined } + | undefined + return response?.content?.['application/json']?.schema +} diff --git a/src/Parser.ts b/src/Parser.ts index ea21a75..3a601ad 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -257,7 +257,7 @@ function setOption( } /** Wraps zod schema.parse(), converting ZodError to ValidationError. */ -function zodParse(schema: z.ZodObject, data: Record) { +export function zodParse(schema: z.ZodObject, data: Record) { try { return schema.parse(data) } catch (err: any) { diff --git a/src/Skillgen.test.ts b/src/Skillgen.test.ts index 49ae419..6e1df8f 100644 --- a/src/Skillgen.test.ts +++ b/src/Skillgen.test.ts @@ -60,13 +60,20 @@ test('collects group descriptions', async () => { test('includes args, options, and examples in output', async () => { const cli = Cli.create('tool', { description: 'A tool', - }).command('greet', { - description: 'Greet someone', - args: z.object({ name: z.string().describe('Name to greet') }), - options: z.object({ loud: z.boolean().default(false).describe('Shout') }), - examples: [{ args: { name: 'world' }, description: 'Greet the world' }], - run: () => ({}), }) + .command('greet', { + description: 'Greet someone', + aliases: ['hi'], + args: z.object({ name: z.string().describe('Name to greet') }), + options: z.object({ loud: z.boolean().default(false).describe('Shout') }), + output: z.object({ message: z.string() }), + examples: [{ args: { name: 'world' }, description: 'Greet the world' }], + run: () => ({ message: 'hi' }), + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) vi.mocked(importCli).mockResolvedValue(cli) const files = await generate('fake-input', tmp, 0) @@ -74,4 +81,7 @@ test('includes args, options, and examples in output', async () => { expect(content).toContain('Name to greet') expect(content).toContain('Shout') expect(content).toContain('Greet the world') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toContain('# tool hi') }) diff --git a/src/Skillgen.ts b/src/Skillgen.ts index 844e52c..3dd2e73 100644 --- a/src/Skillgen.ts +++ b/src/Skillgen.ts @@ -13,7 +13,12 @@ export async function generate(input: string, output: string, depth = 1): Promis const groups = new Map() if (cli.description) groups.set(cli.name, cli.description) - const entries = collectEntries(commands, [], groups) + const entries = Cli.collectSkillCommands( + commands, + [], + groups, + Cli.toRootDefinition.get(cli as unknown as Cli.Root), + ) const files = Skill.split(cli.name, entries, depth, groups) if (depth > 0) await fs.rm(output, { recursive: true, force: true }) @@ -30,37 +35,3 @@ export async function generate(input: string, output: string, depth = 1): Promis return written } - -/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(path.join(' '), entry.description) - result.push(...collectEntries(entry.commands, path, groups)) - } else { - const cmd: Skill.CommandInfo = { name: path.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = Cli.formatExamples(entry.examples) - if (examples) { - const cmdName = path.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 530be61..8f64d23 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -1,4 +1,4 @@ -import { Cli, SyncSkills } from 'incur' +import { Cli, SyncSkills, z } from 'incur' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -288,6 +288,44 @@ test('list includes root command skill', async () => { expect(names).toContain('test-ping') }) +test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => { + const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const cli = Cli.create('tool') + .command('real', { + description: 'Real command', + aliases: ['r'], + options: z.object({ dryRun: z.boolean().default(false) }), + output: z.object({ value: z.string() }), + examples: [{ options: { dryRun: true }, description: 'Preview' }], + run: () => ({ value: 'ok' }), + }) + .command('api', { description: 'Raw API', fetch: () => new Response('{}') }) + + const commands = Cli.toCommands.get(cli)! + const listed = await SyncSkills.list('tool', commands) + const names = listed.map((skill) => skill.name) + expect(names).toContain('tool-api') + expect(names).toContain('tool-real') + expect(names).not.toContain('tool-r') + + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + const synced = await SyncSkills.sync('tool', commands, { + depth: 0, + global: false, + cwd: installDir, + }) + const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8') + expect(content).toContain('Preview') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toMatch(/^# tool r$/m) + + rmSync(tmp, { recursive: true, force: true }) +}) + test('list results are sorted alphabetically', async () => { const cli = Cli.create('test') cli.command('zebra', { description: 'Z command', run: () => ({}) }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 037c350..3317c26 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -2,9 +2,8 @@ import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { parse as yamlParse } from 'yaml' -import { formatExamples } from './Cli.js' +import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js' import * as Agents from './internal/agents.js' import * as Skill from './Skill.js' @@ -19,7 +18,7 @@ export async function sync( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`)) @@ -31,7 +30,7 @@ export async function sync( : path.join(tmpDir, 'SKILL.md') await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${file.content}\n`) - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) skills.push({ name: meta.name ?? (file.dir || name), description: meta.description }) } @@ -42,7 +41,7 @@ export async function sync( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) const dest = path.join(tmpDir, skillName, 'SKILL.md') @@ -68,7 +67,7 @@ export async function sync( } // Write skills hash + names for staleness detection - const hashEntries = collectEntries(commands, [], undefined, options.rootCommand) + const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand) writeMeta( name, Skill.hash(hashEntries), @@ -139,14 +138,14 @@ export async function list( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const skills: list.Skill[] = [] const installed = readInstalledSkills(name, { cwd }) for (const file of files) { - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) const skillName = meta.name ?? (file.dir || name) skills.push({ name: skillName, @@ -162,7 +161,7 @@ export async function list( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) if (!skills.some((s) => s.name === skillName)) { @@ -223,75 +222,6 @@ export declare namespace list { } } -/** Recursively collects leaf commands as `Skill.CommandInfo`. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), - rootCommand?: - | { - description?: string | undefined - args?: any - env?: any - hint?: string | undefined - options?: any - output?: any - examples?: any[] | undefined - } - | undefined, -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - if (rootCommand) { - const cmd: Skill.CommandInfo = {} - if (rootCommand.description) cmd.description = rootCommand.description - if (rootCommand.args) cmd.args = rootCommand.args - if (rootCommand.env) cmd.env = rootCommand.env - if (rootCommand.hint) cmd.hint = rootCommand.hint - if (rootCommand.options) cmd.options = rootCommand.options - if (rootCommand.output) cmd.output = rootCommand.output - const examples = formatExamples(rootCommand.examples) - if (examples) cmd.examples = examples - result.push(cmd) - } - for (const [name, entry] of commands) { - const entryPath = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(entryPath.join(' '), entry.description) - result.push(...collectEntries(entry.commands, entryPath, groups)) - } else { - const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = formatExamples(entry.examples) - if (examples) { - const cmdName = entryPath.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} - -function parseFrontmatter(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 } -} - /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */ function resolvePackageRoot(): string { const bin = process.argv[1] diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..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: { - 'pr create': { args: { title: string }; options: {} } - 'pr list': { args: {}; options: { state: string } } - } + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands } } " @@ -77,11 +101,19 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + "pr review approve": { args: { id: number }; options: {} } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { interface Register { - commands: { - 'pr review approve': { args: { id: number }; options: {} } - } + commands: Commands } } " @@ -118,6 +150,38 @@ describe('fromCli', () => { expect(output).toContain('tags: string[]') }) + test('emits scalar and array output schemas', () => { + const cli = Cli.create('test') + .command('read', { + output: z.string(), + run: () => 'content', + }) + .command('list', { + output: z.array(z.object({ id: z.string(), active: z.boolean() })), + run: () => [{ id: 'one', active: true }], + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('read: { args: {}; options: {}; output: string }') + expect(output).toContain( + 'list: { args: {}; options: {}; output: { id: string; active: boolean }[] }', + ) + }) + + test('marks async generator commands as streams', () => { + const cli = Cli.create('test').command('tail', { + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'ok' } + }, + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain( + 'tail: { args: {}; options: {}; output: { line: string }; stream: true }', + ) + }) + test('commands are sorted alphabetically', () => { const cli = Cli.create('test') .command('zebra', { run: () => ({}) }) @@ -125,7 +189,7 @@ describe('fromCli', () => { .command('middle', { run: () => ({}) }) const output = Typegen.fromCli(cli) - const commandOrder = [...output.matchAll(/^ {6}'(\w+)':/gm)].map((m) => m[1]) + const commandOrder = [...output.matchAll(/^ {2}(\w+):/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -169,7 +233,7 @@ describe('fromCli', () => { expect(output).toContain('config: { host: string; port: number }') }) - test('optional properties use optional modifier', () => { + test('optional properties include undefined for exact optional property types', () => { const cli = Cli.create('test').command('create', { args: z.object({ name: z.string() }), options: z.object({ @@ -180,7 +244,7 @@ describe('fromCli', () => { }) const output = Typegen.fromCli(cli) - expect(output).toContain('verbose?: boolean') + expect(output).toContain('verbose?: boolean | undefined') expect(output).toContain('output: string') }) @@ -191,15 +255,53 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + ping: { args: {}; options: {} } + "pr list": { args: {}; options: {} } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { interface Register { - commands: { - 'ping': { args: {}; options: {} } - 'pr list': { args: {}; options: {} } - } + commands: Commands } } " `) }) + + test('includes root commands and excludes raw fetch gateways', () => { + const cli = Cli.create('status', { + run: () => ({ ok: true }), + }).command('raw', { + fetch: () => new Response('{}'), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('status: { args: {}; options: {} }') + expect(output).not.toContain("'raw'") + expect(output).toContain("declare module 'incur/client'") + }) + + test('escapes command and property keys', () => { + const cli = Cli.create('test').command('bad key "quoted"', { + options: z.object({ + 'bad-key': z.string().optional(), + 'quote"key': z.number(), + nested: z.object({ 'child-key': z.string().optional() }), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"bad key \\"quoted\\""') + expect(output).toContain('"bad-key"?: string | undefined') + expect(output).toContain('"quote\\"key": number') + expect(output).toContain('nested: { "child-key"?: string | undefined }') + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..0903fe6 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import { z } from 'zod' import * as Cli from './Cli.js' +import * as RuntimeContext from './internal/runtime-context.js' import { importCli } from './internal/utils.js' /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ @@ -12,48 +13,45 @@ export async function generate(input: string, output: string): Promise { /** Generates a `.d.ts` declaration string for the `incur` module augmentation. */ export function fromCli(cli: Cli.Cli): string { - const commands = Cli.toCommands.get(cli) - if (!commands) throw new Error('No commands registered on this CLI instance') + const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli)) - const entries = collectEntries(commands, []) + const lines: string[] = ['export type Commands = {'] - const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] - - for (const { name, args, options } of entries) + for (const { id, command } of entries) lines.push( - ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) - lines.push(' }', ' }', '}', '') + lines.push( + '}', + '', + "declare module 'incur' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + "declare module 'incur/client' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + ) return lines.join('\n') } -/** Recursively collects leaf commands with their full paths and schemas. */ -function collectEntries( - commands: Map, - prefix: string[], -): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] { - const result: ReturnType = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) - else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) - } - return result.sort((a, b) => a.name.localeCompare(b.name)) -} - /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function schemaToType(schema: z.ZodObject | undefined): string { +function objectSchemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' + return schemaToType(schema) +} + +/** Converts a Zod schema to a TypeScript type string. */ +function schemaToType(schema: z.ZodType): string { const json = z.toJSONSchema(schema) as Record const defs = (json.$defs ?? {}) as Record> - const properties = json.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' - const required = new Set((json.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) - return `{ ${entries.join('; ')} }` + return resolveType(json, defs) } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ @@ -98,12 +96,22 @@ function resolveType( const properties = schema.properties as Record> | undefined if (!properties || Object.keys(properties).length === 0) return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) + const entries = Object.entries(properties).map(([key, value]) => { + const type = resolveType(value, defs) + if (required.has(key)) return `${propertyKey(key)}: ${type}` + return `${propertyKey(key)}?: ${type} | undefined` + }) return `{ ${entries.join('; ')} }` } default: return 'unknown' } } + +function propertyKey(key: string) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +} + +function isStream(command: Cli.CommandDefinition) { + return command.run.constructor.name === 'AsyncGeneratorFunction' +} diff --git a/src/bin.ts b/src/bin.ts index 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/ClientError.ts b/src/client/ClientError.ts new file mode 100644 index 0000000..3765181 --- /dev/null +++ b/src/client/ClientError.ts @@ -0,0 +1,52 @@ +import { BaseError } from '../Errors.js' +import type * as Rpc from './Rpc.js' + +/** Error thrown by client transports. */ +export class ClientError extends BaseError { + override name = 'Incur.ClientError' + /** Machine-readable error code. */ + code: string | undefined + /** Full error envelope or diagnostic payload. */ + data: unknown | undefined + /** RPC error object. */ + error: Rpc.Error | undefined + /** Field validation errors. */ + fieldErrors: Rpc.Error['fieldErrors'] | undefined + /** Response metadata. */ + meta: Rpc.Meta | undefined + /** Whether the operation can be retried. */ + retryable: boolean | undefined + /** HTTP status when available. */ + status: number | undefined + + constructor(message: string, options: ClientError.Options = {}) { + super(message, options.cause ? { cause: options.cause } : undefined) + this.code = options.code + this.data = options.data + this.error = options.error + this.fieldErrors = options.fieldErrors + this.meta = options.meta + this.retryable = options.retryable + this.status = options.status + } +} + +export declare namespace ClientError { + /** Client error constructor options. */ + type Options = BaseError.Options & { + /** Machine-readable error code. */ + code?: string | undefined + /** Full error envelope or diagnostic payload. */ + data?: unknown | undefined + /** RPC error object. */ + error?: Rpc.Error | undefined + /** Field validation errors. */ + fieldErrors?: Rpc.Error['fieldErrors'] | undefined + /** Response metadata. */ + meta?: Rpc.Meta | undefined + /** Whether the operation can be retried. */ + retryable?: boolean | undefined + /** HTTP status when available. */ + status?: number | undefined + } +} diff --git a/src/client/HttpClient.test-d.ts b/src/client/HttpClient.test-d.ts new file mode 100644 index 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/Local.ts b/src/client/Local.ts new file mode 100644 index 0000000..265ead2 --- /dev/null +++ b/src/client/Local.ts @@ -0,0 +1,54 @@ +import type * as SyncMcp from '../SyncMcp.js' +import type * as SyncSkills from '../SyncSkills.js' + +/** Options for `local.skills.add()`. */ +export type SkillsAddOptions = { + /** Grouping depth. */ + depth?: number | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Options for `local.skills.list()`. */ +export type SkillsListOptions = { + /** Grouping depth. */ + depth?: number | undefined +} + +/** Options for `local.mcp.add()`. */ +export type McpAddOptions = { + /** Target agents. */ + agents?: string[] | undefined + /** Command agents should run. */ + command?: string | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Synced skills result. */ +export type SyncedSkills = SyncSkills.sync.Result + +/** Skills list result. */ +export type SkillsList = { + /** Listed skills. */ + skills: SyncSkills.list.Skill[] +} + +/** MCP registration result. */ +export type McpRegistration = SyncMcp.register.Result + +/** Memory-only local methods exposed by memory transports and clients. */ +export type Methods = { + /** Skill setup actions. */ + skills: { + /** Sync generated skill files. */ + add(options?: SkillsAddOptions | undefined): Promise + /** List generated skill files without writing them. */ + list(options?: SkillsListOptions | undefined): Promise + } + /** MCP setup actions. */ + mcp: { + /** Register the CLI as an MCP server. */ + add(options?: McpAddOptions | undefined): Promise + } +} diff --git a/src/client/MemoryClient.test-d.ts b/src/client/MemoryClient.test-d.ts new file mode 100644 index 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 new file mode 100644 index 0000000..8bec9ac --- /dev/null +++ b/src/client/Resources.ts @@ -0,0 +1,133 @@ +import type * as Formatter from '../Formatter.js' +import type * as Client from './Client.js' + +/** Resources format. */ +export type Format = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' + +/** Resources result for a structured type and format option. */ +export type Result = [format] extends [undefined] + ? structured + : [format] extends ['json'] + ? structured + : undefined extends format + ? structured | string + : string + +/** Resource request accepted by `transport.discover()`. */ +export type Request = + | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'schema'; command?: string | undefined } + | { resource: 'help'; command?: string | undefined } + | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined } + | { resource: 'skillsIndex' } + | { resource: 'skill'; name: string } + | { resource: 'mcpTools' } + +/** Resource response returned by `transport.discover()`. */ +export type Response = + | { contentType: string; body: string } + | { contentType: string; data: unknown } + +/** LLM manifest. */ +export type LlmsManifest< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = { + /** Manifest version. */ + version: string + /** Available commands. */ + commands: LlmsCommand[] +} + +/** Full LLM manifest. */ +export type LlmsFullManifest< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = LlmsManifest + +/** LLM command entry. */ +export type LlmsCommand< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = { + /** Command name. */ + name: scope extends undefined + ? Client.CommandId + : Extract, `${scope}` | `${scope} ${string}`> + /** Command description. */ + description?: string | undefined + /** Command schemas. */ + schema?: CommandSchema> | undefined +} + +/** JSON-ish command schema. */ +export type CommandSchema<_commands = Client.Commands, _command extends string = string> = Record< + string, + unknown +> & { + /** Args schema. */ + args?: Record | undefined + /** Options schema. */ + options?: Record | undefined + /** Env schema. */ + env?: Record | undefined + /** Output schema. */ + output?: Record | undefined +} + +/** OpenAPI document. */ +export type OpenApiDocument = Record & { + /** OpenAPI version. */ + openapi?: string | undefined + /** OpenAPI info object. */ + info?: Record | undefined +} + +/** Skills index. */ +export type SkillsIndex = { + /** Generated skills. */ + skills: { name: string; description: string; files: string[] }[] +} + +/** MCP tool descriptor response. */ +export type McpToolsResponse<_commands = Client.Commands> = { + /** MCP tools. */ + tools: Record[] +} + +/** Resources action set. */ +export type Actions = { + llms: LlmsAction + llmsFull: LlmsFullAction + schema(command?: Client.CommandScope | undefined): Promise> + help(command?: Client.CommandScope | undefined): Promise + openapi(): Promise + skills: { + index(): Promise + get(name: string): Promise + } + mcp: { + tools(): Promise> + } +} + +/** Compact LLM resources action. */ +export type LlmsAction = { + < + const scope extends Client.CommandScope | undefined = undefined, + const format extends Format | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} + +/** Full LLM resources action. */ +export type LlmsFullAction = { + < + const scope extends Client.CommandScope | undefined = undefined, + const format extends Format | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} diff --git a/src/client/Rpc.ts b/src/client/Rpc.ts new file mode 100644 index 0000000..2d376a9 --- /dev/null +++ b/src/client/Rpc.ts @@ -0,0 +1,89 @@ +import type { FieldError } from '../Errors.js' +import type * as Formatter from '../Formatter.js' + +/** RPC request accepted by `transport.request()`. */ +export type Request = { + /** Canonical command ID. */ + command: string + /** Structured positional arguments. */ + args?: Record | undefined + /** Structured named options. */ + options?: Record | undefined + /** Output format for rendered text. */ + outputFormat?: Formatter.Format | undefined + /** Output selection paths. */ + selection?: string[] | undefined + /** Whether token metadata should be included. */ + outputTokenCount?: boolean | undefined + /** Maximum rendered output tokens to return. */ + outputTokenLimit?: number | undefined + /** Rendered output token offset. */ + outputTokenOffset?: number | undefined +} + +/** Rendered output payload. */ +export type Output = { + /** Rendered output text. */ + text: string + /** Rendered format. */ + format?: Formatter.Format | undefined + /** Offset to request for the next token window. */ + nextOffset?: number | undefined + /** Rendered token count before truncation. */ + tokenCount?: number | undefined + /** Requested token limit. */ + tokenLimit?: number | undefined + /** Requested token offset. */ + tokenOffset?: number | undefined + /** Whether text was truncated by token controls. */ + truncated?: boolean | undefined +} + +/** RPC response metadata. */ +export type Meta = { + /** Canonical command ID. */ + command: string + /** Suggested next commands. */ + cta?: unknown | undefined + /** Wall-clock duration. */ + duration: string +} + +/** Full RPC success/error envelope. */ +export type Envelope = + | { + ok: true + data: unknown + output?: Output | undefined + meta: Meta + } + | { + ok: false + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + } + meta: Meta + /** HTTP status when the response came from an HTTP transport. */ + status?: number | undefined + } + +/** RPC error object. */ +export type Error = Extract['error'] + +/** Non-streaming RPC response. */ +export type Response = Envelope + +/** Streaming RPC record. */ +export type StreamRecord = + | { type: 'chunk'; data: unknown } + | ({ type: 'done' } & Extract) + | ({ type: 'error' } & Extract) + +/** Streaming RPC response. */ +export type StreamResponse = { + stream: true + records(): AsyncGenerator +} diff --git a/src/client/Run.test-d.ts b/src/client/Run.test-d.ts new file mode 100644 index 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 = [selection] extends [undefined] ? output : unknown + +/** Effective run output type after input/default selection controls. */ +export type EffectiveRunOutput = EffectiveOutput< + output, + input extends { selection: infer selection } + ? selection + : defaults extends { selection: infer selection } + ? selection + : undefined +> + +/** Run return type. */ +export type Return< + commands, + command extends Client.CommandId, + input extends Input | undefined, + defaults extends Client.Defaults, +> = commands[command] extends { stream: true } + ? StreamResponse, input, defaults>, unknown, commands> + : Result, input, defaults>, commands> + +/** Run action set. */ +export type Actions = { + run< + const command extends Client.CommandId, + const input extends Input | undefined = undefined, + >( + command: command, + ...input: InputParameters + ): Promise> +} + +/** Successful non-streaming command result. */ +export type Result = { + /** Success discriminator. */ + ok: true + /** Structured command data. */ + data: data + /** Rendered output text and pagination controls. */ + output?: Output | undefined + /** Command metadata. */ + meta: Meta +} + +/** Rendered command output. */ +export type Output = { + /** Rendered text. */ + text: string + /** Rendered format. */ + format?: Formatter.Format | undefined + /** Full rendered token count. */ + tokenCount?: number | undefined + /** Requested token limit. */ + tokenLimit?: number | undefined + /** Requested token offset. */ + tokenOffset?: number | undefined + /** Fetches the next output page for the same command. */ + next?: (() => Promise>) | undefined +} + +/** Client metadata. */ +export type Meta = { + /** Canonical command id. */ + command: string + /** Wall-clock duration. */ + duration: string + /** Normalized call-to-action metadata. */ + cta?: CtaBlock | undefined +} + +/** CTA block. */ +export type CtaBlock = { + /** CTA block description. */ + description?: string | undefined + /** CTA commands. */ + commands: Cta[] +} + +/** CTA command. */ +export type Cta = { + /** Suggested command id. */ + command: string + /** CLI-ready command text. */ + cliCommand: string + /** CTA description. */ + description?: string | undefined + /** Structured args when provided by the server. */ + args?: Record | undefined + /** Structured options when provided by the server. */ + options?: Record | undefined + /** Raw source CTA. */ + raw: unknown + /** Runs the suggested command. Invalid suggestions fail like normal client runs. */ + run( + options?: options, + ): Promise< + Result< + EffectiveOutput< + unknown, + options extends { selection: infer selection } ? selection : undefined + >, + commands + > + > +} + +/** Stream response wrapper. */ +export type StreamResponse< + chunk, + finalData = unknown, + commands = Client.Commands, +> = AsyncIterable & { + /** Terminal stream result. */ + final: Promise> + /** Iterates over chunk and terminal records. */ + records(): AsyncIterable> +} + +/** Successful terminal stream result. */ +export type StreamFinal = { + /** Success discriminator. */ + ok: true + /** Terminal structured data. */ + data?: finalData | undefined + /** Terminal rendered output text. */ + output?: Output | undefined + /** Terminal metadata. */ + meta: Meta +} + +/** Stream output attached to a chunk. */ +export type StreamOutput = { + /** Rendered chunk text. */ + text: string + /** Rendered chunk format. */ + format?: Formatter.Format | undefined +} + +/** Normalized stream record. */ +export type StreamRecord = + | { type: 'chunk'; data: chunk; output?: StreamOutput | undefined } + | { + type: 'done' + ok: true + data?: finalData | undefined + output?: Output | undefined + meta: Meta + } + | { type: 'error'; ok: false; error: Rpc.Error; meta: Meta } diff --git a/src/client/actions/ActionClient.ts b/src/client/actions/ActionClient.ts new file mode 100644 index 0000000..c38f09f --- /dev/null +++ b/src/client/actions/ActionClient.ts @@ -0,0 +1,14 @@ +import type * as Client from '../Client.js' +import type * as Local from '../Local.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' + +/** Client implementation shape used by actions. */ +export type ActionClient = { + defaults: Client.Defaults + transport: { + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + local?: Local.Methods | undefined + } & Client.ResolvedTransport +} diff --git a/src/client/actions/LocalActions.test.ts b/src/client/actions/LocalActions.test.ts new file mode 100644 index 0000000..0d6175c --- /dev/null +++ b/src/client/actions/LocalActions.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import * as Cli from '../../Cli.js' +import * as MemoryClient from '../MemoryClient.js' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + register: vi.fn(), + sync: vi.fn(), +})) + +vi.mock('../../SyncSkills.js', () => ({ + list: mocks.list, + sync: mocks.sync, +})) + +vi.mock('../../SyncMcp.js', () => ({ + register: mocks.register, +})) + +beforeEach(() => { + mocks.list.mockReset() + mocks.register.mockReset() + mocks.sync.mockReset() +}) + +describe('local actions', () => { + test('memory local actions use the real memory client and coexist with resources namespaces', async () => { + const cli = Cli.create('app', { + description: 'App', + mcp: { agents: ['codex'], command: 'pnpm app --mcp' }, + sync: { cwd: '/workspace/app', depth: 2 }, + }).command('deploy', { + description: 'Deploy app', + run: () => ({ ok: true }), + }) + mocks.list.mockResolvedValueOnce([ + { description: 'Deploy app', installed: false, name: 'app-deploy' }, + ]) + mocks.sync.mockResolvedValueOnce({ + agents: [], + paths: ['/workspace/app/.agents/skills/app-deploy'], + skills: [{ description: 'Deploy app', name: 'app-deploy' }], + }) + mocks.register.mockResolvedValueOnce({ agents: ['Codex'], command: 'pnpm app --mcp' }) + const client = MemoryClient.create(cli) + + await expect(client.skills.index()).resolves.toMatchObject({ + skills: [expect.objectContaining({ name: 'deploy' })], + }) + await expect(client.skills.list({ depth: 3 })).resolves.toEqual({ + skills: [{ description: 'Deploy app', installed: false, name: 'app-deploy' }], + }) + await expect(client.skills.add({ depth: 4, global: false })).resolves.toMatchObject({ + skills: [{ description: 'Deploy app', name: 'app-deploy' }], + }) + await expect(client.mcp.add({ agents: ['cursor'], global: false })).resolves.toEqual({ + agents: ['Codex'], + command: 'pnpm app --mcp', + }) + + expect(mocks.list).toHaveBeenCalledWith('app', expect.any(Map), { + cwd: '/workspace/app', + depth: 3, + description: 'App', + include: undefined, + rootCommand: undefined, + }) + expect(mocks.sync).toHaveBeenCalledWith('app', expect.any(Map), { + cwd: '/workspace/app', + depth: 4, + description: 'App', + global: false, + include: undefined, + rootCommand: undefined, + }) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['cursor'], + command: 'pnpm app --mcp', + global: false, + }) + }) +}) diff --git a/src/client/actions/LocalActions.ts b/src/client/actions/LocalActions.ts new file mode 100644 index 0000000..b01e7c1 --- /dev/null +++ b/src/client/actions/LocalActions.ts @@ -0,0 +1,43 @@ +import { ClientError } from '../ClientError.js' +import type * as Local from '../Local.js' +import type { ActionClient } from './ActionClient.js' + +/** Runs memory-local `skills add`. */ +export function skillsAdd(client: ActionClient, options?: Local.SkillsAddOptions | undefined) { + return local(client).skills.add(options) +} + +/** Runs memory-local `skills list`. */ +export function skillsList(client: ActionClient, options?: Local.SkillsListOptions | undefined) { + return local(client).skills.list(options) +} + +/** Runs memory-local `mcp add`. */ +export function mcpAdd(client: ActionClient, options?: Local.McpAddOptions | undefined) { + return local(client).mcp.add(options) +} + +/** Binds memory-local actions to a client. */ +export function actions(client: ActionClient) { + return { + skills: { + add(options?: Local.SkillsAddOptions | undefined) { + return skillsAdd(client, options) + }, + list(options?: Local.SkillsListOptions | undefined) { + return skillsList(client, options) + }, + }, + mcp: { + add(options?: Local.McpAddOptions | undefined) { + return mcpAdd(client, options) + }, + }, + } +} + +function local(client: ActionClient): Local.Methods { + const { local } = client.transport + if (!local) throw new ClientError('Local actions require a memory client.') + return local +} diff --git a/src/client/actions/ResourcesActions.test.ts b/src/client/actions/ResourcesActions.test.ts new file mode 100644 index 0000000..982d6ce --- /dev/null +++ b/src/client/actions/ResourcesActions.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test, vi } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import * as Client from '../Client.js' +import type * as Resources from '../Resources.js' +import * as HttpTransport from '../transports/HttpTransport.js' + +function createCli() { + return Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose } + }, + }) +} + +function httpClient(cli: Cli.Cli) { + const requests: Request[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + return cli.fetch(request) + }) as typeof globalThis.fetch + return { + client: Client.create({ + transport: HttpTransport.create({ baseUrl: 'https://example.com', fetch }), + }), + requests, + } +} + +function clientWithDiscover(discover: (request: Resources.Request) => Promise) { + return Client.create({ + transport: (() => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover, + request: vi.fn(), + })) satisfies HttpTransport.HttpTransport, + }) +} + +describe('resources actions', () => { + test('routes every resources action through HTTP and preserves structured/text returns', async () => { + const { client, requests } = httpClient(createCli()) + + await expect(client.llms()).resolves.toMatchObject({ + version: 'incur.v1', + commands: [expect.objectContaining({ name: 'status' })], + }) + await expect(client.llms({ command: 'status' as never, format: 'md' })).resolves.toContain( + '| `app status ` | Show status |', + ) + await expect(client.llms({ command: 'status' as never, format: 'jsonl' })).resolves.toContain( + '"name":"status"', + ) + await expect(client.llmsFull({ command: 'status' as never })).resolves.toMatchObject({ + version: 'incur.v1', + commands: [expect.objectContaining({ name: 'status' })], + }) + await expect(client.schema('status' as never)).resolves.toMatchObject({ + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }) + await expect(client.help('status' as never)).resolves.toContain('Usage: status [options]') + await expect(client.openapi()).resolves.toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + }) + await expect(client.skills.index()).resolves.toMatchObject({ + skills: [expect.objectContaining({ name: 'status' })], + }) + await expect(client.skills.get('status')).resolves.toContain('# app status') + await expect(client.mcp.tools()).resolves.toMatchObject({ + tools: [expect.objectContaining({ name: 'status' })], + }) + + expect( + requests.map((request) => ({ + pathname: new URL(request.url).pathname, + search: new URL(request.url).search, + })), + ).toEqual([ + { pathname: '/_incur/llms', search: '?format=json' }, + { pathname: '/_incur/llms', search: '?command=status&format=md' }, + { pathname: '/_incur/llms', search: '?command=status&format=jsonl' }, + { pathname: '/_incur/llms-full', search: '?command=status&format=json' }, + { pathname: '/_incur/schema', search: '?command=status' }, + { pathname: '/_incur/help', search: '?command=status' }, + { pathname: '/openapi.json', search: '' }, + { pathname: '/_incur/skills', search: '' }, + { pathname: '/_incur/skill', search: '?name=status' }, + { pathname: '/_incur/mcp/tools', search: '' }, + ]) + }) + + test('normalizes resources failures into ClientError fields', async () => { + const client = clientWithDiscover( + vi.fn(async () => { + throw Object.assign(new Error('Unknown command'), { + code: 'COMMAND_NOT_FOUND', + status: 404, + }) + }), + ) + + await expect(client.help('missing' as never)).rejects.toMatchObject({ + code: 'COMMAND_NOT_FOUND', + status: 404, + }) + }) +}) diff --git a/src/client/actions/ResourcesActions.ts b/src/client/actions/ResourcesActions.ts new file mode 100644 index 0000000..788e3c6 --- /dev/null +++ b/src/client/actions/ResourcesActions.ts @@ -0,0 +1,134 @@ +import type * as Client from '../Client.js' +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import type { ActionClient } from './ActionClient.js' + +/** LLM resource action options. */ +export type LlmsOptions = { command?: string | undefined; format?: Resources.Format | undefined } + +/** Reads compact LLM resources. */ +export async function llms(client: ActionClient, options: LlmsOptions = {}): Promise { + const { command, format = 'json' } = options + return discover(client, { + resource: 'llms', + ...(command ? { command } : undefined), + format, + }) +} + +/** Reads full LLM resources. */ +export async function llmsFull(client: ActionClient, options: LlmsOptions = {}): Promise { + const { command, format = 'json' } = options + return discover(client, { + resource: 'llmsFull', + ...(command ? { command } : undefined), + format, + }) +} + +/** Reads a command schema. */ +export async function schema( + client: ActionClient, + command?: Client.CommandScope | undefined, +): Promise> { + return discover(client, { + resource: 'schema', + ...(command ? { command } : undefined), + }) as Promise> +} + +/** Reads help text. */ +export async function help( + client: ActionClient, + command?: Client.CommandScope | undefined, +): Promise { + return discover(client, { + resource: 'help', + ...(command ? { command } : undefined), + }) as Promise +} + +/** Reads the OpenAPI document. */ +export async function openapi(client: ActionClient): Promise { + return discover(client, { resource: 'openapi' }) as Promise +} + +/** Reads the generated skills index. */ +export async function skillsIndex(client: ActionClient): Promise { + return discover(client, { resource: 'skillsIndex' }) as Promise +} + +/** Reads a generated skill file. */ +export async function skill(client: ActionClient, name: string): Promise { + return discover(client, { resource: 'skill', name }) as Promise +} + +/** Reads MCP tool descriptors. */ +export async function mcpTools(client: ActionClient): Promise { + return discover(client, { resource: 'mcpTools' }) as Promise +} + +/** Binds resource actions to a client. */ +export function actions(client: ActionClient) { + return { + llms(options?: LlmsOptions | undefined) { + return llms(client, options) + }, + llmsFull(options?: LlmsOptions | undefined) { + return llmsFull(client, options) + }, + schema(command?: Client.CommandScope | undefined) { + return schema(client, command) + }, + help(command?: Client.CommandScope | undefined) { + return help(client, command) + }, + openapi() { + return openapi(client) + }, + skills: { + index() { + return skillsIndex(client) + }, + get(name: string) { + return skill(client, name) + }, + }, + mcp: { + tools() { + return mcpTools(client) + }, + }, + } +} + +async function discover(client: ActionClient, request: Resources.Request): Promise { + try { + const response = await client.transport.discover(request) + if ('body' in response) return response.body + return response.data + } catch (error) { + if (error instanceof ClientError) throw error + const data = isRecord(error) + ? { + ok: false, + error: { + code: typeof error.code === 'string' ? error.code : 'RESOURCES_ERROR', + message: error instanceof Error ? error.message : String(error), + }, + meta: { resource: request.resource }, + } + : undefined + throw new ClientError(error instanceof Error ? error.message : 'Resources request failed', { + cause: error instanceof Error ? error : undefined, + code: isRecord(error) && typeof error.code === 'string' ? error.code : 'RESOURCES_ERROR', + data, + error: isRecord(data) && isRecord(data.error) ? data.error : undefined, + status: isRecord(error) && typeof error.status === 'number' ? error.status : undefined, + }) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/client/actions/RunActions.test.ts b/src/client/actions/RunActions.test.ts new file mode 100644 index 0000000..106f08e --- /dev/null +++ b/src/client/actions/RunActions.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test, vi } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import * as Client from '../Client.js' +import { ClientError } from '../ClientError.js' +import * as MemoryClient from '../MemoryClient.js' +import type { + Request as RpcRequest, + Response as RpcResponse, + StreamRecord as RpcStreamRecord, + StreamResponse as RpcStreamResponse, +} from '../Rpc.js' +import type * as HttpTransport from '../transports/HttpTransport.js' + +type LogsCommands = { + logs: { args: {}; options: {}; output: unknown; stream: true } +} + +type MockCommands = { + deploy: { args: {}; options: {}; output: {} } + status: { args: {}; options: {}; output: { ok: boolean } } +} + +function testClient() { + const cli = Cli.create('app') + .command('list', { + run() { + return { + items: Array.from({ length: 200 }, (_, i) => ({ + id: i + 1, + label: `item-${i + 1}`, + message: 'alpha beta gamma delta epsilon zeta eta theta iota kappa', + })), + page: 1, + } + }, + }) + .command('report', { + run(c) { + return c.ok( + {}, + { + cta: { + commands: [ + { + command: 'unblock', + args: { taskId: 't1' }, + options: { dryRun: true }, + description: 'Unblock task', + }, + ], + }, + }, + ) + }, + }) + .command('status', { + run() { + return { items: [{ ok: true }], ok: true } + }, + }) + .command('unblock', { + args: z.object({ taskId: z.string() }), + options: z.object({ dryRun: z.boolean().optional() }), + run() { + return { items: [{ unblocked: true }], unblocked: true } + }, + }) + return MemoryClient.create(cli, { + outputFormat: 'toon', + selection: ['items[0]'], + }) +} + +function mockClient(request: (request: RpcRequest) => Promise) { + const transport = (() => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover: vi.fn(), + request(r: RpcRequest): Promise { + return request(r) + }, + })) satisfies HttpTransport.HttpTransport + return Client.create({ transport }) +} + +function streamClient(onReturn = vi.fn()) { + const cli = Cli.create('app').command('logs', { + async *run(c) { + try { + yield { line: 1 } + yield { line: 2 } + return c.ok({ lines: 2 }) + } finally { + onReturn() + } + }, + }) + return MemoryClient.create(cli) +} + +function failingStreamClient() { + return mockStreamClient([ + { type: 'chunk', data: 1 }, + { + type: 'error', + ok: false, + error: { code: 'DISCONNECTED', message: 'Disconnected.' }, + meta: { command: 'logs', duration: '2ms' }, + }, + ]) +} + +function mockStreamClient(records: RpcStreamRecord[]) { + const transport = (() => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover: vi.fn(), + async request(_request: RpcRequest): Promise { + return { + stream: true as const, + async *records() { + const terminal = records.at(-1)! + for (const record of records) yield record + return terminal + }, + } + }, + })) satisfies HttpTransport.HttpTransport + return Client.create({ transport }) +} + +describe('run action', () => { + test('merges defaults with per-call output controls and clears selection with undefined', async () => { + const client = testClient() + const request = vi.spyOn(client.transport, 'request') + + await client.run('status', { + outputFormat: 'md', + selection: undefined, + outputTokenLimit: 24, + }) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'status', + args: {}, + options: {}, + outputFormat: 'md', + outputTokenLimit: 24, + }), + ) + expect(request.mock.calls[0]?.[0]).toEqual({ + command: 'status', + args: {}, + options: {}, + outputFormat: 'md', + outputTokenLimit: 24, + }) + }) + + test('throws ClientError for failed envelopes and preserves public fields', async () => { + const request = vi.fn( + async (_request: RpcRequest): Promise => ({ + ok: false, + error: { + code: 'NOT_AUTHENTICATED', + fieldErrors: [ + { + path: 'token', + code: 'invalid', + expected: 'string', + received: 'missing', + message: 'Required', + }, + ], + message: 'Login required.', + retryable: false, + }, + meta: { command: 'deploy', duration: '2ms' }, + status: 401, + }), + ) + const client = mockClient(request) + + await expect(client.run('deploy')).rejects.toMatchObject({ + code: 'NOT_AUTHENTICATED', + error: { message: 'Login required.' }, + fieldErrors: [expect.objectContaining({ path: 'token' })], + meta: { command: 'deploy' }, + retryable: false, + status: 401, + }) + try { + await client.run('deploy') + } catch (error) { + expect(error).toBeInstanceOf(ClientError) + if (!(error instanceof ClientError)) throw error + expect(error.error).toMatchObject({ + code: 'NOT_AUTHENTICATED', + message: 'Login required.', + }) + expect(error.data).toMatchObject({ ok: false, error: { code: 'NOT_AUTHENTICATED' } }) + } + }) + + test('output.next reruns the same command with next outputTokenOffset', async () => { + const client = testClient() + const request = vi.spyOn(client.transport, 'request') + const result = await client.run('list', { selection: undefined, outputTokenLimit: 5 }) + + expect(result.output).toMatchObject({ tokenLimit: 5, tokenOffset: 0 }) + expect(result.output?.next).toBeDefined() + await expect(result.output?.next?.()).resolves.toMatchObject({ data: { page: 1 } }) + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ command: 'list', outputTokenOffset: 5 }), + ) + }) + + test('throws ClientError for malformed output payloads', async () => { + const request = vi.fn( + async (_request: RpcRequest): Promise => ({ + ok: true, + data: { ok: true }, + output: { format: 'json' } as never, + meta: { command: 'status', duration: '1ms' }, + }), + ) + const client = mockClient(request) + + await expect(client.run('status')).rejects.toThrow(ClientError) + await expect(client.run('status')).rejects.toMatchObject({ + message: 'Malformed RPC output.', + }) + }) + + test('normalizes CTA metadata and cta.run inherits client defaults only', async () => { + const client = testClient() + const request = vi.spyOn(client.transport, 'request') + const result = await client.run('report', { outputFormat: 'md' }) + const cta = result.meta.cta?.commands[0] + + expect(cta).toMatchObject({ + command: 'unblock', + cliCommand: 'unblock t1 --dry-run ', + raw: expect.any(Object), + }) + if (!cta) throw new Error('expected CTA') + await expect(cta.run()).resolves.toMatchObject({ ok: true }) + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: { taskId: 't1' }, + command: 'unblock', + options: { dryRun: true }, + outputFormat: 'toon', + selection: ['items[0]'], + }), + ) + }) + + test('CTA suggestions fail like normal runs when the command is invalid', async () => { + const cli = Cli.create('app').command('report', { + run(c) { + return c.ok({}, { cta: { commands: [{ command: 'missing' }] } }) + }, + }) + const client = MemoryClient.create(cli) + const result = await client.run('report', { selection: undefined }) + const cta = result.meta.cta?.commands[0] + + expect(cta).toMatchObject({ command: 'missing', cliCommand: 'missing' }) + await expect(cta?.run()).rejects.toMatchObject({ code: 'COMMAND_NOT_FOUND' }) + }) + + describe('stream responses', () => { + test('default async iteration yields chunks and final resolves terminal metadata', async () => { + const client = streamClient() + const stream = await client.run('logs') + const chunks: unknown[] = [] + for await (const chunk of stream as AsyncIterable) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 1 }, { line: 2 }]) + await expect(stream.final).resolves.toMatchObject({ + data: { lines: 2 }, + output: { format: 'toon' }, + meta: { command: 'logs' }, + }) + }) + + test('records yields terminal errors without throwing, while iteration and final throw', async () => { + const recordsStream = await failingStreamClient().run('logs') + const records: unknown[] = [] + for await (const record of recordsStream.records()) records.push(record) + expect(records.at(-1)).toMatchObject({ type: 'error', error: { code: 'DISCONNECTED' } }) + + const iterStream = await failingStreamClient().run('logs') + await expect( + (async () => { + for await (const _ of iterStream as AsyncIterable) { + } + })(), + ).rejects.toThrow(ClientError) + + const finalStream = await failingStreamClient().run('logs') + await expect(finalStream.final).rejects.toMatchObject({ code: 'DISCONNECTED' }) + }) + + test('enforces single-consumer streams and returns the underlying iterator on early exit', async () => { + const onReturn = vi.fn() + const stream = await streamClient(onReturn).run('logs') + + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { line: 1 } }) + expect(() => stream.records()).toThrow(ClientError) + await iterator.return?.() + expect(onReturn).toHaveBeenCalled() + }) + }) +}) diff --git a/src/client/actions/RunActions.ts b/src/client/actions/RunActions.ts new file mode 100644 index 0000000..7801e72 --- /dev/null +++ b/src/client/actions/RunActions.ts @@ -0,0 +1,340 @@ +import type * as Client from '../Client.js' +import { ClientError } from '../ClientError.js' +import type { + Envelope as RpcFullEnvelope, + Meta as RpcMeta, + Output as RpcOutput, + Request as RpcRequest, + Response as RpcResponse, + StreamRecord as RpcStreamRecord, + StreamResponse as RpcStreamResponse, +} from '../Rpc.js' +import type * as Run from '../Run.js' +import type { ActionClient } from './ActionClient.js' + +/** Runtime input accepted by the untyped run action wrapper. */ +export type Input = Client.Defaults & { args?: unknown; options?: unknown } + +/** Executes a command through a client transport. */ +export async function run( + client: ActionClient, + command: string, + input: Input | undefined, +): Promise { + const request = toRequest(client.defaults, command, input) + const response = await client.transport.request(request) + if ('stream' in response) return normalizeStream(client, request, response) + return normalizeEnvelope(client, request, response) +} + +/** Binds command run actions to a client. */ +export function actions(client: ActionClient) { + return { + run(command: string, input?: Input | undefined) { + return run(client, command, input) + }, + } +} + +function toRequest( + defaults: Client.Defaults, + command: string, + input: Input | undefined, +): RpcRequest { + const merged = { + ...defaults, + ...input, + } + if (input && 'selection' in input && input.selection === undefined) delete merged.selection + return { + command, + args: isRecord(input?.args) ? input.args : {}, + options: isRecord(input?.options) ? input.options : {}, + ...(merged.outputFormat !== undefined ? { outputFormat: merged.outputFormat } : undefined), + ...(merged.selection !== undefined ? { selection: merged.selection } : undefined), + ...(merged.outputTokenCount !== undefined + ? { outputTokenCount: merged.outputTokenCount } + : undefined), + ...(merged.outputTokenLimit !== undefined + ? { outputTokenLimit: merged.outputTokenLimit } + : undefined), + ...(merged.outputTokenOffset !== undefined + ? { outputTokenOffset: merged.outputTokenOffset } + : undefined), + } +} + +function normalizeEnvelope( + client: ActionClient, + request: RpcRequest, + response: RpcResponse, +): Run.Result { + if (!response.ok) throw errorFromEnvelope(client, response) + return { + ok: true, + data: response.data, + ...(response.output ? { output: output(client, request, response.output) } : undefined), + meta: normalizeMeta(client, response.meta), + } +} + +function output(client: ActionClient, request: RpcRequest, value: RpcOutput): Run.Output { + return normalizeOutput(value, value.nextOffset, (nextOffset) => + normalizeNext(client, { + ...request, + outputTokenOffset: nextOffset, + }), + ) +} + +function normalizeOutput( + value: RpcOutput, + nextOffset?: number | undefined, + next?: ((nextOffset: number) => Promise>) | undefined, +): Run.Output { + if (typeof value.text !== 'string') throw new ClientError('Malformed RPC output.') + return { + text: value.text, + ...(value.format !== undefined ? { format: value.format } : undefined), + ...(value.tokenCount !== undefined ? { tokenCount: value.tokenCount } : undefined), + ...(value.tokenLimit !== undefined ? { tokenLimit: value.tokenLimit } : undefined), + ...(value.tokenOffset !== undefined ? { tokenOffset: value.tokenOffset } : undefined), + ...(nextOffset !== undefined && next ? { next: () => next(nextOffset) } : undefined), + } +} + +async function normalizeNext( + client: ActionClient, + request: RpcRequest, +): Promise> { + const response = await client.transport.request(request) + if ('stream' in response) throw new ClientError('Expected non-streaming RPC response.') + return normalizeEnvelope(client, request, response) +} + +function normalizeStream( + client: ActionClient, + request: RpcRequest, + response: RpcStreamResponse, +): Run.StreamResponse { + let mode: 'chunks' | 'records' | 'final' | undefined + let terminal: Run.StreamFinal | ClientError | undefined + let resolveFinal: ((value: Run.StreamFinal) => void) | undefined + let rejectFinal: ((error: ClientError) => void) | undefined + const iterator = response.records() + const finalState = new Promise>((resolve, reject) => { + resolveFinal = resolve + rejectFinal = reject + }) + void finalState.catch(() => undefined) + + async function nextRecord(): Promise> { + const { value, done } = await iterator.next() + if (done) throw new ClientError('RPC stream ended before a terminal record.') + const record = streamRecord(value) + if (record.type === 'done') { + terminal = record + resolveFinal?.(record) + } + if (record.type === 'error') { + const error = errorFromRecord(record) + terminal = error + rejectFinal?.(error) + } + return record + } + + async function consumeFinal() { + mode ??= 'final' + if (mode !== 'final') throw new ClientError('Client stream has already been consumed.') + if (terminal instanceof ClientError) throw terminal + if (terminal) return terminal + while (true) { + const record = await nextRecord() + if (record.type === 'done') return record + if (record.type === 'error') throw errorFromRecord(record) + } + } + + const final = finalState + const then = final.then.bind(final) + const finalCatch = final.catch.bind(final) + const finalFinally = final.finally.bind(final) + // oxlint-disable-next-line unicorn/no-thenable -- `final` is an actual Promise; this starts lazy final-only consumption. + final.then = ((onfulfilled, onrejected) => { + if (mode === undefined) void consumeFinal().catch(() => undefined) + return then(onfulfilled, onrejected) + }) as typeof final.then + final.catch = ((onrejected) => { + if (mode === undefined) void consumeFinal().catch(() => undefined) + return finalCatch(onrejected) + }) as typeof final.catch + final.finally = ((onfinally) => { + if (mode === undefined) void consumeFinal().catch(() => undefined) + return finalFinally(onfinally) + }) as typeof final.finally + + const wrapper = { + final, + records() { + if (mode === undefined) mode = 'records' + else if (mode !== 'records') throw new ClientError('Client stream has already been consumed.') + return recordsIterator() + }, + [Symbol.asyncIterator]() { + if (mode === undefined) mode = 'chunks' + else if (mode !== 'chunks') throw new ClientError('Client stream has already been consumed.') + return chunksIterator() + }, + } + + async function* recordsIterator() { + try { + while (true) { + const record = await nextRecord() + yield record + if (record.type === 'done' || record.type === 'error') return + } + } finally { + await iterator.return?.(undefined as never) + } + } + + async function* chunksIterator() { + try { + while (true) { + const record = await nextRecord() + if (record.type === 'chunk') { + yield record.data + continue + } + if (record.type === 'error') throw errorFromRecord(record) + return + } + } finally { + await iterator.return?.(undefined as never) + } + } + + function streamRecord(record: RpcStreamRecord): Run.StreamRecord { + if (record.type === 'chunk') return record + if (record.type === 'done') + return { + type: 'done', + ok: true, + ...('data' in record ? { data: record.data } : undefined), + ...(record.output ? { output: normalizeOutput(record.output) } : undefined), + meta: meta(record.meta), + } + return { + type: 'error', + ok: false, + error: record.error, + meta: meta(record.meta), + } + } + + function meta(value: RpcMeta): Run.Meta { + return normalizeMeta(client, value) + } + + void request + return wrapper +} + +function errorFromEnvelope( + client: ActionClient | undefined, + response: Extract, +) { + return new ClientError(response.error.message, { + code: response.error.code, + data: response, + error: response.error, + fieldErrors: response.error.fieldErrors, + meta: normalizeMeta(client, response.meta), + retryable: response.error.retryable, + status: (response as { status?: number | undefined }).status, + }) +} + +function errorFromRecord(record: Extract, { type: 'error' }>) { + return new ClientError(record.error.message, { + code: record.error.code, + data: record, + error: record.error, + fieldErrors: record.error.fieldErrors, + meta: record.meta, + retryable: record.error.retryable, + }) +} + +function normalizeMeta(client: ActionClient | undefined, value: RpcMeta): Run.Meta { + return { + command: value.command, + duration: value.duration, + ...(value.cta ? { cta: ctaBlock(client, value.cta) } : undefined), + } +} + +function ctaBlock(client: ActionClient | undefined, value: unknown): Run.CtaBlock { + const block = isRecord(value) ? value : {} + const commands = Array.isArray(block.commands) ? block.commands : [] + return { + ...(typeof block.description === 'string' ? { description: block.description } : undefined), + commands: commands.flatMap((command) => { + const suggestion = cta(client, command) + return suggestion ? [suggestion] : [] + }), + } +} + +function cta(client: ActionClient | undefined, value: unknown): Run.Cta | undefined { + const raw = value + if (typeof value === 'string') return runnableCta(client, { command: value }, raw) + if (isRecord(value) && typeof value.command === 'string') return runnableCta(client, value, raw) + return undefined +} + +function runnableCta( + client: ActionClient | undefined, + value: Record, + raw: unknown, +): Run.Cta { + const command = value.command as string + const args = isRecord(value.args) ? value.args : {} + const options = isRecord(value.options) ? value.options : {} + const result = { + command, + cliCommand: cliCommand(command, args, options), + ...(typeof value.description === 'string' ? { description: value.description } : undefined), + args, + options, + raw, + run(optionsOverride?: Client.Defaults) { + if (!client) throw new ClientError('CTA is not attached to a client.') + return run(client, command, { args, options, ...optionsOverride }) as Promise< + Run.Result + > + }, + } satisfies Run.Cta + return result +} + +function cliCommand( + command: string, + args: Record, + options: Record, +) { + const parts = [command] + for (const [key, value] of Object.entries(args)) + parts.push(value === true ? `<${key}>` : String(value)) + for (const [key, value] of Object.entries(options)) { + const flag = `--${key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)}` + parts.push(flag, value === true ? `<${key}>` : String(value)) + } + return parts.join(' ') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts new file mode 100644 index 0000000..bd94b8d --- /dev/null +++ b/src/client/index.test-d.ts @@ -0,0 +1,164 @@ +import { Cli, z } from 'incur' +import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport, Run } from 'incur/client' +import { expectTypeOf, test } from 'vitest' + +type Commands = { + status: { args: {}; options: {}; output: { ok: boolean } } + 'project report': { + args: { projectId: string } + options: { includeClosed?: boolean | undefined } + output: { summary: string } + } + 'project deploy': { + args: { projectId: string } + options: { environment: 'production' | 'staging' } + output: { deployed: boolean } + } + 'logs tail': { + args: { service: string } + options: {} + output: { line: string } + stream: true + } +} + +type RegisteredCommands = { + registered: { args: {}; options: {}; output: { ok: true } } +} + +declare module 'incur/client' { + interface Register { + commands: RegisteredCommands + } +} + +test('module registration defaults namespace creators', async () => { + const client = HttpClient.create({ baseUrl: 'https://example.com' }) + const result = await client.run('registered') + expectTypeOf(result).toEqualTypeOf>() + // @ts-expect-error unregistered commands are rejected without an explicit command map. + await client.run('status') +}) + +test('client creation preserves transport type and defaults', () => { + const http = HttpClient.create({ + baseUrl: 'https://example.com', + outputFormat: 'toon', + }) + expectTypeOf(http).toExtend>() + expectTypeOf(http.transport.type).toEqualTypeOf<'http'>() + + const primitive = Client.create({ + transport: HttpTransport.create({ baseUrl: 'https://example.com' }), + }) + expectTypeOf(primitive).toExtend>() +}) + +test('memory clients infer commands and allow explicit override', () => { + const cli = Cli.create('app').command('status', { + args: z.object({ id: z.string() }), + run: () => ({ ok: true }), + }) + const inferred = MemoryClient.create(cli) + expectTypeOf(inferred).toExtend< + MemoryClient.MemoryClient<{ status: { args: { id: string }; options: {} } }> + >() + + const explicit = MemoryClient.create(cli) + expectTypeOf(explicit).toExtend>() +}) + +test('local actions are memory-only and unavailable on HTTP or broad transports', () => { + const http = HttpClient.create({ baseUrl: 'https://example.com' }) + // @ts-expect-error HTTP clients do not expose local skills.add. + http.skills.add() + // @ts-expect-error HTTP clients do not expose local mcp.add. + http.mcp.add() + + const cli = Cli.create('app') + const memory = MemoryClient.create(cli) + expectTypeOf(memory.skills.add).toBeFunction() + expectTypeOf(memory.skills.list).toBeFunction() + expectTypeOf(memory.mcp.add).toBeFunction() + + const broad = Client.create< + Commands, + HttpTransport.HttpTransport | MemoryTransport.MemoryTransport + >({ + transport: MemoryTransport.create(cli), + }) + // @ts-expect-error broad Transport clients do not expose local actions. + broad.skills.add() +}) + +test('run input and return types follow command map', async () => { + const client = HttpClient.create({ baseUrl: 'https://example.com' }) + await client.run('status') + // @ts-expect-error required args make input required. + await client.run('project report') + await client.run('project report', { args: { projectId: 'p1' } }) + // @ts-expect-error required options make input required. + await client.run('project deploy', { args: { projectId: 'p1' } }) + + const report = await client.run('project report', { args: { projectId: 'p1' } }) + expectTypeOf(report).toEqualTypeOf>() + const selected = await client.run('project report', { + args: { projectId: 'p1' }, + selection: ['summary'], + }) + expectTypeOf(selected.data).toEqualTypeOf() + + const stream = await client.run('logs tail', { args: { service: 'api' } }) + expectTypeOf(stream).toEqualTypeOf>() + // @ts-expect-error streaming commands reject token pagination controls. + await client.run('logs tail', { args: { service: 'api' }, outputTokenLimit: 1 }) +}) + +test('selection defaults and clearing affect data inference', async () => { + const selectedClient = Client.create< + Commands, + HttpTransport.HttpTransport, + { selection: string[] } + >({ + selection: ['summary'], + transport: HttpTransport.create({ baseUrl: 'https://example.com' }), + }) + const selected = await selectedClient.run('project report', { args: { projectId: 'p1' } }) + expectTypeOf(selected.data).toEqualTypeOf() + + const cleared = await selectedClient.run('project report', { + args: { projectId: 'p1' }, + selection: undefined, + }) + expectTypeOf(cleared.data).toEqualTypeOf<{ summary: string }>() + + const maybeSelection = undefined as string[] | undefined + const conservative = await selectedClient.run('project report', { + args: { projectId: 'p1' }, + selection: maybeSelection, + }) + expectTypeOf(conservative.data).toEqualTypeOf() +}) + +test('resources overloads and permissive command maps', async () => { + const client = HttpClient.create({ baseUrl: 'https://example.com' }) + expectTypeOf(await client.llms()).toExtend<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: undefined })).toExtend<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: 'json' })).toExtend<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: 'md' })).toEqualTypeOf() + expectTypeOf(await client.llmsFull()).toExtend<{ commands: unknown[] }>() + expectTypeOf(await client.llmsFull({ format: undefined })).toExtend<{ + commands: unknown[] + }>() + const format = undefined as 'md' | undefined + expectTypeOf(await client.llms({ format })).toExtend() + await client.llmsFull({ command: 'project' }) + // @ts-expect-error unknown resources scope. + await client.llmsFull({ command: 'unknown' }) + await client.schema('project') + await client.help('project report') + + type UnknownCommands = Record + const loose = HttpClient.create({ baseUrl: 'https://example.com' }) + await loose.run('runtime-only command', { args: { any: 'value' }, options: ['accepted'] }) +}) diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..7281ff1 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,11 @@ +export * as Client from './Client.js' +export * as HttpClient from './HttpClient.js' +export * as HttpTransport from './transports/HttpTransport.js' +export * as Local from './Local.js' +export * as MemoryClient from './MemoryClient.js' +export * as MemoryTransport from './transports/MemoryTransport.js' +export * as Resources from './Resources.js' +export * as Rpc from './Rpc.js' +export * as Run from './Run.js' +export * as Transport from './transports/Transport.js' +export type { Register } from './Client.js' diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts new file mode 100644 index 0000000..cbe9a56 --- /dev/null +++ b/src/client/transports/HttpTransport.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, test, vi } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import * as HttpTransport from './HttpTransport.js' + +function resolve(fetch: typeof globalThis.fetch) { + return HttpTransport.create({ baseUrl: 'https://example.com/api/', fetch })() +} + +function connect(cli: Cli.Cli, options: Partial = {}) { + const requests: { input: RequestInfo | URL; init: RequestInit | undefined }[] = [] + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ input, init }) + return cli.fetch(new Request(input, init)) + } + return { + requests, + transport: HttpTransport.create({ + baseUrl: 'https://example.com/', + ...options, + fetch, + })(), + } +} + +function ndjson(lines: string[], options: { cancel?: () => void } = {}) { + const encoder = new TextEncoder() + const source: UnderlyingDefaultSource = { + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line)) + controller.close() + }, + } + if (options.cancel) source.cancel = options.cancel + return new Response(new ReadableStream(source), { + headers: { 'content-type': 'application/x-ndjson; charset=utf-8' }, + }) +} + +describe('HttpTransport', () => { + test('requests commands through the CLI HTTP route', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { requests, transport } = connect(cli, { headers: { 'x-custom': 'yes' } }) + + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { ok: true }, + }) + + const request = requests[0]! + expect(String(request.input)).toBe('https://example.com/_incur/rpc') + expect(request.init?.method).toBe('POST') + const headers = new Headers(request.init?.headers) + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('accept')).toBe('application/json, application/x-ndjson') + expect(headers.get('x-custom')).toBe('yes') + expect(JSON.parse(String(request.init?.body))).toEqual({ + command: 'status', + args: {}, + options: {}, + }) + }) + + test('sends args and options to the CLI HTTP route', async () => { + const cli = Cli.create('app').command('sum', { + args: z.object({ left: z.number(), right: z.number() }), + options: z.object({ label: z.string() }), + run(c) { + return { label: c.options.label, total: c.args.left + c.args.right } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'sum', + args: { left: 2, right: 3 }, + options: { label: 'result' }, + }), + ).resolves.toMatchObject({ + ok: true, + data: { label: 'result', total: 5 }, + }) + }) + + test('preserves rendered output metadata from JSON envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + + test('preserves HTTP status on failed RPC envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.request({ command: 'missing' })).resolves.toMatchObject({ + ok: false, + status: 404, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + }) + + test('wraps fetch rejection and rejects malformed JSON envelopes', async () => { + const failing = vi.fn(async () => { + throw new Error('offline') + }) as unknown as typeof globalThis.fetch + await expect(resolve(failing).request({ command: 'status' })).rejects.toThrow(ClientError) + + const invalidJson = vi.fn( + async () => new Response('nope', { headers: { 'content-type': 'application/json' } }), + ) as typeof globalThis.fetch + await expect(resolve(invalidJson).request({ command: 'status' })).rejects.toThrow( + 'Invalid RPC JSON', + ) + + const malformed = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof globalThis.fetch + await expect(resolve(malformed).request({ command: 'status' })).rejects.toThrow( + 'Malformed RPC envelope', + ) + }) + + test('wraps discovery route errors with response metadata', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + code: 'SKILL_NOT_FOUND', + data: { + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, + ok: false, + }, + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, + message: expect.stringContaining("Unknown skill 'missing'."), + status: 404, + }) + }) + + test('preserves structured discovery error details', async () => { + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string', + path: 'command', + received: 'number', + }, + ], + message: 'Invalid discovery request.', + retryable: false, + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ), + ) as typeof globalThis.fetch + const transport = resolve(fetch) + + await expect(transport.discover({ resource: 'help' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + error: { code: 'VALIDATION_ERROR', message: 'Invalid discovery request.' }, + fieldErrors: [expect.objectContaining({ path: 'command' })], + retryable: false, + status: 400, + }) + }) + + test('streams records from the CLI HTTP route', async () => { + const cli = Cli.create('app').command('stream', { + async *run() { + yield { step: 1 } + yield { step: 2 } + }, + }) + const { transport } = connect(cli) + + const response = await transport.request({ command: 'stream' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { + type: 'done', + ok: true, + data: undefined, + meta: expect.objectContaining({ command: 'stream' }), + }, + ]) + }) + + test('parses split NDJSON records and rejects truncated streams', async () => { + const fetch = vi.fn(async () => + ndjson([ + '{"type":"chunk","data":{"a":', + '1}}\n\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}', + ]), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { a: 1 } }, + { type: 'done', ok: true, data: null, meta: { command: 'status', duration: '1ms' } }, + ]) + + const truncated = vi.fn(async () => + ndjson(['{"type":"chunk","data":1}\n']), + ) as typeof globalThis.fetch + const truncatedResponse = await resolve(truncated).request({ command: 'status' }) + if (!('stream' in truncatedResponse)) throw new Error('expected stream') + await expect(async () => { + for await (const _ of truncatedResponse.records()) { + } + }).rejects.toThrow('terminal record') + }) + + test('cancels the HTTP reader when the consumer stops early', async () => { + const cancel = vi.fn() + const fetch = vi.fn(async () => + ndjson( + [ + '{"type":"chunk","data":1}\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}\n', + ], + { cancel }, + ), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const iterator = response.records() + await iterator.next() + await iterator.return(undefined as any) + expect(cancel).toHaveBeenCalled() + }) + + test('discovers every resource through the CLI HTTP route', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, + }) + const { requests, transport } = connect(cli) + + const cases: { + request: Resources.Request + url: string + assert(response: Awaited>): void + }[] = [ + { + request: { resource: 'llms' }, + url: 'https://example.com/_incur/llms', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', command: 'status' }, + url: 'https://example.com/_incur/llms?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + url: 'https://example.com/_incur/llms?format=yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + url: 'https://example.com/_incur/llms-full', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + url: 'https://example.com/_incur/llms-full?command=status&format=json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, + url: 'https://example.com/_incur/llms-full?command=status&format=jsonl', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + url: 'https://example.com/_incur/schema', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + url: 'https://example.com/_incur/schema?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + url: 'https://example.com/_incur/help', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + url: 'https://example.com/_incur/help?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + url: 'https://example.com/openapi.json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + url: 'https://example.com/openapi.yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + url: 'https://example.com/_incur/skills', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + url: 'https://example.com/_incur/skill?name=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + url: 'https://example.com/_incur/mcp/tools', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] + + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } + + expect(requests.map((request) => String(request.input))).toEqual(cases.map((item) => item.url)) + }) +}) diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts new file mode 100644 index 0000000..16853a4 --- /dev/null +++ b/src/client/transports/HttpTransport.ts @@ -0,0 +1,239 @@ +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' +import type * as Transport from './Transport.js' + +/** HTTP transport factory. */ +export type HttpTransport = Transport.Factory< + 'http', + { + baseUrl: URL + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + } +> + +/** HTTP transport options. */ +export type Options = { + /** Base URL for the served CLI. */ + baseUrl: string | URL + /** Fetch implementation. Defaults to globalThis.fetch. */ + fetch?: typeof globalThis.fetch | undefined + /** Headers merged into every request. */ + headers?: HeadersInit | undefined +} + +/** Creates an HTTP transport. */ +export function create(options: Options): HttpTransport { + const fetcher = options.fetch ?? globalThis.fetch + if (!fetcher) throw new ClientError('No fetch implementation is available.') + const baseUrl = new URL(options.baseUrl) + + return () => ({ + config: { key: 'http', name: 'HTTP', type: 'http' }, + baseUrl, + async request(request) { + const response = await requestFetch(fetcher, url(baseUrl, '_incur/rpc'), { + method: 'POST', + headers: headers(options.headers, { + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }), + body: JSON.stringify({ + ...request, + args: request.args ?? {}, + options: request.options ?? {}, + }), + }) + return parseRpcResponse(response) + }, + async discover(request) { + const response = await requestFetch(fetcher, discoveryUrl(baseUrl, request), { + method: 'GET', + headers: headers(options.headers, { + accept: 'application/json, text/plain, text/markdown', + }), + }) + return parseDiscoverResponse(response) + }, + }) +} + +async function requestFetch(fetcher: typeof globalThis.fetch, input: URL, init: RequestInit) { + try { + return await fetcher(input, init) + } catch (error) { + throw new ClientError('RPC request failed', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } +} + +async function parseRpcResponse(response: Response): Promise { + const contentType = essence(response.headers.get('content-type') ?? '') + if (contentType === 'application/x-ndjson') { + if (!response.body) throw new ClientError('Streaming RPC response is missing a body.') + return streamResponse(response.body) + } + if (contentType !== 'application/json') throw new ClientError('RPC response was not JSON.') + const value = await parseJson(response) + if (!isEnvelope(value)) throw new ClientError('Malformed RPC envelope.') + if (!value.ok) return { ...value, status: response.status } + return value +} + +function streamResponse(body: ReadableStream): Rpc.StreamResponse { + return { + stream: true, + async *records() { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let terminal: Rpc.StreamRecord | undefined + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + for (const record of drainRecords(buffer)) { + buffer = record.rest + const parsed = parseRecord(record.line) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + } + const rest = buffer.trim() + if (rest) { + const parsed = parseRecord(rest) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + if (!terminal) throw new ClientError('RPC stream ended before a terminal record.') + return terminal + } finally { + await reader.cancel().catch(() => undefined) + } + }, + } +} + +function* drainRecords(buffer: string): Generator<{ line: string; rest: string }> { + let current = buffer + while (true) { + const index = current.indexOf('\n') + if (index === -1) return + const line = current.slice(0, index).trim() + current = current.slice(index + 1) + if (line) yield { line, rest: current } + } +} + +function parseRecord(line: string): Rpc.StreamRecord { + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + throw new ClientError('Invalid RPC stream JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } + if (!isRecord(value)) throw new ClientError('Malformed RPC stream record.') + return value +} + +async function parseJson(response: Response) { + try { + return JSON.parse(await response.text()) + } catch (error) { + throw new ClientError('Invalid RPC JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } +} + +async function parseDiscoverResponse(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!response.ok) { + const data = contentType.includes('application/json') + ? await parseJson(response).catch(() => undefined) + : await response.text().catch(() => undefined) + const error = isErrorPayload(data) ? data.error : undefined + throw new ClientError(error?.message ?? 'Discover request failed.', { + code: error?.code, + data, + error, + fieldErrors: error?.fieldErrors, + retryable: error?.retryable, + status: response.status, + }) + } + if (contentType.includes('application/json')) + return { contentType: essence(contentType), data: await parseJson(response) } + return { contentType: essence(contentType), body: await response.text() } +} + +function discoveryUrl(baseUrl: URL, request: Resources.Request) { + const path = (() => { + if (request.resource === 'llms') return '_incur/llms' + if (request.resource === 'llmsFull') return '_incur/llms-full' + if (request.resource === 'schema') return '_incur/schema' + if (request.resource === 'help') return '_incur/help' + if (request.resource === 'mcpTools') return '_incur/mcp/tools' + if (request.resource === 'skillsIndex') return '_incur/skills' + if (request.resource === 'skill') return '_incur/skill' + if (request.resource === 'openapi' && request.format === 'yaml') return 'openapi.yaml' + return 'openapi.json' + })() + const target = url(baseUrl, path) + if ('command' in request && request.command) target.searchParams.set('command', request.command) + if ('format' in request && request.format && request.resource !== 'openapi') + target.searchParams.set('format', request.format) + if (request.resource === 'skill') target.searchParams.set('name', request.name) + return target +} + +function url(baseUrl: URL, path: string) { + const pathname = `${baseUrl.pathname.replace(/\/$/, '')}/${path}` + const target = new URL(baseUrl) + target.pathname = pathname + target.search = '' + return target +} + +function headers(custom: HeadersInit | undefined, required: Record) { + const result = new Headers(required) + if (custom) new Headers(custom).forEach((value, key) => result.set(key, value)) + return result +} + +function essence(value: string) { + return value.split(';', 1)[0]!.trim().toLowerCase() +} + +function isEnvelope(value: unknown): value is Rpc.Response { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { ok?: unknown }).ok === 'boolean' && + typeof (value as { meta?: { command?: unknown } }).meta?.command === 'string' + ) +} + +function isRecord(value: unknown): value is Rpc.StreamRecord { + return ( + typeof value === 'object' && + value !== null && + ((value as { type?: unknown }).type === 'chunk' || + ((value as { type?: unknown }).type === 'done' && isEnvelope(value)) || + ((value as { type?: unknown }).type === 'error' && isEnvelope(value))) + ) +} + +function isErrorPayload(value: unknown): value is { error: Rpc.Error } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { error?: unknown }).error === 'object' && + (value as { error?: unknown }).error !== null + ) +} diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts new file mode 100644 index 0000000..b0e3300 --- /dev/null +++ b/src/client/transports/MemoryTransport.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import { ResourcesError } from '../../internal/handlers/resources.js' +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import * as MemoryTransport from './MemoryTransport.js' + +describe('MemoryTransport', () => { + test('executes through shared runtime without calling cli.fetch and uses explicit env', 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 transport = MemoryTransport.create(cli, { env: { TOKEN: 'secret' } })() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { token: 'secret' }, + }) + }) + + test('does not load config defaults for in-process requests', async () => { + const cli = Cli.create('app', { config: {} }).command('status', { + options: z.object({ name: z.string().default('runtime') }), + run(c) { + return c.options + }, + }) + const transport = MemoryTransport.create(cli)() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { name: 'runtime' }, + }) + }) + + test('preserves CLI version for in-process execution', async () => { + const cli = Cli.create('app', { version: '1.2.3' }).command('status', { + run(c) { + return { version: c.version } + }, + }) + const transport = MemoryTransport.create(cli)() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { version: '1.2.3' }, + }) + }) + + test('preserves rendered output metadata for in-process execution', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + + test('discovers every resource in process', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, + }) + const transport = MemoryTransport.create(cli)() + const cases: { + request: Resources.Request + assert(response: Awaited>): void + }[] = [ + { + request: { resource: 'llms' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] + + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } + }) + + test('discovery reuses CLI manifest and skill projection behavior', async () => { + const cli = Cli.create('app', { description: 'App' }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string() }), + examples: [ + { + args: { id: '123' }, + options: { verbose: true }, + description: 'Verbose status', + }, + ], + run(c) { + return { id: c.args.id } + }, + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { name: 'status', description: 'Show status' }, + ], + }, + }) + + const full = await transport.discover({ resource: 'llmsFull', format: 'json' }) + expect(full).toMatchObject({ + contentType: 'application/json', + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { + name: 'status', + description: 'Show status', + examples: [ + { + command: 'status 123 --verbose true', + description: 'Verbose status', + }, + ], + schema: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + ], + }, + }) + + const schema = await transport.discover({ resource: 'schema', command: 'status' }) + expect(schema).toMatchObject({ + data: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }) + + const markdown = await transport.discover({ resource: 'llmsFull' }) + if (!('body' in markdown)) throw new Error('expected markdown body') + expect(markdown.body).toContain('Verbose status') + expect(markdown.body).toContain('## Output') + expect(markdown.body).toContain('Fetch gateway. Pass path segments') + expect(markdown.body).not.toMatch(/^# app st$/m) + }) + + test('wraps discovery failures as client errors with internal cause', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + cause: expect.any(ResourcesError), + code: 'SKILL_NOT_FOUND', + message: expect.stringContaining('Discover request failed.'), + status: 404, + }) + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toThrow( + ClientError, + ) + }) + + test('exposes memory-only local capability', async () => { + const cli = Cli.create('app', { description: 'App' }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const transport = MemoryTransport.create(cli)() + expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) + expect(typeof transport.local.skills.add).toBe('function') + expect(typeof transport.local.skills.list).toBe('function') + expect(typeof transport.local.mcp.add).toBe('function') + await expect(transport.local.skills.list()).resolves.toEqual({ + skills: [expect.objectContaining({ installed: false, name: 'app-status' })], + }) + }) +}) diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts new file mode 100644 index 0000000..b2090c8 --- /dev/null +++ b/src/client/transports/MemoryTransport.ts @@ -0,0 +1,84 @@ +import * as Cli from '../../Cli.js' +import { createLocalHandler } from '../../internal/handlers/local.js' +import { createResourcesHandler } from '../../internal/handlers/resources.js' +import { createRpcHandler } from '../../internal/handlers/rpc.js' +import * as RuntimeContext from '../../internal/runtime-context.js' +import { ClientError } from '../ClientError.js' +import type * as Local from '../Local.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' +import type * as Transport from './Transport.js' + +/** Memory transport factory. */ +export type MemoryTransport = Transport.Factory< + 'memory', + { + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + local: Local.Methods + } +> + +/** Memory transport options. */ +export type Options = { + /** Explicit environment source. */ + env?: Record | undefined +} + +/** Creates an in-process memory transport. */ +export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { + return () => { + const ctx = RuntimeContext.fromCli(cli) + const { request } = createRpcHandler(ctx, { env: options.env }) + const { discover } = createResourcesHandler(ctx) + const { local } = createLocalHandler(ctx) + return { + config: { key: 'memory', name: 'Memory', type: 'memory' }, + request, + async discover(request) { + try { + return await discover(request) + } catch (error) { + throw toClientError('Discover request failed.', error) + } + }, + local: { + skills: { + async add(options) { + try { + return await local.skills.add(options) + } catch (error) { + throw toClientError('Local skills sync failed.', error) + } + }, + async list(options) { + try { + return await local.skills.list(options) + } catch (error) { + throw toClientError('Local skills list failed.', error) + } + }, + }, + mcp: { + async add(options) { + try { + return await local.mcp.add(options) + } catch (error) { + throw toClientError('Local MCP registration failed.', error) + } + }, + }, + }, + } + } +} + +function toClientError(message: string, error: unknown) { + if (error instanceof ClientError) return error + const cause = error instanceof Error ? error : new Error(String(error)) + return new ClientError(message, { + cause, + code: 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined, + status: 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined, + }) +} diff --git a/src/client/transports/Transport.test-d.ts b/src/client/transports/Transport.test-d.ts new file mode 100644 index 0000000..9c5683e --- /dev/null +++ b/src/client/transports/Transport.test-d.ts @@ -0,0 +1,53 @@ +import { Cli } from 'incur' +import { HttpTransport, MemoryTransport, Resources, Rpc, Transport } from 'incur/client' +import { expectTypeOf, test } from 'vitest' + +test('transport base types preserve discriminants and capabilities', async () => { + expectTypeOf().toEqualTypeOf<'http' | 'memory'>() + expectTypeOf['type']>().toEqualTypeOf<'http'>() + expectTypeOf['type']>().toEqualTypeOf<'memory'>() + + type Custom = Transport.Factory<'http', { ping(): Promise<'pong'> }> + const custom = undefined as unknown as Custom + const resolved = custom() + expectTypeOf(resolved.config.type).toEqualTypeOf<'http'>() + expectTypeOf(await resolved.ping()).toEqualTypeOf<'pong'>() +}) + +test('http and memory transport factories expose the expected resolved capabilities', async () => { + const http = HttpTransport.create({ baseUrl: new URL('https://example.com') }) + const resolvedHttp = http() + expectTypeOf(http).toEqualTypeOf() + expectTypeOf(resolvedHttp.config.type).toEqualTypeOf<'http'>() + expectTypeOf(resolvedHttp.baseUrl).toEqualTypeOf() + expectTypeOf(resolvedHttp.request).toEqualTypeOf< + (request: Rpc.Request) => Promise + >() + expectTypeOf(resolvedHttp.discover).toEqualTypeOf< + (request: Resources.Request) => Promise + >() + // @ts-expect-error HTTP transports do not expose local methods. + void resolvedHttp.local + + const memory = MemoryTransport.create(Cli.create('app')) + const resolvedMemory = memory() + expectTypeOf(memory).toEqualTypeOf() + expectTypeOf(resolvedMemory.config.type).toEqualTypeOf<'memory'>() + expectTypeOf(resolvedMemory.local.skills.add).toBeFunction() + expectTypeOf(resolvedMemory.local.skills.list).toBeFunction() + expectTypeOf(resolvedMemory.local.mcp.add).toBeFunction() + // @ts-expect-error memory transports do not expose an HTTP base URL. + void resolvedMemory.baseUrl +}) + +test('transport option types reject invalid values', () => { + HttpTransport.create({ baseUrl: 'https://example.com', headers: [['x-test', 'yes']] }) + HttpTransport.create({ baseUrl: new URL('https://example.com'), fetch: globalThis.fetch }) + MemoryTransport.create(Cli.create('app'), { env: { TOKEN: undefined } }) + // @ts-expect-error baseUrl is required. + HttpTransport.create({}) + // @ts-expect-error baseUrl must be a string or URL. + HttpTransport.create({ baseUrl: 123 }) + // @ts-expect-error env values must be strings or undefined. + MemoryTransport.create(Cli.create('app'), { env: { TOKEN: 123 } }) +}) diff --git a/src/client/transports/Transport.ts b/src/client/transports/Transport.ts new file mode 100644 index 0000000..f91a989 --- /dev/null +++ b/src/client/transports/Transport.ts @@ -0,0 +1,20 @@ +/** Transport type names. */ +export type TransportType = 'http' | 'memory' + +/** Transport configuration. */ +export type Config = { + /** Stable transport key. */ + key: string + /** Human-readable transport name. */ + name: string + /** Transport type. */ + type: type +} + +/** Transport capabilities exposed by a resolved transport. */ +export type Capabilities = Record + +/** Transport factory. */ +export type Factory = () => { + config: Config +} & capabilities diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 61bbb4d..7f0412b 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1601,34 +1601,41 @@ describe('--llms', () => { describe('typegen', () => { test('generates correct .d.ts for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + "auth login": { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } + "auth logout": { args: {}; options: {} } + "auth status": { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } + config: { args: { key?: string | undefined }; options: {} } + echo: { args: { message: string; repeat?: number | undefined }; options: { upper: boolean; prefix: string } } + explode: { args: {}; options: {} } + "explode-clac": { args: {}; options: {} } + noop: { args: {}; options: {} } + ping: { args: {}; options: {} } + "project create": { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } + "project delete": { args: { id: string }; options: { force: boolean } } + "project deploy create": { args: { env: string }; options: { branch: string; dryRun: boolean }; output: { deployId: string; url: string; status: string } } + "project deploy rollback": { args: { deployId: string }; options: {} } + "project deploy status": { args: { deployId: string }; options: {}; output: { deployId: string; status: string; progress: number } } + "project get": { args: { id: string }; options: {}; output: { id: string; name: string; description: string; members: { userId: string; role: string }[] } } + "project list": { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean }; output: { items: { id: string; name: string; archived: boolean }[]; total: number } } + slow: { args: {}; options: {} } + stream: { args: {}; options: {}; stream: true } + "stream-error": { args: {}; options: {}; stream: true } + "stream-ok": { args: {}; options: {}; stream: true } + "stream-text": { args: {}; options: {}; stream: true } + "stream-throw": { args: {}; options: {}; stream: true } + "validate-fail": { args: { email: string; age: number }; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'api': { args: {}; options: {} } - 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } - 'auth logout': { args: {}; options: {} } - 'auth status': { args: {}; options: {} } - 'config': { args: { key?: string }; options: {} } - 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string } } - 'explode': { args: {}; options: {} } - 'explode-clac': { args: {}; options: {} } - 'noop': { args: {}; options: {} } - 'ping': { args: {}; options: {} } - 'project create': { args: { name: string }; options: { description: string; private: boolean } } - 'project delete': { args: { id: string }; options: { force: boolean } } - 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } } - 'project deploy rollback': { args: { deployId: string }; options: {} } - 'project deploy status': { args: { deployId: string }; options: {} } - 'project get': { args: { id: string }; options: {} } - 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } } - 'slow': { args: {}; options: {} } - 'stream': { args: {}; options: {} } - 'stream-error': { args: {}; options: {} } - 'stream-ok': { args: {}; options: {} } - 'stream-text': { args: {}; options: {} } - 'stream-throw': { args: {}; options: {} } - 'validate-fail': { args: { email: string; age: number }; options: {} } - } + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands } } " @@ -2833,6 +2840,8 @@ describe('fetch api', () => { .trim() .split('\n') .map((l) => JSON.parse(l)) + expect(lines[2].meta.duration).toMatch(/^\d+ms$/) + lines[2].meta.duration = '' expect(lines).toMatchInlineSnapshot(` [ { @@ -2850,6 +2859,7 @@ describe('fetch api', () => { { "meta": { "command": "stream", + "duration": "", }, "ok": true, "type": "done", diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..795f481 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -81,12 +81,20 @@ export async function execute(command: any, options: execute.Options): Promise + options?: Record + } + args = command.args ? Parser.zodParse(command.args, input.args ?? {}) : {} + parsedOptions = command.options ? Parser.zodParse(command.options, input.options ?? {}) : {} } // Parse env @@ -128,7 +136,7 @@ export async function execute(command: any, options: execute.Options): Promise + return yield* raw as AsyncGenerator } finally { resolveStreamConsumed!() } @@ -296,8 +304,9 @@ export declare namespace execute { * - `'argv'` (default): parse both args and options from argv tokens (CLI mode) * - `'split'`: args from argv, options from inputOptions (HTTP mode) * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode) + * - `'structured'`: inputOptions contains separate args/options objects (RPC mode) */ - parseMode?: 'argv' | 'split' | 'flat' | undefined + parseMode?: 'argv' | 'split' | 'flat' | 'structured' | undefined /** The resolved command path. */ path: string /** Vars schema for middleware variables. */ diff --git a/src/internal/handlers/local.test.ts b/src/internal/handlers/local.test.ts new file mode 100644 index 0000000..a62cc7d --- /dev/null +++ b/src/internal/handlers/local.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import * as Cli from '../../Cli.js' +import * as RuntimeContext from '../runtime-context.js' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + register: vi.fn(), + sync: vi.fn(), +})) + +vi.mock('../../SyncSkills.js', () => ({ + list: mocks.list, + sync: mocks.sync, +})) + +vi.mock('../../SyncMcp.js', () => ({ + register: mocks.register, +})) + +import { createLocalHandler, LocalError } from './local.js' + +function createFixture() { + const cli = Cli.create('app', { + description: 'App CLI', + mcp: { agents: ['claude-code'], command: 'pnpm app --mcp' }, + sync: { + cwd: '/workspace/app', + depth: 2, + include: ['skills/*'], + suggestions: ['Run app status'], + }, + }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + return { ctx, local: createLocalHandler(ctx).local } +} + +beforeEach(() => { + mocks.list.mockReset() + mocks.register.mockReset() + mocks.sync.mockReset() +}) + +describe('createLocalHandler', () => { + test('skills.add delegates to sync with context defaults', async () => { + const { ctx, local } = createFixture() + const result = { + agents: [{ agent: 'codex', path: '/agents/codex/app' }], + paths: ['/skills/app'], + skills: [{ description: 'App CLI', name: 'app' }], + } + mocks.sync.mockResolvedValueOnce(result) + + await expect(local.skills.add()).resolves.toBe(result) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + global: true, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add options override sync defaults', async () => { + const { ctx, local } = createFixture() + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add({ depth: 4, global: false }) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 4, + description: 'App CLI', + global: false, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add defaults depth to 1 and global to true when context has no sync defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + const { local } = createLocalHandler(ctx) + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add() + expect(mocks.sync).toHaveBeenCalledWith('bare', ctx.commands, { + cwd: undefined, + depth: 1, + description: undefined, + global: true, + include: undefined, + rootCommand: undefined, + }) + }) + + test('skills.add wraps sync failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('disk full') + mocks.sync.mockRejectedValueOnce(cause) + + try { + await local.skills.add() + throw new Error('expected local.skills.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'disk full', + name: 'Incur.LocalError', + shortMessage: 'Failed to sync local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('skills.list delegates to list and wraps the array result', async () => { + const { ctx, local } = createFixture() + const skills = [{ description: 'Show status', installed: true, name: 'app-status' }] + mocks.list.mockResolvedValueOnce(skills) + + await expect(local.skills.list()).resolves.toEqual({ skills }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list option depth overrides context depth', async () => { + const { ctx, local } = createFixture() + mocks.list.mockResolvedValueOnce([]) + + await local.skills.list({ depth: 5 }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 5, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list wraps list failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('bad glob') + mocks.list.mockRejectedValueOnce(cause) + + try { + await local.skills.list() + throw new Error('expected local.skills.list to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'bad glob', + name: 'Incur.LocalError', + shortMessage: 'Failed to list local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('mcp.add delegates to register with context defaults', async () => { + const { local } = createFixture() + const result = { agents: ['Claude Code'], command: 'pnpm app --mcp' } + mocks.register.mockResolvedValueOnce(result) + + await expect(local.mcp.add()).resolves.toBe(result) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['claude-code'], + command: 'pnpm app --mcp', + global: true, + }) + }) + + test('mcp.add options override context defaults', async () => { + const { local } = createFixture() + mocks.register.mockResolvedValueOnce({ agents: ['Cursor'], command: 'node app.js --mcp' }) + + await local.mcp.add({ + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + }) + + test('mcp.add defaults global to true without context defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const { local } = createLocalHandler(RuntimeContext.fromCli(cli)) + mocks.register.mockResolvedValueOnce({ agents: [], command: 'pnpm bare --mcp' }) + + await local.mcp.add() + expect(mocks.register).toHaveBeenCalledWith('bare', { + agents: undefined, + command: undefined, + global: true, + }) + }) + + test('mcp.add wraps register failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('missing runner') + mocks.register.mockRejectedValueOnce(cause) + + try { + await local.mcp.add() + throw new Error('expected local.mcp.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'missing runner', + name: 'Incur.LocalError', + shortMessage: 'Failed to register local MCP server.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('LocalError exposes a stable name', () => { + expect(new LocalError('Nope')).toMatchObject({ + message: 'Nope', + name: 'Incur.LocalError', + }) + }) +}) diff --git a/src/internal/handlers/local.ts b/src/internal/handlers/local.ts new file mode 100644 index 0000000..d9c769f --- /dev/null +++ b/src/internal/handlers/local.ts @@ -0,0 +1,67 @@ +import type * as Local from '../../client/Local.js' +import { BaseError } from '../../Errors.js' +import * as SyncMcp from '../../SyncMcp.js' +import * as SyncSkills from '../../SyncSkills.js' +import type * as RuntimeContext from '../runtime-context.js' + +/** Local setup/admin failure. */ +export class LocalError extends BaseError { + override name = 'Incur.LocalError' +} + +/** Creates the shared in-process local handler. */ +export function createLocalHandler(ctx: RuntimeContext.RuntimeCliContext) { + return { + local: { + skills: { + async add(options: Local.SkillsAddOptions = {}) { + try { + return await SyncSkills.sync(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + global: options.global ?? true, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to sync local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + async list(options: Local.SkillsListOptions = {}) { + try { + const skills = await SyncSkills.list(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + return { skills } + } catch (error) { + throw new LocalError('Failed to list local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + }, + mcp: { + async add(options: Local.McpAddOptions = {}) { + try { + return await SyncMcp.register(ctx.name, { + agents: options.agents ?? ctx.mcp?.agents, + command: options.command ?? ctx.mcp?.command, + global: options.global ?? true, + }) + } catch (error) { + throw new LocalError('Failed to register local MCP server.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + }, + }, + } +} diff --git a/src/internal/handlers/resources.test.ts b/src/internal/handlers/resources.test.ts new file mode 100644 index 0000000..ed10c2f --- /dev/null +++ b/src/internal/handlers/resources.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import * as RuntimeContext from '../runtime-context.js' +import { createResourcesHandler, ResourcesError } from './resources.js' + +function createFixture() { + const project = Cli.create('project', { description: 'Project commands' }) + .command('list', { + description: 'List projects', + args: z.object({ org: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ projects: z.array(z.object({ id: z.string() })) }), + run() { + return { projects: [{ id: 'p1' }] } + }, + }) + .command('empty', { + description: 'Empty schema command', + run() { + return { ok: true } + }, + }) + + const cli = Cli.create('app', { + description: 'App CLI', + version: '1.2.3', + args: z.object({ workspace: z.string().optional() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ ok: z.boolean() }), + run() { + return { ok: true } + }, + }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string(), verbose: z.boolean() }), + examples: [ + { + args: { id: '123' }, + description: 'Verbose status', + options: { verbose: true }, + }, + ], + hint: 'Use status wisely', + env: z.object({ TOKEN: z.string().optional() }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose } + }, + }) + .command(project) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + return createResourcesHandler(RuntimeContext.fromCli(cli)) +} + +async function body(response: Resources.Response) { + if (!('body' in response)) throw new Error('expected body response') + return response.body +} + +async function data(response: Resources.Response) { + if (!('data' in response)) throw new Error('expected data response') + return response.data +} + +describe('createResourcesHandler', () => { + test('rejects invalid requests, unknown scopes, fetch scopes, and unsafe skill names', async () => { + const { discover } = createFixture() + const cases: { + request: unknown + code: string + status: number + }[] = [ + { request: {}, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 1 }, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 'missing' }, code: 'COMMAND_NOT_FOUND', status: 404 }, + { request: { resource: 'schema', command: 'api' }, code: 'FETCH_GATEWAY', status: 400 }, + { + request: { resource: 'skill', name: '../status' }, + code: 'INVALID_SKILL_NAME', + status: 400, + }, + { request: { resource: 'skill', name: 'missing' }, code: 'SKILL_NOT_FOUND', status: 404 }, + ] + + for (const item of cases) + await expect(discover(item.request)).rejects.toMatchObject({ + code: item.code, + name: 'Incur.ResourcesError', + status: item.status, + }) + }) + + test('returns llms resources across root, group, leaf, and non-markdown formats', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'llms' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + await expect(discover({ resource: 'llms', command: 'project' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app project project list ` | List projects |'), + }) + await expect(discover({ resource: 'llms', command: 'status' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + + await expect(discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api', description: 'Proxy API' }), + expect.objectContaining({ name: 'project list', description: 'List projects' }), + expect.objectContaining({ + name: 'project empty', + description: 'Empty schema command', + }), + expect.objectContaining({ name: 'status', description: 'Show status' }), + ]), + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'llms', format: 'yaml' }))) + expect(yaml).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + + const jsonl = JSON.parse(await body(await discover({ resource: 'llms', format: 'jsonl' }))) + expect(jsonl).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + }) + + test('returns full manifests with schemas, examples, output, and fetch gateway guidance', async () => { + const { discover } = createFixture() + const full = await discover({ resource: 'llmsFull', format: 'json' }) + const manifest = await data(full) + const commands = (manifest as { commands: any[] }).commands + + expect(full).toMatchObject({ + contentType: 'application/json', + data: { version: 'incur.v1' }, + }) + expect(commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + expect(commands.find((command) => command.name === 'api')).toMatchObject({ + description: 'Proxy API', + }) + expect(commands.find((command) => command.name === 'project list')).toMatchObject({ + schema: { + args: { properties: { org: { type: 'string' } }, required: ['org'] }, + output: { properties: { projects: { type: 'array' } }, required: ['projects'] }, + }, + }) + expect(commands.find((command) => command.name === 'project empty')).toMatchObject({ + description: 'Empty schema command', + }) + expect(commands.find((command) => command.name === 'status')).toMatchObject({ + examples: [{ command: 'status 123 --verbose true', description: 'Verbose status' }], + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + + const markdown = await body(await discover({ resource: 'llmsFull' })) + expect(markdown).toContain('Verbose status') + expect(markdown).toContain('## Output') + expect(markdown).toContain('Fetch gateway. Pass path segments') + expect(markdown).not.toMatch(/^# app st$/m) + }) + + test('returns schemas for root, group, leaf, and schemaless commands', async () => { + const { discover } = createFixture() + const rootSchema = await data(await discover({ resource: 'schema' })) + + expect((rootSchema as { commands: any[] }).commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + await expect(discover({ resource: 'schema', command: 'project' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { commands: [{ name: 'project empty' }, { name: 'project list' }] }, + }) + await expect(discover({ resource: 'schema', command: 'status' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + await expect(discover({ resource: 'schema', command: 'project empty' })).resolves.toEqual({ + contentType: 'application/json', + data: {}, + }) + }) + + test('returns help for root, group, and leaf command scopes', async () => { + const { discover } = createFixture() + + expect(await body(await discover({ resource: 'help' }))).toContain('Commands:') + expect(await body(await discover({ resource: 'help', command: 'project' }))).toContain('list') + const help = await body(await discover({ resource: 'help', command: 'status' })) + expect(help).toContain('Usage: status [options]') + expect(help).toContain('--verbose') + expect(help).toContain('TOKEN') + }) + + test('returns OpenAPI JSON and YAML with CLI metadata', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { + '/': { post: expect.any(Object) }, + '/status/{id}': { get: expect.any(Object) }, + '/project/list/{org}': { get: expect.any(Object) }, + }, + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'openapi', format: 'yaml' }))) + expect(yaml).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }) + + test('returns skills index, individual skill markdown, and MCP tools', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'skillsIndex' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + skills: expect.arrayContaining([ + { + description: 'App CLI. Run `app --help` for usage details.', + files: ['SKILL.md'], + name: 'app', + }, + { + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + name: 'status', + }, + ]), + }, + }) + + const rootSkill = await body(await discover({ resource: 'skill', name: 'app' })) + expect(rootSkill).toContain('# app') + expect(rootSkill).toContain('## Arguments') + expect(rootSkill).toContain('## Output') + + const statusSkill = await body(await discover({ resource: 'skill', name: 'status' })) + expect(statusSkill).toContain('# app status') + expect(statusSkill).toContain('## Arguments') + expect(statusSkill).toContain('## Options') + + const tools = (await data(await discover({ resource: 'mcpTools' }))) as { tools: any[] } + expect(tools.tools.map((tool) => tool.name)).toEqual([ + 'api', + 'project_empty', + 'project_list', + 'status', + ]) + expect(tools.tools.find((tool) => tool.name === 'status')).toMatchObject({ + description: 'Show status', + inputSchema: { + properties: { + id: { type: 'string' }, + verbose: { default: false, type: 'boolean' }, + }, + }, + outputSchema: { + properties: { + id: { type: 'string' }, + verbose: { type: 'boolean' }, + }, + }, + }) + }) + + test('ResourcesError exposes stable metadata', () => { + const error = new ResourcesError('NOPE', 'Nope.', 418) + expect(error).toMatchObject({ + code: 'NOPE', + message: 'Nope.', + name: 'Incur.ResourcesError', + status: 418, + }) + }) +}) diff --git a/src/internal/handlers/resources.ts b/src/internal/handlers/resources.ts new file mode 100644 index 0000000..4885acb --- /dev/null +++ b/src/internal/handlers/resources.ts @@ -0,0 +1,214 @@ +import { stringify as yamlStringify } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import { BaseError } from '../../Errors.js' +import * as Formatter from '../../Formatter.js' +import * as Help from '../../Help.js' +import * as Mcp from '../../Mcp.js' +import * as Openapi from '../../Openapi.js' +import * as Skill from '../../Skill.js' +import * as RuntimeContext from '../runtime-context.js' + +/** Resources failure with protocol code and HTTP status metadata. */ +export class ResourcesError extends BaseError { + override name = 'Incur.ResourcesError' + /** Machine-readable error code. */ + code: string + /** HTTP status for discovery routes. */ + status: number + + constructor(code: string, message: string, status: number) { + super(message) + this.code = code + this.status = status + } +} + +const requestSchema = z.discriminatedUnion('resource', [ + z.object({ + resource: z.literal('llms'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ + resource: z.literal('llmsFull'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ resource: z.literal('schema'), command: z.string().optional() }), + z.object({ resource: z.literal('help'), command: z.string().optional() }), + z.object({ resource: z.literal('openapi'), format: z.enum(['json', 'yaml']).optional() }), + z.object({ resource: z.literal('skillsIndex') }), + z.object({ resource: z.literal('skill'), name: z.string() }), + z.object({ resource: z.literal('mcpTools') }), +]) + +/** Creates the shared in-process resources handler. */ +export function createResourcesHandler(ctx: RuntimeContext.RuntimeCliContext) { + return { + async discover(request: unknown): Promise { + const parsedRequest = requestSchema.safeParse(request) + if (!parsedRequest.success) + throw new ResourcesError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + const parsed = parsedRequest.data + if (parsed.resource === 'openapi') { + const spec = openapi(ctx) + if (parsed.format === 'yaml') + return { contentType: 'application/yaml', body: yamlStringify(spec) } + return { contentType: 'application/json', data: spec } + } + if (parsed.resource === 'mcpTools') + return { + contentType: 'application/json', + data: { tools: Mcp.collectTools(ctx.commands, []) }, + } + + if (parsed.resource === 'skillsIndex' || parsed.resource === 'skill') { + const { files } = skills(ctx) + if (parsed.resource === 'skillsIndex') { + return { + contentType: 'application/json', + data: { + skills: files.map((file) => { + const meta = Cli.parseSkillFrontmatter(file.content) + return { + name: file.dir || ctx.name, + description: meta.description ?? '', + files: ['SKILL.md'], + } + }), + }, + } + } + if (!safeSkillName(parsed.name)) + throw new ResourcesError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + const file = files.find((value) => (value.dir || ctx.name) === parsed.name) + if (!file) + throw new ResourcesError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + return { contentType: 'text/markdown', body: file.content } + } + + const scoped = scope(ctx, parsed.command) + if (parsed.resource === 'help') { + if (scoped.type === 'command') + return { + contentType: 'text/plain', + body: Help.formatCommand(scoped.id, { + alias: scoped.command.alias, + args: scoped.command.args, + description: scoped.command.description, + env: scoped.command.env, + examples: [], + hint: scoped.command.hint, + options: scoped.command.options, + usage: [], + }), + } + return { + contentType: 'text/plain', + body: Help.formatRoot(scoped.id, { + description: scoped.description, + commands: Cli.buildIndexManifest(scoped.commands, []).commands.map( + ({ name, description }) => ({ + name, + ...(description ? { description } : undefined), + }), + ), + }), + } + } + + if (parsed.resource === 'schema') { + if (scoped.type === 'command') { + const schema = Cli.buildCommandSchema(scoped.command) + return { contentType: 'application/json', data: schema ?? {} } + } + return { + contentType: 'application/json', + data: Cli.buildManifest(scoped.commands, scoped.prefix), + } + } + + const full = parsed.resource === 'llmsFull' + const format = parsed.format ?? 'md' + const data = full + ? Cli.buildManifest(scoped.commands, scoped.prefix) + : Cli.buildIndexManifest(scoped.commands, scoped.prefix) + if (format === 'json') return { contentType: 'application/json', data } + if (format === 'md') { + const groups = new Map() + const entries = Cli.collectSkillCommands( + scoped.commands, + scoped.prefix, + groups, + scoped.rootCommand, + ) + const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name + const body = full + ? Skill.generate(name, entries, groups) + : Skill.index(name, entries, scoped.description) + return { contentType: 'text/markdown', body } + } + return { + contentType: 'text/plain', + body: Formatter.format(data, format), + } + }, + } +} + +function scope(ctx: RuntimeContext.RuntimeCliContext, command: string | undefined) { + if (!command) + return { + type: 'group' as const, + id: ctx.name, + commands: ctx.commands, + prefix: [] as string[], + rootCommand: ctx.rootCommand, + description: ctx.description, + } + const resolved = RuntimeContext.resolveCanonical(ctx, command) + if ('error' in resolved) + throw new ResourcesError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) + if ('gateway' in resolved) + throw new ResourcesError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) + if ('commands' in resolved) + return { + type: 'group' as const, + id: resolved.id, + commands: resolved.commands, + prefix: resolved.id.split(' '), + rootCommand: undefined, + description: resolved.description, + } + return { + type: 'command' as const, + id: resolved.id, + command: resolved.command, + commands: new Map([[resolved.id.split(' ').at(-1)!, resolved.command]]), + prefix: resolved.id.split(' ').slice(0, -1), + rootCommand: undefined, + description: resolved.command.description, + } +} + +function openapi(ctx: RuntimeContext.RuntimeCliContext) { + const cli = { name: ctx.name, description: ctx.description } as any + Cli.toCommands.set(cli, ctx.commands as any) + if (ctx.rootCommand) Cli.toRootDefinition.set(cli as Cli.Root, ctx.rootCommand as any) + return Openapi.fromCli(Object.assign(cli, { env: ctx.env, vars: ctx.vars }), { + ...(ctx.version !== undefined ? { version: ctx.version } : undefined), + }) +} + +function skills(ctx: RuntimeContext.RuntimeCliContext) { + const groups = new Map() + const entries = Cli.collectSkillCommands(ctx.commands, [], groups, ctx.rootCommand) + return { files: Skill.split(ctx.name, entries, 1, groups) } +} + +function safeSkillName(name: string) { + return name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '..' +} diff --git a/src/internal/handlers/rpc.test.ts b/src/internal/handlers/rpc.test.ts new file mode 100644 index 0000000..c60d025 --- /dev/null +++ b/src/internal/handlers/rpc.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import * as Formatter from '../../Formatter.js' +import * as RuntimeContext from '../runtime-context.js' +import { createRpcHandler, getRpcStatus } from './rpc.js' + +function createFixture() { + const order: string[] = [] + const child = Cli.create('child', { + args: z.object({ id: z.string() }), + options: z.object({ loud: z.boolean().default(false) }), + run(c) { + order.push(`child:${c.agent}:${c.args.id}:${c.options.loud}:${c.env.TOKEN}`) + return c.ok({ id: c.args.id, loud: c.options.loud }, { cta: { commands: ['next'] } }) + }, + env: z.object({ TOKEN: z.string() }), + }) + + const router = Cli.create('project') + router.use(async (_, next) => { + order.push('group:before') + await next() + order.push('group:after') + }) + router.command('list', { + args: z.object({ projectId: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ items: z.array(z.object({ id: z.string() })) }), + run(c) { + order.push(`run:${c.args.projectId}:${c.options.limit}:${(c.var as { root: string }).root}`) + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + router.command('stream', { + async *run(c) { + try { + yield { step: 1 } + yield { step: 2 } + return c.ok({ done: true }, { cta: { commands: ['project list'] } }) + } finally { + order.push('stream:return') + } + }, + }) + router.command('fail-stream', { + async *run(c) { + yield { step: 1 } + return c.error({ code: 'STREAM_FAILED', message: 'nope', retryable: true }) + }, + }) + router.command('denied', { + run(c) { + return c.error({ + code: 'DENIED', + cta: { commands: ['project list'] }, + message: 'Denied.', + retryable: true, + }) + }, + }) + router.command('throw', { + run() { + throw new Error('boom') + }, + }) + + const cli = Cli.create('root', { + vars: z.object({ root: z.string().default('unset') }), + env: z.object({ API_KEY: z.string() }), + run() { + return { root: true } + }, + }) + cli.use(async (c, next) => { + order.push(`root:before:${c.env.API_KEY}`) + c.set('root', 'set') + await next() + order.push('root:after') + }) + cli.command('alias-target', { + aliases: ['alias'], + run() { + return { ok: true } + }, + }) + cli.command(child) + cli.command(router) + cli.command('raw', { fetch: () => new Response('{}') }) + return { cli, order, ctx: RuntimeContext.fromCli(cli) } +} + +describe('createRpcHandler', () => { + test('executes root, mounted root, and mounted router commands by canonical ID', async () => { + const { ctx, order } = createFixture() + + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: ' root ', + args: {}, + options: {}, + }), + ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k', TOKEN: 't' } }).request({ + command: 'child', + args: { id: 'c1' }, + options: { loud: true }, + }), + ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: { limit: 1 }, + }), + ).resolves.toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }, { id: 'b' }] }, + meta: { command: 'project list' }, + }) + + expect(order).toEqual([ + 'root:before:k', + 'root:after', + 'root:before:k', + 'child:true:c1:true:t', + 'root:after', + 'root:before:k', + 'group:before', + 'run:p1:1:set', + 'group:after', + 'root:after', + ]) + }) + + test('rejects malformed RPC requests with field errors', async () => { + const { ctx } = createFixture() + const { request } = createRpcHandler(ctx) + const cases = [ + null, + {}, + { command: 1 }, + { command: 'project list', args: [] }, + { command: 'project list', options: [] }, + { command: 'project list', outputFormat: 'xml' }, + { command: 'project list', outputTokenLimit: -1 }, + { command: 'project list', outputTokenOffset: 1.5 }, + { command: 'project list', selection: [] }, + ] + + for (const item of cases) { + const response = await request(item) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'INVALID_RPC_REQUEST', + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ message: expect.any(String) }), + ]), + }, + }) + } + }) + + test('rejects unknown commands, groups, aliases, and raw fetch gateways', async () => { + const { ctx } = createFixture() + const { request } = createRpcHandler(ctx) + await expect(request({ command: '' })).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) + await expect(request({ command: 'missing' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(request({ command: 'project' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_GROUP' }, + }) + await expect(request({ command: 'alias' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(request({ command: 'raw' })).resolves.toMatchObject({ + ok: false, + error: { code: 'FETCH_GATEWAY' }, + }) + }) + + test('validates structured args, options, CLI env, and command env independently', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: {}, + options: { limit: 1 }, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p' }, + options: { limit: 'bad' }, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx).request({ + command: 'project list', + args: { projectId: 'p' }, + options: {}, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'child', + args: { id: 'c' }, + options: {}, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + }) + + test('returns command error envelopes with retryable and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project denied', + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'DENIED', message: 'Denied.', retryable: true }, + meta: { + command: 'project denied', + cta: { + commands: [{ command: 'root project list' }], + description: 'Suggested command:', + }, + }, + }) + }) + + test('returns thrown errors as unknown command failures', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project throw' }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'UNKNOWN', message: 'boom' }, + meta: { command: 'project throw' }, + }) + }) + + test('applies selection, formatting, token metadata, and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 4, + selection: ['items[0,1]'], + }) + expect(response).toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }] }, + meta: { command: 'project list' }, + output: { + format: 'json', + nextOffset: 4, + tokenCount: expect.any(Number), + tokenLimit: 4, + tokenOffset: 0, + truncated: true, + }, + }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.meta).not.toHaveProperty('nextOffset') + expect(response.meta).not.toHaveProperty('outputTokenCount') + }) + + test('rejects empty selections and omits token count unless requested', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx).request({ command: 'project list', selection: [] }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.output).toMatchObject({ format: Formatter.defaultFormat }) + expect(response.output).not.toHaveProperty('tokenCount') + expect(response.output).not.toHaveProperty('tokenLimit') + expect(response.output).not.toHaveProperty('tokenOffset') + expect(response.output).not.toHaveProperty('nextOffset') + + const counted = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenCount: true, + }) + expect(counted).toMatchObject({ + ok: true, + output: { format: Formatter.defaultFormat, tokenCount: expect.any(Number) }, + }) + if ('stream' in counted || !counted.ok || !counted.output) throw new Error('expected success') + expect(counted.output).not.toHaveProperty('tokenLimit') + expect(counted.output).not.toHaveProperty('tokenOffset') + expect(counted.output).not.toHaveProperty('nextOffset') + expect(counted.output).not.toHaveProperty('truncated') + }) + + test('keeps token metadata on output for non-truncated and offset-only requests', async () => { + const { ctx } = createFixture() + const request = createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request + const limited = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenLimit: 100, + }) + expect(limited).toMatchObject({ + ok: true, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenLimit: 100, + tokenOffset: 0, + }, + }) + if ('stream' in limited || !limited.ok || !limited.output) throw new Error('expected success') + expect(limited.output).not.toHaveProperty('nextOffset') + expect(limited.output).not.toHaveProperty('truncated') + + const offset = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenOffset: 1, + }) + expect(offset).toMatchObject({ + ok: true, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenOffset: 1, + truncated: true, + }, + }) + if ('stream' in offset || !offset.ok || !offset.output) throw new Error('expected success') + expect(offset.output).not.toHaveProperty('nextOffset') + }) + + test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { + const { ctx, order } = createFixture() + const { request } = createRpcHandler(ctx, { env: { API_KEY: 'k' } }) + const response = await request({ + command: 'project stream', + outputTokenCount: true, + outputTokenLimit: 1, + }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toMatchObject([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { + type: 'done', + ok: true, + meta: { command: 'project stream', cta: expect.any(Object) }, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 0, + truncated: true, + }, + }, + ]) + + const failed = await request({ command: 'project fail-stream' }) + if (!('stream' in failed)) throw new Error('expected stream') + const failedRecords: unknown[] = [] + for await (const record of failed.records()) failedRecords.push(record) + expect(failedRecords.at(-1)).toMatchObject({ + type: 'error', + ok: false, + error: { code: 'STREAM_FAILED', retryable: true }, + meta: { command: 'project fail-stream' }, + }) + + const cancelled = await request({ command: 'project stream' }) + if (!('stream' in cancelled)) throw new Error('expected stream') + const iterator = cancelled.records() + await iterator.next() + await iterator.return(undefined as any) + expect(order).toContain('stream:return') + }) + + test('maps RPC error codes to HTTP statuses', () => { + expect(getRpcStatus('COMMAND_NOT_FOUND')).toBe(404) + expect(getRpcStatus('VALIDATION_ERROR')).toBe(400) + expect(getRpcStatus('INVALID_RPC_REQUEST')).toBe(400) + expect(getRpcStatus('COMMAND_GROUP')).toBe(400) + expect(getRpcStatus('FETCH_GATEWAY')).toBe(400) + expect(getRpcStatus('UNKNOWN')).toBe(500) + }) +}) diff --git a/src/internal/handlers/rpc.ts b/src/internal/handlers/rpc.ts new file mode 100644 index 0000000..3e62394 --- /dev/null +++ b/src/internal/handlers/rpc.ts @@ -0,0 +1,322 @@ +import { estimateTokenCount, sliceByTokens } from 'tokenx' +import { z } from 'zod' + +import type * as Rpc from '../../client/Rpc.js' +import type { FieldError } from '../../Errors.js' +import * as Filter from '../../Filter.js' +import * as Formatter from '../../Formatter.js' +import * as Command from '../command.js' +import * as RuntimeContext from '../runtime-context.js' + +const requestSchema = z.object({ + command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), + args: z.record(z.string(), z.unknown()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + outputFormat: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + selection: z.array(z.string().min(1)).nonempty().optional(), + outputTokenCount: z.boolean().optional(), + outputTokenLimit: z.number().int().nonnegative().optional(), + outputTokenOffset: z.number().int().nonnegative().optional(), +}) +const sentinel = Symbol.for('incur.sentinel') + +/** Returns the HTTP status for an RPC error code. */ +export function getRpcStatus(code: string) { + if (code === 'COMMAND_NOT_FOUND') return 404 + if (code === 'VALIDATION_ERROR' || code === 'INVALID_RPC_REQUEST') return 400 + if (code === 'COMMAND_GROUP' || code === 'FETCH_GATEWAY') return 400 + return 500 +} + +/** Creates the shared in-process RPC handler. */ +export function createRpcHandler( + ctx: RuntimeContext.RuntimeCliContext, + options: createRpcHandler.Options = {}, +) { + return { + async request(request: unknown): Promise { + const start = performance.now() + const parsed = requestSchema.safeParse(request) + if (!parsed.success) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'Invalid RPC request.', + fieldErrors: parsed.error.issues.map((issue) => ({ + code: issue.code, + expected: 'valid RPC request', + received: 'invalid', + message: issue.message, + path: issue.path.join('.'), + })), + }) + + const rpc = parsed.data + if (!rpc.command) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'RPC command is required.', + }) + + const resolved = RuntimeContext.resolveCanonical(ctx, rpc.command) + if ('error' in resolved) + return errorEnvelope(rpc.command, start, { + code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', + message: + resolved.error === 'empty' + ? 'RPC command is required.' + : `'${resolved.token}' is not a command for '${resolved.parent}'.`, + }) + if ('commands' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'COMMAND_GROUP', + message: `'${resolved.id}' is a command group. Specify a subcommand.`, + }) + if ('gateway' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'FETCH_GATEWAY', + message: `'${resolved.id}' is a raw fetch gateway and cannot be called with structured RPC.`, + }) + + const result = await Command.execute(resolved.command, { + agent: true, + argv: [], + env: ctx.env, + envSource: options.env, + format: rpc.outputFormat ?? Formatter.defaultFormat, + formatExplicit: true, + inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, + middlewares: resolved.middlewares, + name: ctx.name, + parseMode: 'structured', + path: resolved.id, + vars: ctx.vars, + version: ctx.version, + }) + + if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) + if (!result.ok) + return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta)) + return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) + }, + } +} + +export declare namespace createRpcHandler { + /** Execution options. */ + type Options = { + /** Explicit environment source. */ + env?: Record | undefined + } +} + +function streamResponse( + stream: AsyncGenerator, + command: string, + start: number, + request: Rpc.Request, +): Rpc.StreamResponse { + return { + stream: true, + async *records() { + let terminal: Rpc.StreamRecord + try { + while (true) { + const { value, done } = await stream.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) + } else { + const data = isSentinel(value) ? value.data : undefined + terminal = { + type: 'done', + ...successEnvelope( + command, + start, + data, + formatCta('', isSentinel(value) ? value.cta : undefined), + request, + ), + } + } + yield terminal + return terminal + } + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) + yield terminal + return terminal + } + yield { type: 'chunk', data: value } + } + } catch (error) { + terminal = errorRecord( + command, + start, + { + code: 'UNKNOWN', + message: error instanceof Error ? error.message : String(error), + }, + undefined, + ) + yield terminal + return terminal + } finally { + await stream.return(undefined).catch(() => undefined) + } + }, + } +} + +function successEnvelope( + command: string, + start: number, + data: unknown, + cta?: unknown | undefined, + request: Rpc.Request = { command }, +): Extract { + const selected = applySelection(data, request.selection) + const output = renderOutput(selected, request) + const payload = outputPayload(output, request) + return { + ok: true, + data: selected, + ...(payload ? { output: payload } : undefined), + meta: meta(command, start, cta), + } +} + +function errorEnvelope( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta?: unknown | undefined, +): Extract { + return { + ok: false, + error, + meta: meta(command, start, cta), + } +} + +function errorRecord( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta: unknown | undefined, +): Extract { + return { type: 'error', ...errorEnvelope(command, start, error, cta) } +} + +function applySelection(data: unknown, selection: string[] | undefined) { + if (!selection?.length) return data + return Filter.apply( + data, + selection.flatMap((value) => Filter.parse(value)), + ) +} + +function renderOutput(data: unknown, request: Rpc.Request) { + const format = request.outputFormat ?? Formatter.defaultFormat + const text = Formatter.format(data, format) + const count = estimateTokenCount(text) + const offset = request.outputTokenOffset ?? 0 + if (request.outputTokenLimit === undefined && request.outputTokenOffset === undefined) + return { text, format, count, offset, truncated: false } + const end = request.outputTokenLimit === undefined ? count : offset + request.outputTokenLimit + const sliced = sliceByTokens(text, offset, end) + return { + text: sliced, + format, + count, + offset, + truncated: offset > 0 || end < count, + nextOffset: end < count ? end : undefined, + } +} + +function outputPayload( + output: ReturnType, + request: Rpc.Request, +): Rpc.Output | undefined { + if (!output.text && !includeTokenMetadata(request)) return undefined + return { + text: output.text, + format: output.format, + ...(output.nextOffset !== undefined ? { nextOffset: output.nextOffset } : undefined), + ...(includeTokenMetadata(request) ? { tokenCount: output.count } : undefined), + ...(request.outputTokenLimit !== undefined + ? { tokenLimit: request.outputTokenLimit } + : undefined), + ...(request.outputTokenLimit !== undefined || request.outputTokenOffset !== undefined + ? { tokenOffset: output.offset } + : undefined), + ...(output.truncated ? { truncated: true } : undefined), + } +} + +function includeTokenMetadata(request: Rpc.Request) { + return ( + request.outputTokenCount || + request.outputTokenLimit !== undefined || + request.outputTokenOffset !== undefined + ) +} + +function meta(command: string, start: number, cta: unknown | undefined): Rpc.Meta { + return { + command, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + } +} + +function formatCta(name: string, block: unknown | undefined) { + if (!block || typeof block !== 'object' || !('commands' in block)) return undefined + const commands = (block as { commands: unknown[]; description?: string | undefined }).commands + if (commands.length === 0) return undefined + return { + description: + (block as { description?: string | undefined }).description ?? + (commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'), + commands: commands.map((command) => { + if (typeof command === 'string') return { command: name ? `${name} ${command}` : command } + if (typeof command === 'object' && command !== null && 'command' in command) return command + return { command: String(command) } + }), + } +} + +type SentinelValue = { + [sentinel]: 'ok' | 'error' + code?: string | undefined + cta?: unknown | undefined + data?: unknown | undefined + message?: string | undefined + retryable?: boolean | undefined +} + +function isSentinel(value: unknown): value is SentinelValue { + return typeof value === 'object' && value !== null && sentinel in value +} + +function sentinelError(value: { + code?: string | undefined + message?: string | undefined + retryable?: boolean | undefined +}) { + return { + code: value.code ?? 'UNKNOWN', + message: value.message ?? 'Command failed', + ...(value.retryable !== undefined ? { retryable: value.retryable } : undefined), + } +} diff --git a/src/internal/runtime-context.test.ts b/src/internal/runtime-context.test.ts new file mode 100644 index 0000000..aedd2f6 --- /dev/null +++ b/src/internal/runtime-context.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import * as RuntimeContext from './runtime-context.js' + +describe('runtime-context', () => { + test('collects canonical structured command IDs and excludes aliases/raw gateways', () => { + const root = Cli.create('root', { + run() { + return null + }, + }) + const mounted = Cli.create('mounted', { + run() { + return null + }, + }) + const nested = Cli.create('nested').command('leaf', { + run() { + return null + }, + }) + const router = Cli.create('project').command(nested) + root.command('target', { + aliases: ['alias'], + run() { + return null + }, + }) + root.command('raw', { fetch: () => new Response('{}') }) + root.command(mounted) + root.command(router) + + const ctx = RuntimeContext.fromCli(root) + expect(RuntimeContext.collectStructuredCommands(ctx).map((entry) => entry.id)).toEqual([ + 'mounted', + 'project nested leaf', + 'root', + 'target', + ]) + expect(RuntimeContext.resolveCanonical(ctx, 'alias')).toMatchObject({ error: 'unknown' }) + expect(RuntimeContext.resolveCanonical(ctx, 'raw')).toMatchObject({ + gateway: expect.any(Object), + }) + }) + + test('includes OpenAPI-mounted operations without serving first', () => { + const cli = Cli.create('app').command('api', { + fetch: (req) => + new Response(JSON.stringify({ id: new URL(req.url).pathname.split('/').pop() }), { + headers: { 'content-type': 'application/json' }, + }), + openapi: { + paths: { + '/users/{id}': { + get: { + operationId: 'getUser', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + const command = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli))[0]! + expect(command.id).toBe('api getUser') + expect(command.command.args?.shape.id).toBeDefined() + expect(command.command.output).toBeDefined() + }) + + test('builds separate input schemas', () => { + const command = { + args: z.object({ id: z.string() }), + env: z.object({ TOKEN: z.string() }), + options: z.object({ limit: z.number().optional() }), + run() {}, + } + expect(RuntimeContext.buildInputSchema(command)).toMatchObject({ + args: { properties: { id: { type: 'string' } } }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { properties: { limit: { type: 'number' } } }, + }) + }) +}) diff --git a/src/internal/runtime-context.ts b/src/internal/runtime-context.ts new file mode 100644 index 0000000..c3efc30 --- /dev/null +++ b/src/internal/runtime-context.ts @@ -0,0 +1,208 @@ +import type { z } from 'zod' + +import * as Cli from '../Cli.js' +import type { + CommandDefinition, + CommandEntry, + InternalAlias, + InternalFetchGateway, + InternalGroup, +} from '../Cli.js' +import type { Handler as MiddlewareHandler } from '../middleware.js' +import * as Schema from '../Schema.js' + +/** Runtime metadata needed to execute and discover a CLI command tree. */ +export type RuntimeCliContext = { + /** Command map registered on the CLI. */ + commands: Map + /** CLI description. */ + description?: string | undefined + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Middleware handlers registered on the root CLI. */ + middlewares?: MiddlewareHandler[] | undefined + /** Local MCP setup defaults. */ + mcp?: { agents?: string[] | undefined; command?: string | undefined } | undefined + /** CLI name. */ + name: string + /** Root command definition, when the CLI itself is callable. */ + rootCommand?: CommandDefinition | undefined + /** Local skill sync defaults. */ + sync?: + | { + cwd?: string | undefined + depth?: number | undefined + include?: string[] | undefined + suggestions?: string[] | undefined + } + | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + /** CLI version string. */ + version?: string | undefined +} + +/** Resolved callable command. */ +export type ResolvedCommand = { + command: CommandDefinition + id: string + middlewares: MiddlewareHandler[] +} + +/** Resolved command group. */ +export type ResolvedGroup = { + commands: Map + description?: string | undefined + id: string +} + +/** Resolved raw fetch gateway. */ +export type ResolvedFetchGateway = { + gateway: InternalFetchGateway + id: string + middlewares: MiddlewareHandler[] +} + +/** Returns a runtime context for a CLI instance. */ +export function fromCli(cli: Cli.Cli): RuntimeCliContext { + const commands = Cli.toCommands.get(cli) + if (!commands) throw new Error('No commands registered on this CLI instance') + const version = Cli.toVersion.get(cli) + return { + commands: commands as Map, + ...(cli.description ? { description: cli.description } : undefined), + ...(cli.env ? { env: cli.env } : undefined), + middlewares: Cli.toMiddlewares.get(cli) ?? [], + ...(Cli.toMcpOptions.get(cli) ? { mcp: Cli.toMcpOptions.get(cli) } : undefined), + name: cli.name, + ...(Cli.toRootDefinition.get(cli as unknown as Cli.Root) + ? { + rootCommand: Cli.toRootDefinition.get(cli as unknown as Cli.Root) as CommandDefinition< + any, + any, + any, + any, + any, + any + >, + } + : undefined), + ...(Cli.toSyncOptions.get(cli) ? { sync: Cli.toSyncOptions.get(cli) } : undefined), + ...(cli.vars ? { vars: cli.vars } : undefined), + ...(version !== undefined ? { version } : undefined), + } +} + +/** Returns true when an entry is an alias. */ +export function isAlias(entry: CommandEntry): entry is InternalAlias { + return Cli.isAlias(entry) +} + +/** Returns true when an entry is a command group. */ +export function isGroup(entry: CommandEntry): entry is InternalGroup { + return Cli.isGroup(entry) +} + +/** Returns true when an entry is a raw fetch gateway. */ +export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { + return Cli.isFetchGateway(entry) +} + +/** Resolves an alias entry within its owning command map. */ +export function resolveAlias( + commands: Map, + entry: CommandEntry, +): Exclude { + return Cli.resolveAlias(commands, entry) as Exclude +} + +/** Resolves a canonical command ID without accepting aliases. */ +export function resolveCanonical( + ctx: RuntimeCliContext, + command: string, +): + | ResolvedCommand + | ResolvedGroup + | ResolvedFetchGateway + | { error: 'empty' | 'unknown'; token?: string | undefined; parent: string } { + const id = command.trim().replace(/\s+/g, ' ') + if (!id) return { error: 'empty', parent: ctx.name } + if (ctx.rootCommand && id === ctx.name) + return { id, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] } + + let commands = ctx.commands + let entry: CommandEntry | undefined + let parent = ctx.name + const path: string[] = [] + const middlewares = [...(ctx.middlewares ?? [])] + + for (const token of id.split(' ')) { + entry = commands.get(token) + if (!entry || isAlias(entry)) return { error: 'unknown', token, parent } + path.push(token) + if (isGroup(entry)) { + middlewares.push(...(entry.middlewares ?? [])) + commands = entry.commands + parent = path.join(' ') + continue + } + if (path.join(' ') !== id) + return { error: 'unknown', token: id.split(' ')[path.length], parent } + } + + if (!entry) return { error: 'unknown', token: id, parent } + if (isGroup(entry)) return { id, commands: entry.commands, description: entry.description } + if (isFetchGateway(entry)) return { id, gateway: entry, middlewares } + if (isAlias(entry)) return { error: 'unknown', token: id, parent } + return { id, command: entry, middlewares: [...middlewares, ...(entry.middleware ?? [])] } +} + +/** Traverses structured command entries. Aliases and raw fetch gateways are excluded. */ +export function collectStructuredCommands(ctx: RuntimeCliContext): ResolvedCommand[] { + const result: ResolvedCommand[] = [] + if (ctx.rootCommand) + result.push({ id: ctx.name, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] }) + collect(ctx.commands, [], ctx.middlewares ?? [], result) + return result.sort((a, b) => a.id.localeCompare(b.id)) +} + +function collect( + commands: Map, + prefix: string[], + middlewares: MiddlewareHandler[], + result: ResolvedCommand[], +) { + for (const [name, entry] of commands) { + if (isAlias(entry) || isFetchGateway(entry)) continue + const path = [...prefix, name] + if (isGroup(entry)) { + collect(entry.commands, path, [...middlewares, ...(entry.middlewares ?? [])], result) + continue + } + result.push({ + id: path.join(' '), + command: entry, + middlewares: [...middlewares, ...(entry.middleware ?? [])], + }) + } +} + +/** Builds the structured input schema used by discovery payloads. */ +export function buildInputSchema(command: CommandDefinition): + | { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } + | undefined { + if (!command.args && !command.env && !command.options) return undefined + const result: { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } = {} + if (command.args) result.args = Schema.toJsonSchema(command.args) + if (command.env) result.env = Schema.toJsonSchema(command.env) + if (command.options) result.options = Schema.toJsonSchema(command.options) + return result +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c796343..6a77c1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "target": "esnext", "paths": { "incur": ["./src/index.ts"], + "incur/client": ["./src/client/index.ts"], "incur/register": ["./src/Register.ts"] },