From 30857116f313014c0cbdf36a61946501c105867a Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Mon, 25 May 2026 19:27:15 +0200 Subject: [PATCH 01/21] feat: add typed client public surface --- docs/api_example.ts | 392 ++++++ docs/typed-client-implementation-plan.md | 818 ++++++++++++ docs/typed-client-spec.md | 1461 ++++++++++++++++++++++ src/Typegen.test.ts | 97 +- src/Typegen.ts | 69 +- src/client/actions/discovery.test.ts | 70 ++ src/client/actions/discovery.ts | 107 ++ src/client/actions/local.test.ts | 47 + src/client/actions/local.ts | 29 + src/client/actions/run.test.ts | 173 +++ src/client/actions/run.ts | 344 +++++ src/client/api-example.test-d.ts | 127 ++ src/client/createClient.test.ts | 93 ++ src/client/createClient.ts | 140 +++ src/client/index.test-d.ts | 151 +++ src/client/index.ts | 79 +- src/client/package-exports.test.ts | 18 + src/client/stream.test.ts | 95 ++ src/client/types.ts | 523 ++++++++ src/e2e.test.ts | 83 +- src/index.ts | 1 + 21 files changed, 4856 insertions(+), 61 deletions(-) create mode 100644 docs/api_example.ts create mode 100644 docs/typed-client-implementation-plan.md create mode 100644 docs/typed-client-spec.md create mode 100644 src/client/actions/discovery.test.ts create mode 100644 src/client/actions/discovery.ts create mode 100644 src/client/actions/local.test.ts create mode 100644 src/client/actions/local.ts create mode 100644 src/client/actions/run.test.ts create mode 100644 src/client/actions/run.ts create mode 100644 src/client/api-example.test-d.ts create mode 100644 src/client/createClient.test.ts create mode 100644 src/client/createClient.ts create mode 100644 src/client/index.test-d.ts create mode 100644 src/client/package-exports.test.ts create mode 100644 src/client/stream.test.ts create mode 100644 src/client/types.ts diff --git a/docs/api_example.ts b/docs/api_example.ts new file mode 100644 index 0000000..4b928d8 --- /dev/null +++ b/docs/api_example.ts @@ -0,0 +1,392 @@ +import { create } from 'incur' +import { + ClientError, + createClient, + createHttpClient, + createMemoryClient, + httpTransport, + memoryTransport, +} from 'incur/client' + +import type { Commands } from './generated/incur-client.js' + +/** + * Client + */ +const client = createHttpClient({ + baseUrl: 'https://ops.acme.test', + // Optional, defaults to globalThis.fetch. + fetch, + + // Defaults for every client.run(). Per-call options override these. + // output* options affect result.output.text but not the (full) result.data. + outputFormat: 'toon', // --format toon +}) + +// which is exactly the same as: +const _clientViaTransport = createClient({ + transport: httpTransport({ + baseUrl: 'https://ops.acme.test', + }), + outputFormat: 'toon', +}) + +// Or create an in-process memory client. +const cli = create({ name: 'acme' }) // ... +// Memory clients run in-process, so explicit env injection is allowed here. +const memoryClient = createMemoryClient(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, +}) + +// identical to: +const _memoryClientViaTransport = createClient({ + transport: memoryTransport(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, + }), +}) + +/** + * Running + */ +// `acme project report proj_web_2026 --include-closed=false --filter-output summary items[0:3] nextCursor --format md --token-count --token-limit 24 --full-output` +const report = await client.run('project report', { + args: { projectId: 'proj_web_2026' }, + options: { includeClosed: false }, + + // Applies first to structured data (report.data), so report.data is typed as unknown. + selection: ['summary', 'items[0:3]', 'nextCursor'], + + // output* options apply only to report.output. + // They format/count/page report.output.text; they never change report.data. + outputFormat: 'md', + outputTokenCount: true, + outputTokenLimit: 24, +}) + +console.log(report) +/// ClientRunResult +// { +// 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: 24, +// tokenOffset: 0, +// next: [Function] +// }, +// meta: { +// command: 'project report', +// duration: '18ms', +// cta: { ... } +// } +// } + +console.log(typeof report.data) // unknown + +if (report.output?.next) { + const nextPage = await report.output.next() + console.log(nextPage?.output?.text) + // '- open: Publish launch checklist' +} + +// `acme project status proj_web_2026 --full-output` +const status = await client.run('project status', { + args: { projectId: 'proj_web_2026' }, +}) + +console.log(status) +/// ClientRunResult +// ... + +/** + * CTA + */ +const cta = report.meta.cta?.commands[0] +console.log(cta) +/// ClientCta +// { +// command: 'project unblock', +// cliCommand: 'acme project unblock task_2', +// description: 'Unblock the blocked checkout QA task.', +// args: { taskId: 'task_2' }, +// options: {}, +// runnable: true, +// run: [Function], +// raw: { +// command: 'project unblock', +// args: { taskId: 'task_2' }, +// options: {}, +// description: 'Unblock the blocked checkout QA task.' +// } +// } + +if (cta?.runnable) { + console.log(cta) + /// ClientCta + // ... + const unblock = await cta.run({ + // Equivalent to: + // client.run('project unblock', { + // args: { taskId: 'task_2' }, + // options: {}, + // outputFormat: 'toon', + // }) + // + // CTA run() does not inherit output controls from the original report run. + outputFormat: 'toon', + }) + + console.log(unblock) + /// ClientRunResult + // ... +} + +/** + * Errors + */ +try { + // acme project deploy proj_web_2026 production --full-output + await client.run('project deploy', { + args: { projectId: 'proj_web_2026', environment: 'production' }, + }) +} catch (error) { + if (error instanceof ClientError) { + console.log(error) + /// ClientError + // 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: 'acme auth login', + // description: 'Log in to Acme.', + // args: {}, + // options: {}, + // runnable: true, + // run: [Function], + // raw: { command: 'auth login', description: 'Log in to Acme.' } + // } + // ] + // } + // }, + // 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: { ... } + // } + // } + // } + + // Needs to be typed explicitly + const clientError = error as ClientError + console.log(clientError) + /// ClientError + // ... + } +} + +/** + * Streaming + */ +// `acme logs tail checkout-api --format toon` +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' } +} + +console.log(await stream.final) +/// ClientStreamFinal +// { +// ok: true, +// data: { lines: 124 }, +// meta: { command: 'logs tail', duration: '30s' } +// } + +// A stream can only be consumed once: either for await (...) or records(). +const rawStream = await client.run('logs tail', { + args: { service: 'checkout-api' }, +}) + +// records() yields every stream record, including error records. +// It does not throw when an error record arrives. +for await (const record of rawStream.records()) { + console.log(record) + /// ClientStreamRecord + // ... + if (record.type === 'chunk') { + console.log(record.data) + // ... + } + + if (record.type === 'done') { + console.log(record.data) + /// string | undefined + // { lines: 124 } + console.log(record.meta) + /// ClientMeta + // { command: 'logs tail', duration: '30s' } + } + + if (record.type === 'error') { + console.log(record.error) + /// ClientRpcError + // { code: 'LOG_STREAM_DISCONNECTED', message: 'Log stream disconnected.' } + } +} + +/** + * DiscoveryActions + * + * These actions are read-only and available on both HttpClient and MemoryClient: + * - client.llms(options?): Promise + * Compact LLM manifest; structured by default, string with format. + * + * - client.llmsFull(options?): Promise + * Full LLM manifest; structured by default, string with format. + * + * - client.schema(command?): Promise + * JSON Schema for root or command args/env/options/output. + * + * - client.help(command?): Promise + * CLI help text for root or command. + * + * - client.openapi(): Promise + * Parsed OpenAPI JSON document. + * + * - client.skills.index(): Promise + * Structured generated skills index. + * + * - client.skills.get(name): Promise + * Generated SKILL.md markdown. + * + * - client.mcp.tools(): Promise> + * Structured MCP tool descriptors. + * + * LocalActions + * + * These actions are available only on MemoryClient. They are not exposed by + * HttpClient, HTTP routes, RPC, or MCP tools: + * - memoryClient.skills.add(options?): Promise + * Sync generated skill files to local agent skill directories. + * + * - memoryClient.skills.list(options?): Promise + * List generated skills with local install status. + * + * - memoryClient.mcp.add(options?): Promise + * Register this CLI as a local MCP server with supported agents. + */ +const llmsFull = await client.llmsFull({ command: 'project' }) +console.log(llmsFull.commands[0]) +/// LlmsFullManifest['commands'][number] +// { +// 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' } } } +// } +// } + +// Discovery methods are not command runs, so they use `format`. +// `format` changes the discovery response itself from typed data to text. +const llmsMd = await client.llms({ command: 'project', format: 'md' }) +console.log(llmsMd) +/// string +// '# Project commands\n\n- `project report` - Summarize project progress.\n- `project status` - Show project status.' + +const schema = await client.schema('project report') +console.log(schema.args) +// CommandSchema['args'] +// { type: 'object', required: ['projectId'], properties: { projectId: { type: 'string' } } } + +const help = await client.help('project report') +console.log(help) +// string +// 'Usage: acme project report [--include-closed]\n\nSummarize project progress.' + +const openapi = await client.openapi() +console.log(openapi.info) +// OpenApiDocument['info'] +// { title: 'Acme CLI API', version: '1.0.0' } + +const skills = await client.skills.index() +console.log(skills.skills[0]) +// SkillsIndex['skills'][number] +// { name: 'deploy', description: 'Deploy safely with preflight checks.', files: ['SKILL.md'] } + +const deploySkill = await client.skills.get('deploy') +console.log(deploySkill) +// string +// '# Deploy\n\nRun preflight checks, inspect the deployment plan, then deploy.' + +const localSkills = await memoryClient.skills.list() +console.log(localSkills.skills[0]) +/// SkillsList['skills'][number] +// ... + +const syncedSkills = await memoryClient.skills.add({ + depth: 1, + global: true, +}) +console.log(syncedSkills.skills[0]) +/// SyncedSkills['skills'][number] +// { name: 'deploy', description: 'Deploy safely with preflight checks.' } + +// You can't use local actions on a http client. +client.skills.add() +// Type error: LocalActions exist only on MemoryClient. + +const mcpTools = await client.mcp.tools() +console.log(mcpTools.tools[0]) +// McpToolsResponse['tools'][number] +// { +// name: 'project_report', +// description: 'Summarize project progress.', +// inputSchema: { type: 'object', properties: { projectId: { type: 'string' } } }, +// outputSchema: { type: 'object', properties: { summary: { type: 'string' } } } +// } + +const mcpRegistration = await memoryClient.mcp.add({ + agents: ['codex'], +}) +console.log(mcpRegistration) +/// McpRegistration +// {command: 'pnpm acme --mcp', agents: ['Codex']} diff --git a/docs/typed-client-implementation-plan.md b/docs/typed-client-implementation-plan.md new file mode 100644 index 0000000..3e2fc89 --- /dev/null +++ b/docs/typed-client-implementation-plan.md @@ -0,0 +1,818 @@ +# TypeScript Client Implementation Plan + +This plan splits the TypeScript client work into two implementation PRs. + +The split is intentional: + +1. Build the shared runtime and transports first, so command execution, discovery, and local setup can be tested without the final typed client surface. +2. Build the public client and action types second, as a typed wrapper over the tested transport capabilities. + +This mirrors the intended architecture: transports do the work, actions are typed transport consumers, and clients compose actions around a resolved transport. + +The implementation must not carry forward obsolete client shapes from earlier experimental branches: + +- no curried `client(command)(input)` API; +- no HTTP-only `createClient({ baseUrl })`; +- no root-module client creation exports; +- no data-only run return; +- no bare async iterable stream return; +- no stream terminal records without full metadata; +- no RPC alias command identity; +- no HTTP/RPC/MCP local setup actions. + +## PR 1: Runtime And Transport Foundation + +Goal: create the shared runtime contracts that both HTTP and memory transports use. + +This PR should make command execution and discovery available through transport-level APIs, but it does not need to expose the final public client action surface. + +### 1. Extract Command Tree Utilities + +Create an internal command-tree module. + +Suggested file: + +```txt +src/internal/command-tree.ts +``` + +Move or expose the command graph utilities embedded in `Cli.ts`: + +- command entry types; +- alias detection; +- group detection; +- fetch gateway detection; +- canonical command resolution; +- command traversal helpers; +- mounted sub-CLI traversal behavior. + +The module should define canonical command IDs as CLI token paths joined by single spaces. + +Command identity rules: + +- aliases are CLI-only and are not generated client command IDs; +- root CLIs are callable by their own name; +- mounted root CLIs keep their own command ID; +- mounted router CLIs prefix their leaf commands with the router name; +- nested router CLIs flatten into single-space command IDs; +- raw fetch gateways are traversable for HTTP routing but are not RPC/client command IDs; +- OpenAPI-mounted fetch gateways contribute generated operation command IDs. + +Consumers: + +- HTTP RPC runtime; +- memory transport runtime; +- discovery builders; +- MCP tool discovery; +- typegen where useful. + +### 2. Extract Shared Command Runtime + +Create an internal client runtime module. + +Suggested file: + +```txt +src/internal/client-runtime.ts +``` + +This module should expose a runtime function equivalent to: + +```ts +type ExecuteClientCommand = ( + cli: RuntimeCliContext, + request: RpcRequest, +) => Promise +``` + +Responsibilities: + +- validate `RpcRequest`; +- resolve canonical command IDs; +- reject unknown commands; +- reject command groups; +- reject structured RPC calls to raw fetch gateways; +- call `Command.execute()`; +- execute through a structured args/options parse mode rather than argv, split HTTP, or MCP flat-param parsing; +- call `Command.execute()` with `agent: true`; +- call `Command.execute()` with empty `argv`; +- call `Command.execute()` with explicit JSON/full-output semantics; +- preserve middleware behavior; +- preserve root, group, and command middleware order; +- preserve env/vars behavior for in-process execution; +- preserve CLI env and command env validation; +- preserve validation `fieldErrors`; +- preserve root command identity and mounted CLI identity; +- apply `selection`; +- format `output.text`; +- compute token count/limit/offset metadata; +- compute `nextOffset`; +- preserve CTA metadata; +- produce full success/error envelopes; +- produce streaming records for streaming commands; +- include full metadata on terminal stream records; +- call command stream `return()` on cancellation; +- defer streaming middleware after-hooks until stream consumption or cancellation. + +HTTP RPC and memory transport request execution must both call this shared runtime. + +### 3. Define RPC Contracts + +Add shared types for: + +```ts +type RpcRequest +type RpcFullEnvelope +type RpcResponse +type RpcOutput +type RpcMeta +type RpcStreamRecord +type RpcStreamResponse +``` + +These are runtime/protocol contracts, not public `ClientRunResult` types. + +Validation behavior belongs here and should be tested independently. + +RPC contract tests should cover: + +- command trimming and empty-command validation; +- canonical command metadata; +- structured args validation independent from options validation; +- structured options validation independent from args validation; +- root command execution; +- mounted root CLI execution; +- mounted router command execution; +- raw fetch gateway rejection; +- alias rejection for typed-client RPC command identity; +- JSON validation errors before command execution. + +### 4. Implement HTTP RPC Through Shared Runtime + +Keep: + +```http +POST /_incur/rpc +``` + +Route behavior: + +- parse JSON request body; +- delegate validation/execution to the shared runtime; +- serialize non-streaming envelopes as JSON; +- serialize streaming command results as NDJSON; +- return JSON validation errors before a stream starts; +- advertise and accept `application/json, application/x-ndjson`; +- treat `Accept` as capability advertisement, not as a command-shape override; +- call `return()` on command streams when the HTTP response body is cancelled; +- preserve existing direct HTTP route behavior outside `/_incur/rpc`. + +Direct command HTTP routes must preserve existing streaming behavior while RPC is added: + +- async generator commands stream NDJSON chunks; +- terminal `c.ok(..., { cta })` metadata is preserved; +- terminal `c.error()` values become terminal error records; +- thrown stream errors become terminal error records; +- response cancellation closes the command stream. + +Tests: + +- success envelope; +- command error envelope; +- validation error; +- unknown command; +- command group rejection; +- fetch gateway rejection; +- output formatting; +- selection; +- token count; +- token limit/offset; +- streaming chunk/done records; +- streaming error records; +- terminal stream metadata; +- stream cancellation cleanup. + +### 5. Extract Discovery Builders + +Create an internal client discovery module. + +Suggested file: + +```txt +src/internal/client-discovery.ts +``` + +Expose a shared function equivalent to: + +```ts +type DiscoverClientResource = ( + cli: RuntimeCliContext, + request: DiscoveryRequest, +) => Promise +``` + +Discovery builders: + +- `llms`; +- `llmsFull`; +- `schema`; +- `help`; +- `openapi`; +- `skillsIndex`; +- `skill`; +- `mcpTools`. + +Reuse existing primitives: + +- `Skill.index()`; +- `Skill.generate()`; +- `Skill.split()`; +- `Openapi.fromCli()`; +- `Mcp.collectTools()`; +- existing help/schema formatting logic. + +Discovery builders must include OpenAPI-mounted operation commands everywhere command discovery is expected, and must exclude raw fetch gateways from command-run discovery. + +Avoid duplicated traversal between: + +- CLI `--llms`; +- CLI `--llms-full`; +- well-known skills routes; +- `_incur` discovery routes; +- memory discovery. + +### 6. Add HTTP Discovery Routes + +Add client discovery routes: + +```http +GET /_incur/llms +GET /_incur/llms-full +GET /_incur/schema +GET /_incur/help +GET /_incur/mcp/tools +GET /_incur/skills +GET /_incur/skill +``` + +Keep existing public routes: + +```http +GET /openapi.json +GET /openapi.yml +GET /openapi.yaml +GET /.well-known/openapi.json +GET /.well-known/skills/index.json +GET /.well-known/skills/{name}/SKILL.md +POST /mcp +``` + +HTTP discovery routes should delegate to shared discovery builders. + +Tests: + +- structured discovery payloads; +- formatted discovery payloads; +- content types; +- invalid query params; +- unknown command; +- command group handling where valid; +- unknown skill; +- unsafe skill names; +- matching payloads with existing well-known skills where applicable; +- matching MCP tool descriptors with `Mcp.collectTools()`. + +### 7. Extract Local Setup Runtime + +Create an internal local setup module. + +Suggested file: + +```txt +src/internal/client-local.ts +``` + +Expose wrappers for memory local actions: + +```ts +type LocalRuntime = { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} +``` + +Reuse existing local implementations: + +- `SyncSkills.sync()`; +- `SyncSkills.list()`; +- `SyncMcp.register()`. + +This module should use TypeScript-shaped options: + +- `global?: boolean | undefined`; +- `agents?: string[] | undefined`; +- `command?: string | undefined`; +- `depth?: number | undefined`. + +Parity details: + +- `skills.add()` uses configured sync depth when present, otherwise `1`; +- `skills.add({ global: false })` maps to CLI `--no-global`; +- `skills.list()` uses the same depth default as CLI `skills list`; +- `mcp.add()` defaults `global` to `true`; +- `mcp.add({ agents })` maps to repeated CLI agents; +- `mcp.add({ command })` maps to CLI command override. + +It should not expose shell completions. + +### 8. Implement Transports + +Add transport constructors. + +Suggested files: + +```txt +src/client/transports/createTransport.ts +src/client/transports/http.ts +src/client/transports/memory.ts +``` + +The exact file layout can differ, but keep transport code separate from action code. + +Transport constructors: + +```ts +httpTransport(options): HttpTransport +memoryTransport(cli, options): MemoryTransport +``` + +Transport behavior: + +- `httpTransport(...).request()` calls `POST /_incur/rpc`; +- `httpTransport(...).discover()` calls HTTP discovery routes; +- `memoryTransport(...).request()` calls shared command runtime; +- `memoryTransport(...).discover()` calls shared discovery builders; +- `memoryTransport(...).local` calls shared local setup runtime. + +HTTP transport details: + +- use `options.fetch ?? globalThis.fetch`; +- throw `ClientError` when no fetch implementation exists; +- wrap fetch/network rejections in `ClientError` with message `RPC request failed`; +- normalize base URLs with and without trailing slashes; +- preserve base URL path prefixes; +- serialize omitted `args` and `options` as `{}`; +- send required protocol headers; +- merge custom headers predictably; +- parse JSON envelopes; +- parse NDJSON streams split across network chunks; +- ignore blank NDJSON lines; +- accept final NDJSON records without trailing newline; +- throw `ClientError` for invalid JSON, malformed envelopes, malformed stream records, missing stream bodies, and EOF before terminal stream records; +- cancel the underlying HTTP reader when the consumer stops early. + +Memory transport details: + +- execute in process without calling `cli.fetch()`; +- use explicit `env` option as the environment source; +- do not read CLI config defaults; +- close in-process streams when the consumer stops early. + +Transport tests should directly exercise transports without the final public client: + +- HTTP request success/error; +- HTTP stream parsing at transport level; +- missing fetch implementation; +- fetch/network rejection wrapping; +- HTTP base URL normalization; +- omitted `args`/`options` serializing as `{}`; +- required protocol headers; +- HTTP custom headers; +- non-JSON envelope errors; +- malformed envelope errors; +- HTTP malformed-response errors; +- NDJSON records split across chunks; +- blank NDJSON lines; +- final NDJSON record without trailing newline; +- missing stream body errors; +- malformed stream record errors; +- truncated stream errors; +- HTTP discovery routing; +- memory request behavior matching the HTTP runtime; +- memory env injection; +- memory middleware ordering; +- memory stream cancellation; +- memory discovery behavior matching the HTTP discovery builders; +- memory local actions; +- no local capability on HTTP transport. + +### 9. Implement OpenAPI Command Generation + +OpenAPI-mounted fetch handlers must generate command entries and command-map types before the public client layer is built. + +Runtime behavior: + +- dereference `$ref` pointers; +- support standard HTTP methods plus OpenAPI 3.2 `query`; +- merge path-level and operation-level parameters; +- use `operationId` as the command leaf name; +- derive fallback names from method and path when `operationId` is absent; +- apply `basePath`; +- URL-encode path parameters; +- map query parameters into `URLSearchParams`; +- flatten JSON request body object properties into options; +- infer output schemas from the first `200` response, then first `2xx` response; +- convert only `application/json` request and response bodies; +- return command errors with `HTTP_${status}` for failed fetch responses. + +Type behavior: + +- OpenAPI-mounted commands are included in `Cli.Cli`; +- OpenAPI-mounted commands are included in generated `Commands`; +- raw fetch gateways are excluded from generated command maps; +- generated OpenAPI args/options/output types match runtime command schemas. + +Tests: + +- path-level parameters; +- operation-level parameters; +- optional and required query parameters; +- optional and required JSON body fields; +- optional request body semantics; +- success output inference; +- operation fallback naming; +- OpenAPI 3.2 `query`; +- path parameter URL encoding; +- boolean and number path/query coercion; +- strict boolean string coercion; +- raw fetch gateway exclusion; +- no serving required before OpenAPI-mounted command generation; +- generated command round trip through memory transport. + +### 10. PR 1 Non-Goals + +Do not complete the final typed public client surface in this PR. + +Do not add final `RunActions`, `DiscoveryActions`, or `LocalActions` method binding except where needed for low-level transport tests. + +Do not change MCP tool scope to include setup/admin commands. + +Do not add shell completions to any client/transport API. + +## PR 2: Public Client And Type Surface + +Goal: build the final typed API over the tested transport/runtime foundation. + +This PR should make `docs/api_example.ts` typecheck conceptually against the public client surface. + +### 1. Implement Client Creation + +Implement: + +```ts +createClient({ transport, ...defaults }) +createHttpClient(options) +createMemoryClient(cli, options) +``` + +`createClient` should: + +- generate a `uid`; +- resolve the transport factory; +- store client defaults; +- expose resolved transport metadata; +- attach action sets. + +Convenience factories must remain thin wrappers. + +`createMemoryClient(cli)` should infer `commands` from `Cli.Cli` when possible, and should allow an explicit generic override when inference is not desired. + +An explicit permissive command map such as `Record` should be supported as an intentional escape hatch. + +### 2. Implement Action Binding + +Add action modules. + +Suggested layout: + +```txt +src/client/actions/run.ts +src/client/actions/discovery.ts +src/client/actions/local.ts +``` + +Actions should be standalone functions that consume a client. + +The bound client methods should call those standalone actions. + +The action model should stay close to viem's pattern: + +- action implementation receives `client`; +- action calls `client.transport` capabilities; +- convenience client creators compose action sets; +- future overrides/extensions remain possible. + +### 3. Add RunActions + +Implement: + +```ts +client.run(command, input?) +``` + +Runtime behavior: + +- merge client defaults and per-call output controls; +- build `RpcRequest`; +- call `client.transport.request()`; +- normalize successful envelopes into `ClientRunResult`; +- throw `ClientError` for command failures; +- normalize CTAs; +- attach `output.next()` where applicable; +- return stream wrapper for streaming commands. + +Type behavior: + +- command IDs are generated canonical command IDs; +- aliases are not accepted by generated client types; +- required args/options require `input`; +- selected data is `unknown`; +- `selection: undefined` clears default selection; +- streaming commands return `ClientStreamResponse`; +- non-streaming commands return `ClientRunResult`. + +Tests: + +- `.test-d.ts` for required/optional input; +- `.test-d.ts` for root command IDs; +- `.test-d.ts` for mounted root CLI IDs; +- `.test-d.ts` for mounted router CLI IDs; +- `.test-d.ts` for permissive command maps; +- `.test-d.ts` for memory client command inference and explicit override; +- `.test-d.ts` for selected data; +- `.test-d.ts` for default selection clearing; +- runtime tests for output controls; +- runtime tests for `ClientError`; +- runtime tests for `output.next()`. + +### 4. Add CTA Normalization + +Normalize RPC CTA metadata into public client CTA objects. + +Rules: + +- CTA data lives under `meta.cta`; +- runnable CTAs expose typed `run()`; +- unresolved CTAs expose `runnable: false` and `unresolvedReason`; +- `cliCommand` is CLI-ready text; +- `cliCommand` includes the CLI/root command prefix exactly once; +- structured CTA args render as positional values; +- structured CTA args with value `true` render as placeholders; +- structured CTA options render as `--key value` flags; +- structured CTA options with value `true` render as placeholders; +- `raw` preserves source CTA data; +- CTA `run()` inherits client defaults, not source-run output controls. + +Tests: + +- string CTA; +- structured CTA; +- command CTA; +- unknown command CTA; +- invalid input CTA; +- error CTA; +- streaming terminal CTA. + +### 5. Add Stream Wrapper + +Implement `ClientStreamResponse`. + +Behavior: + +- default async iteration yields chunks; +- `records()` yields all normalized records; +- `final` resolves/rejects from the terminal record; +- stream is single-consumer; +- protocol errors throw `ClientError`; +- terminal command errors are yielded by `records()` and thrown by default iteration/final; +- split NDJSON records are parsed correctly; +- blank NDJSON lines are ignored; +- final NDJSON records do not require a trailing newline; +- early consumer exit cancels or returns the underlying stream. + +Tests: + +- chunk iteration; +- final metadata; +- terminal error; +- records mode; +- single-consumer enforcement; +- cancellation behavior; +- invalid JSON record errors; +- malformed record errors; +- missing body errors; +- EOF before terminal record errors. + +### 6. Add DiscoveryActions + +Implement: + +```ts +client.llms() +client.llmsFull() +client.schema(command?) +client.help(command?) +client.openapi() +client.skills.index() +client.skills.get(name) +client.mcp.tools() +``` + +Runtime behavior: + +- call `client.transport.discover()`; +- normalize discovery errors into `ClientError`; +- preserve structured return by default; +- return strings for explicit `format`. + +Type behavior: + +- omitted `format` returns structured data; +- literal `format` returns `string`; +- variable `DiscoveryFormat | undefined` returns structured-or-string; +- command scopes are typed from generated command maps; +- `skills.get(name)` accepts safe strings and server/runtime validates existence. + +Tests: + +- `.test-d.ts` for overloads; +- `.test-d.ts` for command scope narrowing; +- runtime tests for all discovery actions over HTTP transport; +- runtime tests for all discovery actions over memory transport. + +### 7. Add LocalActions + +Implement local actions only for memory clients: + +```ts +memory.skills.add(options?) +memory.skills.list(options?) +memory.mcp.add(options?) +``` + +Runtime behavior: + +- actions call `client.transport.local`; +- no HTTP route is involved; +- no RPC call is involved; +- no MCP tool is involved; +- local action defaults match the spec. + +Type behavior: + +- `MemoryClient` exposes local actions; +- `HttpClient` does not expose local actions; +- `Client` does not expose local actions; +- `Client` exposes local actions. + +Tests: + +- `.test-d.ts` for action availability; +- runtime tests for skills add/list; +- runtime tests for MCP registration; +- runtime tests for default local-action option mapping; +- runtime tests or route tests proving HTTP/RPC/MCP do not expose local setup/admin commands. + +### 8. Update Typegen + +Generated command maps should include: + +- canonical command IDs; +- `args`; +- `options`; +- optional `output`; +- `stream: true` for streaming commands. + +Rules: + +- command groups are not command IDs; +- aliases are not command IDs; +- mounted CLI commands are flattened; +- missing output schema maps to `unknown`; +- streaming `output` is the chunk type; +- generated files export `Commands`; +- generated files augment both `incur` and `incur/client`; +- generated command properties include JSDoc; +- optional properties include `| undefined`; +- invalid object keys and command keys are escaped; +- unsupported schemas fail with a clear typegen error. + +Schema support: + +- primitives, literals, enums, unions, arrays; +- records and enum-key records; +- tuples and rest tuples; +- nested objects; +- catchall/index signatures; +- non-object top-level outputs; +- void, undefined, never, and unknown fallbacks. + +Tests: + +- typegen command ID output; +- stream marker output; +- outputless command typing; +- mounted command typing; +- alias exclusion; +- exported `Commands` shape; +- module augmentation shape; +- exact optional property output; +- non-object output schemas; +- records and enum-key records; +- tuples and rest tuples; +- escaped keys; +- catchall output; +- unsupported schema errors; +- OpenAPI-mounted command output. + +### 9. Add Public Error Types + +Expose public client error types from `incur/client`: + +```ts +ClientError +ClientRpcEnvelope +ClientRpcError +ClientRpcErrorEnvelope +ClientRpcMeta +isClientRpcError +isClientRpcErrorEnvelope +``` + +Tests: + +- `ClientError` fields; +- narrowing `ClientError.error` with `isClientRpcError`; +- narrowing `ClientError.data` with `isClientRpcErrorEnvelope`; +- `ClientError.data`; +- `ClientError.error`; +- `ClientError.status`; +- `ClientError.meta`; +- `ClientError.code`; +- `ClientError.retryable`; +- `ClientError.fieldErrors`; +- malformed response errors preserve diagnostic `data`; +- wrapped fetch failures preserve `cause`; +- failed RPC envelopes preserve error payloads and status. + +### 10. Package Export + +Expose the client subpath. + +Add or update package exports so this works: + +```ts +import { createHttpClient } from 'incur/client' +``` + +Ensure generated declarations and runtime files are emitted for the subpath. + +Do not export client creation APIs from the root `incur` module. + +### 11. Documentation And Example + +Finalize: + +- `docs/typed-client-spec.md`; +- `docs/api_example.ts`; +- public README/API docs as needed. + +The example should show: + +- `createHttpClient`; +- equivalent `createClient({ transport: httpTransport(...) })`; +- `createMemoryClient`; +- equivalent `createClient({ transport: memoryTransport(...) })`; +- run actions; +- output controls; +- CTAs; +- streaming; +- discovery actions; +- memory-only local actions. + +### 12. PR 2 Non-Goals + +Do not add shell completions to TS clients. + +Do not expose local actions over HTTP, RPC, or MCP. + +Do not add config default loading to TS clients. + +Do not add a data-only run API. + +Do not introduce additional transports beyond HTTP and memory. diff --git a/docs/typed-client-spec.md b/docs/typed-client-spec.md new file mode 100644 index 0000000..d6d82a5 --- /dev/null +++ b/docs/typed-client-spec.md @@ -0,0 +1,1461 @@ +# TypeScript Client Spec + +This document specifies the target TypeScript client architecture for incur. It is written as a final-state contract: every section describes the API, runtime, protocol, and type behavior that exists after implementation. + +The design follows the same core model as viem: + +- transports own the execution mechanics; +- clients hold a transport and defaults; +- actions are typed wrappers over client transport capabilities; +- convenience clients are thin compositions over `createClient`; +- transport capabilities determine which actions are present. + +## Overview + +The TypeScript client has three layers: + +1. **Transports** perform work. + - `HttpTransport` serializes requests to incur HTTP routes. + - `MemoryTransport` executes against an in-process CLI instance. + +2. **Clients** hold a transport and client defaults. + - `createClient({ transport, ...defaults })` is the primitive. + - `createHttpClient(options)` wraps `createClient({ transport: httpTransport(...) })`. + - `createMemoryClient(cli, options)` wraps `createClient({ transport: memoryTransport(...) })`. + +3. **Actions** expose the typed API. + - `RunActions` execute CLI commands. + - `DiscoveryActions` expose read-only discovery. + - `LocalActions` expose local setup/admin commands, and exist only on memory clients. + +Minimal example: + +```ts +const http = createHttpClient({ + baseUrl: 'https://ops.acme.test', +}) + +const memory = createMemoryClient(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, +}) +``` + +Equivalent primitive form: + +```ts +const http = createClient({ + transport: httpTransport({ baseUrl: 'https://ops.acme.test' }), +}) + +const memory = createClient({ + transport: memoryTransport(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, + }), +}) +``` + +## Package Surface + +Client APIs are exported from `incur/client`. + +```ts +import { + ClientError, + createClient, + createHttpClient, + createMemoryClient, + httpTransport, + memoryTransport, +} from 'incur/client' + +import type { + Client, + ClientRpcEnvelope, + ClientRpcError, + ClientRpcMeta, + HttpClient, + HttpTransport, + MemoryClient, + MemoryTransport, +} from 'incur/client' +``` + +The root `incur` export remains available for low-level framework APIs. The client subpath keeps runtime/client concepts separate from CLI construction. + +The client creation APIs are exported only from `incur/client`. The root `incur` module must not export `createClient`, `createHttpClient`, `createMemoryClient`, `httpTransport`, or `memoryTransport`. + +Generated command types are importable as normal TypeScript types from the generated file: + +```ts +import type { Commands } from './generated/incur-client.js' +``` + +The generated file also augments client typing so projects can omit the explicit generic when they want global generated commands. See [Generated Command Maps](#generated-command-maps). + +## Rejected Shapes + +These shapes are not part of the TypeScript client contract: + +- no curried command client such as `client('project report')(input)`; +- no HTTP-only `createClient({ baseUrl })`; +- no client creation APIs exported from root `incur`; +- no data-only command result API; +- no bare async iterable stream return without `final` and `records()`; +- no chunk-only stream terminal behavior; +- no stream terminal records without full metadata; +- no RPC alias command identity; +- no local setup/admin actions over HTTP, RPC, or MCP. + +## Client Model + +`createClient` creates a typed client by resolving a transport and attaching action sets. + +```ts +type Client< + commands = Commands, + transport extends Transport = Transport, + defaults extends ClientDefaults = {}, +> = ClientBase & + RunActions & + DiscoveryActions & + ([transport] extends [MemoryTransport] ? LocalActions : {}) +``` + +Use a non-distributive conditional for `LocalActions`. A client whose transport type is the broad union `Transport` must not expose local actions just because one union member is `MemoryTransport`. + +```ts +type HttpClient = Client< + commands, + HttpTransport, + defaults +> + +type MemoryClient = Client< + commands, + MemoryTransport, + defaults +> +``` + +Client base: + +```ts +type ClientBase = { + defaults: defaults + transport: ResolvedTransport + type: 'client' + uid: string +} +``` + +`defaults` are used by actions. They are not sent to transports as opaque state; actions merge defaults into typed request objects before calling transport methods. + +Client defaults: + +```ts +type ClientDefaults = { + outputFormat?: Formatter.Format | undefined + selection?: string[] | undefined + outputTokenCount?: boolean | undefined + outputTokenLimit?: number | undefined + outputTokenOffset?: number | undefined +} +``` + +Factory types: + +```ts +type CreateClientOptions< + transport extends Transport, + defaults extends ClientDefaults, +> = defaults & { + transport: transport +} + +declare function createClient< + const commands = Commands, + const transport extends Transport = Transport, + const defaults extends ClientDefaults = {}, +>(options: CreateClientOptions): Client + +declare function createHttpClient< + const commands = Commands, + const defaults extends ClientDefaults = {}, +>(options: HttpTransportOptions & defaults): HttpClient + +declare function createMemoryClient< + const commands extends Cli.CommandsMap, + const defaults extends ClientDefaults = {}, +>( + cli: Cli.Cli, + options?: (MemoryTransportOptions & defaults) | undefined, +): MemoryClient + +declare function createMemoryClient< + const commands = Commands, + const defaults extends ClientDefaults = {}, +>( + cli: Cli.Any, + options?: (MemoryTransportOptions & defaults) | undefined, +): MemoryClient +``` + +`createMemoryClient(cli)` infers the command map from `cli` when the CLI value carries a concrete `Cli.Cli` type. Passing an explicit generic overrides inference: + +```ts +const inferred = createMemoryClient(cli) +const explicit = createMemoryClient(cli) +``` + +Explicit generics are useful when the CLI value is widened, when a generated command map is preferred, or when a permissive command map is intentionally used. + +Permissive clients are supported through an explicit unknown command map: + +```ts +type UnknownCommands = Record< + string, + { + args: unknown + options: unknown + output: unknown + } +> + +const client = createHttpClient({ baseUrl }) + +await client.run('runtime-only command', { + args: { any: 'value' }, + options: { shape: ['accepted'] }, +}) +``` + +This is an escape hatch. It disables command-name and input-shape inference for the chosen client instance only. + +Convenience factories are thin wrappers: + +```ts +function createHttpClient( + options: HttpTransportOptions & defaults, +) { + const { baseUrl, fetch, headers, ...defaults } = options + return createClient({ + ...defaults, + transport: httpTransport({ baseUrl, fetch, headers }), + }) +} + +function createMemoryClient( + cli: Cli.Any, + options: MemoryTransportOptions & defaults = {} as MemoryTransportOptions & defaults, +) { + const { env, ...defaults } = options + return createClient({ + ...defaults, + transport: memoryTransport(cli, { env }), + }) +} +``` + +## Transport Model + +Transports are factories. `createClient` invokes the transport factory and stores the resolved transport on the client. + +This mirrors viem's pattern: transport constructors such as `httpTransport(...)` return a transport factory, and `createClient` resolves that factory with client runtime context. + +```ts +type Transport = HttpTransport | MemoryTransport + +type TransportType = 'http' | 'memory' + +type TransportContext = { + uid: string +} + +type TransportConfig = { + key: string + name: string + type: type +} + +type TransportCapabilities = Record + +type TransportFactory< + type extends TransportType, + capabilities extends TransportCapabilities, +> = (context: TransportContext) => { config: TransportConfig } & capabilities +``` + +Resolved transport: + +```ts +type ResolvedTransport = ReturnType['config'] & + Omit, 'config'> +``` + +HTTP transport: + +```ts +type HttpTransport = TransportFactory< + 'http', + { + baseUrl: URL + request(request: RpcRequest): Promise + discover(request: DiscoveryRequest): Promise + } +> + +type HttpTransportOptions = { + baseUrl: string | URL + fetch?: typeof globalThis.fetch | undefined + headers?: HeadersInit | undefined +} + +declare function httpTransport(options: HttpTransportOptions): HttpTransport +``` + +`httpTransport` uses `options.fetch ?? globalThis.fetch`. If no fetch implementation exists, transport creation throws `ClientError`. Fetch and network rejections are wrapped in `ClientError` with message `RPC request failed` and the original error as `cause`. + +Memory transport: + +```ts +type MemoryTransport = TransportFactory< + 'memory', + { + request(request: RpcRequest): Promise + discover(request: DiscoveryRequest): Promise + local: LocalActionTransportApi + } +> + +type MemoryTransportOptions = { + env?: Record | undefined +} + +declare function memoryTransport( + cli: Cli.Any, + options?: MemoryTransportOptions | undefined, +): MemoryTransport +``` + +Local transport capability: + +```ts +type LocalActionTransportApi = { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} +``` + +Transport responsibilities: + +- `HttpTransport.request()` calls `POST /_incur/rpc`. +- `MemoryTransport.request()` calls the shared in-process command execution runtime. +- `HttpTransport.discover()` calls HTTP discovery routes. +- `MemoryTransport.discover()` calls shared in-process discovery builders. +- `MemoryTransport.local` calls shared local setup/admin builders. + +HTTP transport serialization rules: + +- `baseUrl` is normalized so `https://api.example.com`, `https://api.example.com/`, and `https://api.example.com/v1` produce `/_incur/rpc` under that base path. +- omitted `args` serialize as `{}`. +- omitted `options` serialize as `{}`. +- command requests use `POST`. +- request headers include `content-type: application/json`. +- request headers include `accept: application/json, application/x-ndjson`. +- custom `headers` are merged into discovery and RPC requests without removing required protocol headers unless a custom header intentionally overrides the same key. + +HTTP transport stream parsing rules: + +- match the response media type by essence; `application/x-ndjson; charset=utf-8` is NDJSON. +- parse records separated by `\n`. +- accept records split across network chunks. +- ignore blank lines. +- accept a final record without a trailing newline. +- throw `ClientError` for invalid JSON records. +- throw `ClientError` for malformed records. +- throw `ClientError` when a streaming response has no body. +- throw `ClientError` when the stream ends before a terminal `done` or `error` record. +- cancel the underlying reader when the consumer stops early. + +Memory transport execution rules: + +- memory request execution never calls `cli.fetch()`. +- memory request execution uses the same shared command runtime as HTTP RPC. +- memory request execution accepts explicit `env` from `MemoryTransportOptions`. +- memory request execution does not apply CLI config-file defaults. +- memory streams call `return()` on the command generator when the consumer stops early. + +Actions do not duplicate transport work. Actions build typed request objects, call transport capabilities, and normalize results for the public client API. + +## Action Model + +Actions are transport consumers. They are implemented as standalone functions that accept a client, then exposed as methods on client instances. + +```ts +async function run(client, command, input) { + const request = toRpcRequest(command, input, client.defaults) + const response = await client.transport.request(request) + return normalizeRunResponse(client, request, response) +} +``` + +The public method form is a bound action: + +```ts +await client.run('project report', { + args: { projectId: 'proj_web_2026' }, +}) +``` + +Action composition: + +```ts +type RunActions = { + run< + const command extends CommandId, + const input extends RunInput | undefined = undefined, + >( + command: command, + ...input: RunInputParameters + ): Promise> +} + +type DiscoveryActions = { + llms: LlmsAction + llmsFull: LlmsFullAction + schema: SchemaAction + help: HelpAction + openapi(): Promise + skills: { + index(): Promise + get(name: string): Promise + } + mcp: { + tools(): Promise> + } +} + +type LocalActions = { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} +``` + +Memory clients merge `LocalActions` into the same `skills` and `mcp` namespaces used by discovery: + +```ts +const memory = createMemoryClient(cli) + +await memory.skills.index() +await memory.skills.get('deploy') +await memory.skills.list() +await memory.skills.add() + +await memory.mcp.tools() +await memory.mcp.add() +``` + +HTTP clients do not expose local actions: + +```ts +const http = createHttpClient({ baseUrl }) + +await http.skills.index() +await http.mcp.tools() + +await http.skills.add() +// ^ type error +``` + +## Run Actions + +`client.run(command, input)` executes a leaf command by canonical command ID. + +Canonical command IDs are CLI token paths joined by single spaces: + +```ts +await client.run('project report', { + args: { projectId: 'proj_web_2026' }, + options: { includeClosed: false }, +}) +``` + +Aliases are accepted by CLI argv parsing but are not generated command IDs. Typed clients use canonical command IDs only. + +Aliases are CLI-only for typed client purposes. `client.run()` is typed against canonical command IDs, generated command maps omit aliases, and RPC requests produced by typed clients always send canonical IDs. A raw RPC request that sends an alias is not part of the typed client contract and must not be required for client correctness. + +Root command IDs: + +- a root CLI created with `Cli.create('status', { run })` has command ID `'status'`; +- a root CLI mounted on a parent keeps its own command ID, such as `'status'`, not `'app status'`; +- a router CLI mounted as a command group prefixes its leaf command IDs, such as `'project list'`; +- nested command groups flatten with single spaces, such as `'project deploy create'`. + +Run input: + +```ts +type CommandArgs> = commands[command] extends { + args: infer args +} + ? args + : unknown + +type CommandOptions> = commands[command] extends { + options: infer options +} + ? options + : unknown + +type CommandData> = commands[command] extends { + output: infer output +} + ? output + : unknown + +type RunInput> = Field< + 'args', + CommandArgs +> & + Field<'options', CommandOptions> & + OutputOptions +``` + +Required args/options determine whether the input argument itself is required. + +```ts +type RunInputParameters< + commands, + command extends CommandId, + input extends RunInput | undefined, +> = + RequiredKeys> extends never + ? [input?: input | undefined] + : [input: input & RunInput] +``` + +Run return: + +```ts +type RunReturn< + commands, + command extends CommandId, + input extends RunInput | undefined, + defaults extends ClientDefaults, +> = commands[command] extends { stream: true } + ? ClientStreamResponse< + EffectiveRunOutput, input, defaults>, + unknown, + commands + > + : ClientRunResult, input, defaults>, commands> +``` + +Non-streaming commands return a full success result. Command failures throw `ClientError`. + +```ts +type ClientRunResult = { + ok: true + data: data + output?: ClientOutput | undefined + meta: ClientMeta +} +``` + +There is no public data-only run API. Consumers use the field they need: + +```ts +const result = await client.run('status') + +result.data +result.output?.text +result.meta +``` + +## Output Controls + +Output controls are set as client defaults or per-run options. + +```ts +const client = createHttpClient({ + baseUrl, + outputFormat: 'toon', + selection: ['items[0:10]'], + outputTokenLimit: 1_000, +}) + +await client.run('project report', { + args: { projectId: 'proj_web_2026' }, + outputFormat: 'md', + outputTokenLimit: 24, +}) +``` + +Options: + +```ts +type OutputOptions = { + outputFormat?: Formatter.Format | undefined + selection?: string[] | undefined + outputTokenCount?: boolean | undefined + outputTokenLimit?: number | undefined + outputTokenOffset?: number | undefined +} +``` + +Rules: + +- `selection` applies to structured `data`. +- `outputFormat`, `outputTokenCount`, `outputTokenLimit`, and `outputTokenOffset` apply to `output`. +- Output controls never mutate `data`. +- Any effective `selection` changes returned `data` to `unknown`. +- Literal `selection: undefined` clears a client-level selection. +- Omitting `selection` preserves a client-level selection. +- A `string[] | undefined` variable is conservatively treated as selected data. +- Token controls imply formatted output. If no `outputFormat` is effective, use `toon`. +- `output.next()` reruns the same command with the next `outputTokenOffset`. + +Type behavior: + +```ts +type EffectiveRunOutput = EffectiveOutput< + output, + input extends { selection: infer selection } + ? selection + : defaults extends { selection: infer selection } + ? selection + : undefined +> + +type EffectiveOutput = [selection] extends [undefined] ? output : unknown +``` + +Client output: + +```ts +type ClientOutput = { + text: string + format?: Formatter.Format | undefined + tokenCount?: number | undefined + tokenLimit?: number | undefined + tokenOffset?: number | undefined + next?: (() => Promise>) | undefined +} +``` + +Streaming commands accept `selection` and `outputFormat`. They reject `outputTokenCount`, `outputTokenLimit`, and `outputTokenOffset` because stream pagination requires an aggregate buffering design that this API does not define. + +## CTA Model + +CTAs are normalized under `meta.cta`. + +```ts +type ClientMeta = { + command: string + duration: string + cta?: ClientCtaBlock | undefined +} + +type ClientCtaBlock = { + description?: string | undefined + commands: ClientCta[] +} +``` + +CTA commands preserve raw data and expose CLI-ready text: + +```ts +type ClientCta = + | ClientRunnableCta> + | ClientUnresolvedCta + +type ClientRunnableCta> = { + command: command + cliCommand: string + description?: string | undefined + args?: CommandArgs | undefined + options?: CommandOptions | undefined + raw: unknown + runnable: true + run( + options?: options, + ): Promise> +} + +type ClientUnresolvedCta = { + cliCommand?: string | undefined + description?: string | undefined + raw: unknown + runnable: false + unresolvedReason: 'unknown-command' | 'invalid-input' | 'unstructured' +} +``` + +`cta.run()` is equivalent to: + +```ts +client.run(cta.command, { + args: cta.args, + options: cta.options, + ...ctaRunOptions, +}) +``` + +CTA `run()` inherits client defaults. It does not inherit output controls from the command that produced the CTA. + +CTA formatting rules: + +- `cliCommand` is CLI-ready text. +- `cliCommand` includes the CLI/root command prefix exactly once. +- string CTAs are interpreted relative to the current CLI name when needed. +- structured CTA `args` render as positional values. +- structured CTA `args` with value `true` render as placeholders, such as ``. +- structured CTA `options` render as `--key value` flags. +- structured CTA `options` with value `true` render as placeholders, such as `--project-id `. +- `raw` preserves the original CTA value without normalization. + +## Streaming + +Streaming commands return a stream object, not a bare async iterable. + +```ts +const stream = await client.run('logs tail', { + args: { service: 'checkout-api' }, +}) + +for await (const chunk of stream) { + console.log(chunk) +} + +const final = await stream.final +``` + +Shape: + +```ts +type ClientStreamResponse< + chunk, + finalData = unknown, + commands = Commands, +> = AsyncIterable & { + final: Promise> + records: () => AsyncIterable> +} + +type ClientStreamFinal = { + ok: true + data?: finalData | undefined + meta: ClientMeta +} + +type ClientStreamRecord = + | { type: 'chunk'; data: chunk; output?: ClientStreamOutput | undefined } + | { type: 'done'; ok: true; data?: finalData | undefined; meta: ClientMeta } + | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } +``` + +Rules: + +- A stream is single-consumer. +- Default async iteration yields `chunk.data`. +- Default async iteration throws `ClientError` when the terminal record is `error`. +- `records()` yields normalized records and does not throw for command error records. +- `final` resolves for terminal `done`. +- `final` rejects with `ClientError` for terminal `error`. +- Every stream has exactly one terminal `done` or `error` record. + +## Discovery Actions + +Discovery actions are read-only and available on both HTTP and memory clients. + +```ts +await client.llms() +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() +``` + +Format behavior: + +- Omitted `format` returns structured data. +- Literal `format` returns formatted text. +- `format: 'json'` returns JSON text. +- Omit `format` to receive parsed structured data. + +Discovery formats: + +```ts +type DiscoveryFormat = 'md' | 'json' | 'yaml' | 'toon' + +type DiscoveryResult = [format] extends [undefined] + ? structured + : undefined extends format + ? structured | string + : string +``` + +Command scopes: + +```ts +type CommandId = keyof commands & string + +type CommandPrefix = command extends `${infer head} ${infer tail}` + ? head | `${head} ${CommandPrefix}` + : never + +type CommandScope = CommandId | CommandPrefix> +``` + +Discovery request kinds: + +```ts +type DiscoveryRequest = + | { kind: 'llms'; command?: string | undefined; format?: DiscoveryFormat | undefined } + | { kind: 'llmsFull'; command?: string | undefined; format?: DiscoveryFormat | undefined } + | { kind: 'schema'; command?: string | undefined } + | { kind: 'help'; command?: string | undefined } + | { kind: 'openapi' } + | { kind: 'skillsIndex' } + | { kind: 'skill'; name: string } + | { kind: 'mcpTools' } +``` + +`client.skills.index()` and `client.skills.get(name)` are generated-skill discovery APIs. They do not report local install status and do not install skills. + +`client.mcp.tools()` returns the MCP tool descriptors the CLI exposes through MCP `tools/list`. It does not register MCP servers. + +## OpenAPI Discovery Documents + +`client.openapi()` returns the OpenAPI document generated from the CLI command tree. + +Generation rules: + +- aliases are omitted; +- command groups are omitted as operations and only contribute their leaf commands; +- raw fetch gateways are omitted; +- root commands are included under their root command ID; +- mounted root CLIs keep their own command ID; +- mounted router CLI leaf commands are flattened; +- operation IDs are stable and derived from command IDs; +- command descriptions map to operation summaries; +- command args become path parameters where possible; +- optional args create path variants so shorter paths remain valid; +- `get` and `delete` commands use query parameters for options; +- other commands use JSON request bodies for options; +- command output schemas become success response schemas; +- error responses use the standard incur error envelope; +- response bodies use the same full envelope shape as RPC and direct HTTP command APIs. + +Generated OpenAPI documents are discovery output. They do not change the RPC command protocol, and they do not expose local setup/admin actions. + +## Local Actions + +Local actions are available only on `MemoryClient`. + +```ts +const memory = createMemoryClient(cli) + +await memory.skills.list() +await memory.skills.add({ depth: 1, global: true }) +await memory.mcp.add({ agents: ['codex'] }) +``` + +Local actions are not exposed by: + +- `HttpClient`; +- HTTP routes; +- `POST /_incur/rpc`; +- MCP tools. + +Local action options: + +```ts +type SkillsAddOptions = { + depth?: number | undefined + global?: boolean | undefined +} + +type SkillsListOptions = { + depth?: number | undefined +} + +type McpAddOptions = { + agents?: string[] | undefined + command?: string | undefined + global?: boolean | undefined +} +``` + +Local action payloads: + +```ts +type SyncedSkills = { + agents: SkillAgentInstall[] + paths: string[] + skills: SyncedSkill[] +} + +type SkillsList = { + skills: ListedSkill[] +} + +type McpRegistration = { + agents: string[] + command: string +} +``` + +Option names are TypeScript-shaped: + +- use `global?: boolean | undefined`, not `noGlobal`; +- use `agents?: string[] | undefined`, not repeated `--agent`; +- use `command?: string | undefined`, not `--command` / `-c`. + +Local action mapping: + +- `memory.skills.add()` maps to CLI `skills add`; +- `memory.skills.list()` maps to CLI `skills list`; +- `memory.mcp.add()` maps to CLI `mcp add`. + +Local action defaults: + +- `memory.skills.add()` uses the same default depth as CLI `skills add`: configured sync depth when available, otherwise `1`. +- `memory.skills.add({ depth })` maps to CLI `--depth`. +- `memory.skills.add({ global: false })` maps to CLI `--no-global`. +- `memory.skills.add({ global: true })` maps to global installation behavior. +- `memory.skills.list()` uses the same default depth as CLI `skills list`. +- `memory.skills.list({ depth })` maps to CLI `skills list --depth`. +- `memory.mcp.add()` defaults `global` to `true`. +- `memory.mcp.add({ global: false })` maps to project/local registration behavior. +- `memory.mcp.add({ agents })` maps to repeated CLI `--agent` values. +- `memory.mcp.add({ command })` maps to CLI `--command` / `-c`. + +Shell completions remain CLI-only and are not local actions. + +## RPC Protocol + +The RPC protocol is the command execution wire contract used by `HttpTransport.request()`. + +HTTP endpoint: + +```http +POST /_incur/rpc +``` + +Request: + +```ts +type RpcRequest = { + command: string + args?: Record | undefined + options?: Record | undefined + outputFormat?: Formatter.Format | undefined + selection?: string[] | undefined + outputTokenCount?: boolean | undefined + outputTokenLimit?: number | undefined + outputTokenOffset?: number | undefined +} +``` + +Response: + +```ts +type RpcResponse = RpcFullEnvelope + +type RpcFullEnvelope = + | { + ok: true + data: unknown + output?: RpcOutput | undefined + meta: RpcMeta + } + | { + ok: false + error: ClientRpcError + output?: RpcOutput | undefined + meta: RpcMeta + } + +type RpcMeta = { + command: string + duration: string + cta?: RpcCtaBlock | undefined +} + +type RpcOutput = { + text: string + format?: Formatter.Format | undefined + tokenCount?: number | undefined + tokenLimit?: number | undefined + tokenOffset?: number | undefined + nextOffset?: number | undefined +} +``` + +Validation: + +- request body must be JSON object; +- `command` must be a non-empty string; +- `args` and `options` must be objects when present; +- `selection` must be omitted or a non-empty array of non-empty strings; +- unsupported output-control combinations return `400 VALIDATION_ERROR`; +- unknown command returns `404 COMMAND_NOT_FOUND`; +- fetch gateways return `400 FETCH_GATEWAY_UNSUPPORTED`. + +Command normalization: + +- `command` is trimmed before validation. +- empty trimmed command returns `400 VALIDATION_ERROR`. +- canonical command IDs use single spaces between tokens. +- clients generated from command maps send canonical IDs. +- the shared runtime returns canonical resolved command IDs in `meta.command`. + +Structured parsing: + +- RPC uses structured parsing, distinct from CLI argv, direct HTTP path/query/body routing, and MCP flat params. +- `args` are validated only against the command args schema. +- `options` are validated only against the command options schema. +- path segments are never decoded into args for RPC. +- query strings are never decoded into options for RPC. +- MCP flat-param splitting is not used for RPC. + +Streaming request uses the same endpoint and request body. Clients advertise support for both response shapes with `Accept: application/json, application/x-ndjson`. + +Content negotiation: + +- non-streaming command results return JSON envelopes; +- streaming command results return NDJSON records; +- `Accept` advertises supported response types but does not convert a streaming command into a non-streaming response or a non-streaming command into NDJSON; +- validation errors before stream creation return JSON envelopes even when the client accepts NDJSON. + +Streaming response media type: + +```http +application/x-ndjson +``` + +Records: + +```ts +type RpcStreamRecord = + | { type: 'chunk'; data: chunk; output?: RpcStreamOutput | undefined } + | { type: 'done'; ok: true; data?: finalData | undefined; meta: RpcMeta } + | { type: 'error'; ok: false; error: ClientRpcError; meta: RpcMeta } + +type RpcStreamOutput = { + text: string + format?: Formatter.Format | undefined +} +``` + +Rules: + +- validation errors before stream start return normal JSON envelopes; +- once a stream starts, every line is one JSON record; +- every stream ends with exactly one terminal `done` or `error`; +- the HTTP transport must match media type essence and ignore parameters such as `charset=utf-8`; +- a `done` record always includes full `RpcMeta`, including `command` and `duration`; +- an `error` record always includes full `RpcMeta`, including `command` and `duration`; +- terminal stream CTAs are preserved in `meta.cta`; +- server-side HTTP cancellation calls `return()` on the command stream; +- middleware after-hooks for streaming commands run after the stream is consumed or cancelled. + +Direct command HTTP routes keep equivalent streaming behavior where applicable: + +- async generator command chunks are emitted as NDJSON; +- terminal `c.ok(..., { cta })` metadata is preserved; +- terminal `c.error()` results become terminal error records; +- thrown stream errors become terminal error records; +- response cancellation closes the command stream. + +## HTTP Discovery Routes + +`HttpTransport.discover()` uses read-only HTTP routes. + +Existing routes: + +```http +GET /openapi.json +GET /openapi.yml +GET /openapi.yaml +GET /.well-known/openapi.json +GET /.well-known/skills/index.json +GET /.well-known/skills/{name}/SKILL.md +POST /mcp +``` + +Client discovery routes: + +```http +GET /_incur/llms +GET /_incur/llms-full +GET /_incur/schema?command=project%20report +GET /_incur/help?command=project%20report +GET /_incur/mcp/tools +GET /_incur/skills +GET /_incur/skill?name=deploy +``` + +Mapping: + +```ts +client.llms() // GET /_incur/llms +client.llmsFull() // GET /_incur/llms-full +client.schema(command) // GET /_incur/schema?command=... +client.help(command) // GET /_incur/help?command=... +client.openapi() // GET /openapi.json +client.skills.index() // GET /_incur/skills +client.skills.get(name) // GET /_incur/skill?name=... +client.mcp.tools() // GET /_incur/mcp/tools +``` + +Discovery error behavior: + +- invalid query params return `400 VALIDATION_ERROR`; +- unknown commands return `404 COMMAND_NOT_FOUND`; +- unknown safe skill names return `404 SKILL_NOT_FOUND`; +- errors use JSON envelopes with `ok: false`, `error`, and discovery `meta`. + +Discovery metadata: + +```ts +type DiscoveryMeta = { + route: string + duration?: string | undefined + requestId?: string | undefined + helpRoute?: string | undefined +} +``` + +## Shared Runtime Builders + +HTTP routes and memory transports must share runtime logic. They differ only in transport serialization and process boundary. + +Shared command runtime: + +```ts +type ExecuteClientCommand = ( + cli: RuntimeCliContext, + request: RpcRequest, +) => Promise +``` + +Responsibilities: + +- validate request shape; +- resolve canonical command IDs; +- reject command groups and fetch gateways where appropriate; +- call `Command.execute()`; +- use structured args/options parsing; +- call execution with `agent: true`; +- call execution with empty `argv`; +- call execution with explicit JSON/full-output semantics; +- do not decode path/query/MCP flat params for RPC; +- preserve validation `fieldErrors`; +- preserve root command identity; +- apply selection; +- format output; +- compute token metadata; +- create pagination offsets; +- preserve CTA metadata; +- emit streaming records; +- return canonical metadata; +- close command streams on cancellation. + +Shared discovery runtime: + +```ts +type DiscoverClientResource = ( + cli: RuntimeCliContext, + request: DiscoveryRequest, +) => Promise +``` + +Responsibilities: + +- build `llms`; +- build `llmsFull`; +- build `schema`; +- build `help`; +- build `openapi`; +- build `skills.index`; +- build `skills.get`; +- build `mcp.tools`. + +Shared local runtime: + +```ts +type LocalRuntime = { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} +``` + +Implementation modules keep these boundaries explicit: + +- command graph traversal and resolution; +- command execution and output shaping; +- discovery builders; +- local setup/admin wrappers; +- HTTP serialization; +- TS client actions. + +## Generated Command Maps + +Generated command maps drive client typing. + +```ts +export type Commands = { + 'project report': { + args: { projectId: string } + options: { includeClosed?: boolean | undefined } + output: ProjectReport + } + 'logs tail': { + args: { service: string } + options: {} + output: LogLine + stream: true + } +} + +declare module 'incur' { + interface Register { + commands: Commands + } +} + +declare module 'incur/client' { + interface Register { + commands: Commands + } +} +``` + +Generated files are normal TypeScript modules. They export `Commands` so callers can import it directly, and they augment both root and client modules so default command registration works in either import style. + +Rules: + +- command IDs are canonical command paths joined by spaces; +- aliases are excluded; +- command groups are excluded from run command IDs; +- mounted sub-CLI commands are flattened into canonical IDs; +- `output` is omitted when no output schema exists; +- missing `output` infers `unknown`; +- streaming commands include `stream: true`; +- streaming command `output` is the chunk type; +- each generated command property has JSDoc that names the generated command; +- object keys that are not valid TypeScript identifiers are quoted; +- command keys are emitted with `JSON.stringify`-compatible escaping; +- optional properties include `| undefined` for `exactOptionalPropertyTypes`; +- unsupported schemas throw a typegen error instead of silently emitting `unknown`. + +Streaming detection: + +- a command is streaming when its handler is declared as an async generator function, `async *run`; +- generated type maps mark streaming commands with `stream: true`; +- generated type maps use the declared command `output` schema as the stream chunk type; +- commands that return an async generator from a non-generator `run()` are not part of the typed streaming contract; +- authors should use `async *run` whenever generated clients need streaming-aware types. + +Typegen schema support: + +- object schemas; +- optional object properties; +- string, number, integer, boolean, null, void, undefined, never, and unknown; +- literals and enums; +- unions emitted from JSON Schema `anyOf`; +- arrays, including arrays of union items; +- records, including enum-key records when JSON Schema property names allow it; +- tuples and rest tuples; +- nested objects; +- object catchalls widened into compatible index signatures; +- non-object top-level output schemas. + +Unsupported typegen inputs: + +- schemas that cannot be converted to JSON Schema; +- transforms whose output type cannot be represented from JSON Schema; +- any schema where typegen cannot produce a stable TypeScript type. + +Unsupported inputs throw `TypegenError` with a clear message. + +OpenAPI-mounted fetch gateways participate in generated command maps when they are mounted with an OpenAPI spec. Raw fetch gateways are excluded. + +Generated OpenAPI command map rules: + +- command IDs are `${mountName} ${operationName}`; +- `operationId` defines `operationName`; +- when `operationId` is absent, `operationName` is derived from method and path; +- path parameters become command `args`; +- query parameters become command `options`; +- JSON request body object properties become command `options`; +- JSON success response schema becomes command `output`; +- absent success response schema means missing `output`, which infers `unknown`; +- path-level parameters are merged with operation-level parameters; +- required path parameters are required args; +- required query parameters are required options; +- request body properties are required only when the OpenAPI request body is required and the schema property is required; +- only JSON request and response bodies are projected into command types. + +Type tests must cover: + +- `createClient` preserving transport type; +- `createHttpClient` exposing no local actions; +- `createMemoryClient` exposing local actions; +- broad `Transport` exposing no local actions; +- required input for required args/options; +- optional input for optional args/options; +- selected data becoming `unknown`; +- `selection: undefined` clearing default selection; +- streaming return shape; +- discovery overloads; +- CTA runnable typing; +- generated file module augmentation; +- memory client inference from `Cli.Cli`; +- explicit command-map overrides; +- permissive unknown command maps; +- root command IDs; +- mounted root CLI IDs; +- mounted router CLI IDs; +- OpenAPI-mounted command IDs and input/output inference; +- exact optional property emission; +- non-object output schemas; +- unsupported schema failure. + +## OpenAPI-Mounted Commands + +OpenAPI-mounted fetch handlers turn OpenAPI operations into incur command entries. + +```ts +const cli = create('acme').command('api', { + fetch: app.fetch, + openapi: spec, +}) + +const client = createMemoryClient(cli) + +await client.run('api getUser', { + args: { id: 123 }, +}) +``` + +Runtime generation rules: + +- `$ref` pointers are dereferenced before commands are generated. +- OpenAPI methods include standard HTTP methods and OpenAPI 3.2 `query`. +- path-level parameters are applied to every operation under that path. +- operation-level parameters are merged with path-level parameters. +- `operationId` is the command leaf name when present. +- fallback names are derived from method and path. +- `basePath` prefixes generated request paths. +- path parameter values are URL-encoded when requests are built. +- query parameters are written to `URLSearchParams`. +- JSON request body object properties are flattened into options. +- only `application/json` request bodies are flattened. +- the first `200` response is preferred for output schema inference. +- if no `200` response exists, the first `2xx` response is used. +- only `application/json` response schemas are converted to output schemas. +- failed HTTP responses return command errors with `HTTP_${status}` codes. + +Parameter coercion: + +- path and query numbers use numeric coercion. +- path and query booleans accept only `true` and `false` string values as booleans. +- other string values remain invalid and fail schema validation. +- body properties do not receive path/query string coercion. + +Generated OpenAPI command maps and runtime OpenAPI commands must match: every generated command ID must be callable through the shared command runtime, HTTP RPC, memory transport, and MCP tool generation when the operation is otherwise MCP-compatible. + +## Error Handling + +Command failures throw `ClientError`. + +```ts +class ClientError extends Error { + data: unknown + error: unknown + status?: number | undefined + meta?: ClientMeta | DiscoveryMeta | undefined + code?: string | undefined + retryable?: boolean | undefined + fieldErrors?: ClientRpcFieldError[] | undefined +} +``` + +RPC payload types: + +```ts +type ClientRpcMeta = { + command?: string | undefined + cta?: unknown | undefined + duration?: string | undefined +} + +type ClientRpcError = { + code: string + fieldErrors?: ClientRpcFieldError[] | undefined + message: string + retryable?: boolean | undefined +} + +type ClientRpcSuccessEnvelope = { + data?: unknown | undefined + meta?: ClientRpcMeta | undefined + ok: true +} + +type ClientRpcEnvelope = + | ClientRpcSuccessEnvelope + | { + error: ClientRpcError + meta?: ClientRpcMeta | undefined + ok: false + } +``` + +Rules: + +- `run()` returns success results only; +- failed command envelopes are preserved in `ClientError.data`; +- normalized metadata is available at `ClientError.meta`; +- error CTAs live under `ClientError.meta?.cta`; +- do not add `ClientError.cta`; +- copy `code`, `retryable`, and `fieldErrors` when available; +- preserve HTTP status for HTTP transport failures; +- malformed transport responses throw `ClientError` with diagnostic `data`. + +## Explicit Non-Support + +HTTP env injection is not supported. HTTP commands read server-side environment. + +CLI config defaults are not applied by TS clients. Clients send explicit `args` and `options`. + +Shell completions are CLI-only. Programmatic command discovery uses `DiscoveryActions`. + +HTTP clients, HTTP routes, RPC, and MCP tools do not expose local setup/admin actions: + +- no HTTP `skills add`; +- no HTTP `skills list`; +- no HTTP `mcp add`; +- no MCP tool for these commands. + +MCP tools expose command-map leaf commands and MCP tool discovery. MCP registration remains CLI or memory-client local setup. diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 466cd59..2240660 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -13,12 +13,22 @@ describe('fromCli', () => { }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: get */ + get: { args: { id: number }; options: {} } + /** Generated command: list */ + 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 +39,20 @@ describe('fromCli', () => { const cli = Cli.create('test').command('ping', { run: () => ({}) }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: ping */ + ping: { args: {}; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - ping: { args: {}; options: {} } - } + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands } } " @@ -54,12 +73,22 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: pr create */ + "pr create": { args: { title: string }; options: {} } + /** Generated command: pr list */ + "pr list": { args: {}; options: { state: string } } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { interface Register { - commands: { - "pr create": { args: { title: string }; options: {} } - "pr list": { args: {}; options: { state: string } } - } + commands: Commands } } " @@ -77,11 +106,20 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: pr review approve */ + "pr review approve": { args: { id: number }; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - "pr review approve": { args: { id: number }; options: {} } - } + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands } } " @@ -157,7 +195,7 @@ describe('fromCli', () => { .command('middle', { run: () => ({}) }) const output = Typegen.fromCli(cli) - const commandOrder = [...output.matchAll(/^ {6}(\w+):/gm)].map((m) => m[1]) + const commandOrder = [...output.matchAll(/^ {2}(\w+):/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -223,12 +261,22 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: ping */ + ping: { args: {}; options: {} } + /** Generated command: pr list */ + "pr list": { args: {}; options: {} } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { interface Register { - commands: { - ping: { args: {}; options: {} } - "pr list": { args: {}; options: {} } - } + commands: Commands } } " @@ -245,6 +293,7 @@ describe('fromCli', () => { const output = Typegen.fromCli(cli) expect(output).toContain('status: { args: {}; options: {} }') expect(output).not.toContain("'raw'") + expect(output).toContain("declare module 'incur/client'") }) test('escapes command and property keys', () => { diff --git a/src/Typegen.ts b/src/Typegen.ts index 3d92af7..7c24afc 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -5,6 +5,11 @@ import * as Cli from './Cli.js' import * as RuntimeContext from './internal/runtime-context.js' import { importCli } from './internal/utils.js' +/** Error thrown when command type generation cannot emit a stable TypeScript type. */ +export class TypegenError extends Error { + override name = 'Incur.TypegenError' +} + /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ export async function generate(input: string, output: string): Promise { const cli = await importCli(input) @@ -15,19 +20,36 @@ export async function generate(input: string, output: string): Promise { export function fromCli(cli: Cli.Cli): string { const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli)) - const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] + const lines: string[] = ['export type Commands = {'] - for (const { id, command } of entries) + for (const { id, command } of entries) { + lines.push(` /** Generated command: ${id} */`) lines.push( - ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) + } - lines.push(' }', ' }', '}', '') + lines.push( + '}', + '', + "declare module 'incur' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + "declare module 'incur/client' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + ) return lines.join('\n') } /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function objectSchemaToType(schema: z.ZodObject | undefined): string { +function objectSchemaToType(schema: z.ZodType | undefined): string { if (!schema) return '{}' return schemaToType(schema) } @@ -55,6 +77,7 @@ function resolveType( if (schema.enum) return (schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(' | ') if (schema.anyOf) return (schema.anyOf as Record[]).map((s) => resolveType(s, defs)).join(' | ') + if (schema.not && Object.keys(schema).length === 1) return 'never' const type = schema.type as string | string[] | undefined if (Array.isArray(type)) @@ -63,6 +86,8 @@ function resolveType( .join(' | ') switch (type) { + case undefined: + return 'unknown' case 'string': return 'string' case 'number': @@ -74,18 +99,34 @@ function resolveType( return 'null' case 'array': { const items = schema.items as Record | undefined + const prefixItems = schema.prefixItems as Record[] | undefined + if (prefixItems) { + const values = prefixItems.map((item) => resolveType(item, defs)) + const rest = items ? `, ...${arrayType(resolveType(items, defs))}` : '' + return `[${values.join(', ')}${rest}]` + } const itemType = items ? resolveType(items, defs) : 'unknown' - return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]` + return arrayType(itemType) } case 'object': { const properties = schema.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' + const additional = schema.additionalProperties as + | Record + | boolean + | undefined + if ((!properties || Object.keys(properties).length === 0) && additional === undefined) + return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map(([key, value]) => { + 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` }) + if (additional && typeof additional === 'object') { + const values = Object.values(properties ?? {}).map((value) => resolveType(value, defs)) + entries.push(`[key: string]: ${union([resolveType(additional, defs), ...values])}`) + } + if (additional === true) entries.push('[key: string]: unknown') return `{ ${entries.join('; ')} }` } default: @@ -93,10 +134,18 @@ function resolveType( } } -function propertyKey(key: string) { - return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +function arrayType(type: string) { + return type.includes(' | ') ? `(${type})[]` : `${type}[]` +} + +function union(types: string[]) { + return [...new Set(types)].join(' | ') } function isStream(command: Cli.CommandDefinition) { return command.run.constructor.name === 'AsyncGeneratorFunction' } + +function propertyKey(key: string) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +} diff --git a/src/client/actions/discovery.test.ts b/src/client/actions/discovery.test.ts new file mode 100644 index 0000000..9cb9ad0 --- /dev/null +++ b/src/client/actions/discovery.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from 'vitest' + +import type { Request as ResourcesRequest, Response as ResourcesResponse } from '../Resources.js' +import { createClient } from '../createClient.js' +import type * as HttpTransport from '../transports/HttpTransport.js' + +function clientWith(discover: (request: ResourcesRequest) => Promise) { + const transport = (() => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover(request: ResourcesRequest): Promise { + return discover(request) + }, + request: vi.fn(), + })) satisfies HttpTransport.HttpTransport + return createClient({ transport }) +} + +describe('discovery actions', () => { + test('routes every discovery action and preserves structured/text returns', async () => { + const discover = vi.fn(async (request) => { + if (request.resource === 'help') return { contentType: 'text/plain', body: 'help' } + if (request.resource === 'skill') return { contentType: 'text/markdown', body: '# Skill' } + return { contentType: 'application/json', data: { resource: request.resource } } + }) + const client = clientWith(discover) + + await expect(client.llms()).resolves.toEqual({ resource: 'llms' }) + await expect(client.llms({ command: 'project' as never, format: 'md' })).resolves.toEqual({ + resource: 'llms', + }) + await expect(client.llmsFull({ command: 'project' as never })).resolves.toEqual({ + resource: 'llmsFull', + }) + await expect(client.schema('project report' as never)).resolves.toEqual({ resource: 'schema' }) + await expect(client.help('project report' as never)).resolves.toBe('help') + await expect(client.openapi()).resolves.toEqual({ resource: 'openapi' }) + await expect(client.skills.index()).resolves.toEqual({ resource: 'skillsIndex' }) + await expect(client.skills.get('deploy')).resolves.toBe('# Skill') + await expect(client.mcp.tools()).resolves.toEqual({ resource: 'mcpTools' }) + + expect(discover.mock.calls.map(([request]) => request)).toEqual([ + { resource: 'llms' }, + { resource: 'llms', command: 'project', format: 'md' }, + { resource: 'llmsFull', command: 'project' }, + { resource: 'schema', command: 'project report' }, + { resource: 'help', command: 'project report' }, + { resource: 'openapi' }, + { resource: 'skillsIndex' }, + { resource: 'skill', name: 'deploy' }, + { resource: 'mcpTools' }, + ]) + }) + + test('normalizes discovery failures into ClientError fields', async () => { + const client = clientWith( + 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/discovery.ts b/src/client/actions/discovery.ts new file mode 100644 index 0000000..754165e --- /dev/null +++ b/src/client/actions/discovery.ts @@ -0,0 +1,107 @@ +import type { Request as ResourcesRequest } from '../Resources.js' +import { ClientError } from '../ClientError.js' +import type { + ActionClient, + CommandScope, + DiscoveryFormat, + McpToolsResponse, + OpenApiDocument, + SkillsIndex, +} from '../types.js' + +/** Runs compact LLM discovery. */ +export async function llms( + client: ActionClient, + options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, +): Promise { + return discover(client, { + resource: 'llms', + ...(options.command ? { command: options.command } : undefined), + ...(options.format ? { format: options.format } : undefined), + }) +} + +/** Runs full LLM discovery. */ +export async function llmsFull( + client: ActionClient, + options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, +): Promise { + return discover(client, { + resource: 'llmsFull', + ...(options.command ? { command: options.command } : undefined), + ...(options.format ? { format: options.format } : undefined), + }) +} + +/** Reads a command schema. */ +export async function schema( + client: ActionClient, + command?: CommandScope | undefined, +): Promise> { + return discover(client, { + resource: 'schema', + ...(command ? { command } : undefined), + }) as Promise> +} + +/** Reads help text. */ +export async function help( + client: ActionClient, + command?: 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 +} + +async function discover(client: ActionClient, request: ResourcesRequest): 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 : 'DISCOVERY_ERROR', + message: error instanceof Error ? error.message : String(error), + }, + meta: { resource: request.resource }, + } + : undefined + throw new ClientError(error instanceof Error ? error.message : 'Discovery request failed', { + cause: error instanceof Error ? error : undefined, + code: isRecord(error) && typeof error.code === 'string' ? error.code : 'DISCOVERY_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/local.test.ts b/src/client/actions/local.test.ts new file mode 100644 index 0000000..8ece2e5 --- /dev/null +++ b/src/client/actions/local.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from 'vitest' + +import { createClient } from '../createClient.js' +import type * as MemoryTransport from '../transports/MemoryTransport.js' + +function memoryClient() { + const transport = (() => ({ + config: { key: 'memory', name: 'Memory', type: 'memory' as const }, + discover: vi.fn(async () => ({ contentType: 'application/json', data: {} })), + request: vi.fn(), + local: { + skills: { + add: vi.fn(async (options) => ({ + agents: [], + paths: [], + skills: [{ name: 'deploy' }], + options, + })), + list: vi.fn(async () => [{ description: 'Deploy', installed: false, name: 'deploy' }]), + }, + mcp: { + add: vi.fn(async (options) => ({ agents: options?.agents ?? [], command: 'pnpm app' })), + }, + }, + })) satisfies MemoryTransport.MemoryTransport + return createClient<{}, MemoryTransport.MemoryTransport>({ transport }) +} + +describe('local actions', () => { + test('memory local actions delegate and coexist with discovery namespaces', async () => { + const client = memoryClient() + + await expect(client.skills.index()).resolves.toEqual({}) + await expect(client.mcp.tools()).resolves.toEqual({}) + await expect(client.skills.add({ depth: 1, global: true })).resolves.toMatchObject({ + skills: [{ name: 'deploy' }], + options: { depth: 1, global: true }, + }) + await expect(client.skills.list()).resolves.toEqual({ + skills: [{ description: 'Deploy', installed: false, name: 'deploy' }], + }) + await expect(client.mcp.add({ agents: ['codex'] })).resolves.toEqual({ + agents: ['codex'], + command: 'pnpm app', + }) + }) +}) diff --git a/src/client/actions/local.ts b/src/client/actions/local.ts new file mode 100644 index 0000000..9754b71 --- /dev/null +++ b/src/client/actions/local.ts @@ -0,0 +1,29 @@ +import type { ActionClient, McpAddOptions, SkillsAddOptions, SkillsListOptions } from '../types.js' + +/** Runs memory-local `skills add`. */ +export function skillsAdd(client: ActionClient, options?: SkillsAddOptions | undefined) { + return local(client).skills.add(options) +} + +/** Runs memory-local `skills list`. */ +export async function skillsList(client: ActionClient, options?: SkillsListOptions | undefined) { + const result = await local(client).skills.list(options) + return Array.isArray(result) ? { skills: result } : result +} + +/** Runs memory-local `mcp add`. */ +export function mcpAdd(client: ActionClient, options?: McpAddOptions | undefined) { + return local(client).mcp.add(options) +} + +function local(client: ActionClient) { + return client.transport.local as { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } + } +} diff --git a/src/client/actions/run.test.ts b/src/client/actions/run.test.ts new file mode 100644 index 0000000..7dadec0 --- /dev/null +++ b/src/client/actions/run.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test, vi } from 'vitest' + +import type { + Request as RpcRequest, + Response as RpcResponse, + StreamResponse as RpcStreamResponse, +} from '../Rpc.js' +import { createClient } from '../createClient.js' +import { ClientError } from '../ClientError.js' +import type * as HttpTransport from '../transports/HttpTransport.js' + +function clientWith(request: (request: RpcRequest) => Promise) { + type Commands = { + deploy: { args: {}; options: {}; output: {} } + list: { args: {}; options: {}; output: { page: number } } + report: { args: {}; options: {}; output: {} } + status: { args: {}; options: {}; output: { ok: boolean } } + unblock: { + args: { taskId: string } + options: { dryRun?: boolean | undefined } + output: { unblocked: boolean } + } + } + 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 createClient({ + outputFormat: 'toon', + selection: ['items[0]'], + transport, + }) +} + +describe('run action', () => { + test('merges defaults with per-call output controls and clears selection with undefined', async () => { + const request = vi.fn( + async (_request: RpcRequest): Promise => ({ + ok: true, + data: { ok: true }, + output: { text: 'ok' }, + meta: { command: 'status', duration: '1ms' }, + }), + ) + const client = clientWith(request) + + await client.run('status', { + outputFormat: 'md', + selection: undefined, + outputTokenLimit: 24, + }) + + expect(request).toHaveBeenCalledWith({ + 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' }, + }), + ) + const client = clientWith(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, + }) + 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 request = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + data: { page: 1 }, + output: { text: 'one' }, + meta: { command: 'list', duration: '1ms', nextOffset: 5, outputTokenCount: 10 }, + }) + .mockResolvedValueOnce({ + ok: true, + data: { page: 2 }, + output: { text: 'two' }, + meta: { command: 'list', duration: '1ms', outputTokenCount: 10 }, + }) + const client = clientWith(request) + const result = await client.run('list', { outputTokenLimit: 5 }) + + expect(result.output).toMatchObject({ text: 'one', tokenCount: 10, tokenLimit: 5 }) + await expect(result.output?.next?.()).resolves.toMatchObject({ data: { page: 2 } }) + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ command: 'list', outputTokenOffset: 5 }), + ) + }) + + test('normalizes CTA metadata and cta.run inherits client defaults only', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + data: {}, + meta: { + command: 'report', + duration: '1ms', + cta: { + commands: [ + { + command: 'unblock', + args: { taskId: 't1' }, + options: { dryRun: true }, + description: 'Unblock task', + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + ok: true, + data: { unblocked: true }, + meta: { command: 'unblock', duration: '1ms' }, + }) + const client = clientWith(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 ', + runnable: true, + raw: expect.any(Object), + }) + if (!cta?.runnable) throw new Error('expected runnable CTA') + await expect(cta.run()).resolves.toMatchObject({ data: { unblocked: true } }) + expect(request).toHaveBeenLastCalledWith( + expect.objectContaining({ command: 'unblock', outputFormat: 'toon' }), + ) + }) +}) diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts new file mode 100644 index 0000000..74c8e0b --- /dev/null +++ b/src/client/actions/run.ts @@ -0,0 +1,344 @@ +import type { + Envelope as RpcFullEnvelope, + Meta as RpcMeta, + Request as RpcRequest, + Response as RpcResponse, + StreamRecord as RpcStreamRecord, + StreamResponse as RpcStreamResponse, +} from '../Rpc.js' +import { ClientError } from '../ClientError.js' +import type { + ActionClient, + ClientCta, + ClientCtaBlock, + ClientMeta, + ClientOutput, + ClientRunResult, + ClientStreamFinal, + ClientStreamRecord, + ClientStreamResponse, + OutputOptions, +} from '../types.js' + +/** Executes a command through a client transport. */ +export async function run( + client: ActionClient, + command: string, + input: (OutputOptions & { args?: unknown; options?: unknown }) | 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) +} + +function toRequest( + defaults: OutputOptions, + command: string, + input: (OutputOptions & { args?: unknown; options?: unknown }) | 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, +): ClientRunResult { + if (!response.ok) throw errorFromEnvelope(client, response) + return { + ok: true, + data: response.data, + ...(response.output ? { output: output(client, request, response) } : undefined), + meta: normalizeMeta(client, response.meta), + } +} + +function output( + client: ActionClient, + request: RpcRequest, + response: Extract, +): ClientOutput { + const nextOffset = + (response.output as { nextOffset?: number | undefined } | undefined)?.nextOffset ?? + response.meta.nextOffset + return { + text: response.output?.text ?? '', + ...(response.output?.format !== undefined ? { format: response.output.format } : undefined), + ...(response.output?.tokenCount !== undefined + ? { tokenCount: response.output.tokenCount } + : response.meta.outputTokenCount !== undefined + ? { tokenCount: response.meta.outputTokenCount } + : undefined), + ...(response.output?.tokenLimit !== undefined + ? { tokenLimit: response.output.tokenLimit } + : request.outputTokenLimit !== undefined + ? { tokenLimit: request.outputTokenLimit } + : undefined), + ...(response.output?.tokenOffset !== undefined + ? { tokenOffset: response.output.tokenOffset } + : request.outputTokenOffset !== undefined + ? { tokenOffset: request.outputTokenOffset } + : undefined), + ...(nextOffset !== undefined + ? { + next: () => + normalizeNext(client, { + ...request, + outputTokenOffset: 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, +): ClientStreamResponse { + let mode: 'chunks' | 'records' | 'final' | undefined + let terminal: ClientStreamFinal | ClientError | undefined + let resolveFinal: ((value: ClientStreamFinal) => 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): ClientStreamRecord { + if (record.type === 'chunk') return record + if (record.type === 'done') + return { + type: 'done', + ok: true, + ...('data' in record ? { data: record.data } : undefined), + meta: meta(record.meta), + } + return { + type: 'error', + ok: false, + error: record.error, + meta: meta(record.meta), + } + } + + function meta(value: RpcMeta): ClientMeta { + 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): ClientMeta { + return { + command: value.command, + duration: value.duration, + ...(value.cta ? { cta: ctaBlock(client, value.cta) } : undefined), + } +} + +function ctaBlock(client: ActionClient | undefined, value: unknown): ClientCtaBlock { + const block = isRecord(value) ? value : {} + const commands = Array.isArray(block.commands) ? block.commands : [] + return { + ...(typeof block.description === 'string' ? { description: block.description } : undefined), + commands: commands.map((command) => cta(client, command)), + } +} + +function cta(client: ActionClient | undefined, value: unknown): ClientCta { + 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 { raw, runnable: false, unresolvedReason: 'unstructured' } +} + +function runnableCta( + client: ActionClient | undefined, + value: Record, + raw: unknown, +): ClientCta { + 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, + runnable: true, + run(optionsOverride?: OutputOptions) { + if (!client) throw new ClientError('CTA is not attached to a client.') + return run(client, command, { args, options, ...optionsOverride }) as Promise + }, + } satisfies ClientCta + 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/api-example.test-d.ts b/src/client/api-example.test-d.ts new file mode 100644 index 0000000..a711043 --- /dev/null +++ b/src/client/api-example.test-d.ts @@ -0,0 +1,127 @@ +import { create } from 'incur' +import { + ClientError, + HttpTransport, + MemoryTransport, + createClient, + createHttpClient, + createMemoryClient, +} from 'incur/client' +import { expectTypeOf, test } from 'vitest' + +type Commands = { + 'project report': { + args: { projectId: string } + options: { includeClosed?: boolean | undefined } + output: { + summary: string + items: { id: string; title: string }[] + nextCursor?: string | undefined + } + } + 'project status': { + args: { projectId: string } + options: {} + output: { status: 'open' | 'blocked' | 'done' } + } + 'project unblock': { + args: { taskId: string } + options: {} + output: { ok: boolean } + } + 'project deploy': { + args: { projectId: string; environment: 'production' | 'staging' } + options: {} + output: { deployId: string } + } + 'auth login': { + args: {} + options: {} + output: { authenticated: boolean } + } + 'logs tail': { + args: { service: string } + options: {} + output: { timestamp: string; level: string; message: string } + stream: true + } +} + +test('docs api example client surface typechecks conceptually', async () => { + const fetcher = (() => Promise.resolve(new Response('{}'))) as typeof fetch + const client = createHttpClient({ + baseUrl: 'https://ops.acme.test', + fetch: fetcher, + outputFormat: 'toon', + }) + + createClient({ + transport: HttpTransport.create({ baseUrl: 'https://ops.acme.test' }), + outputFormat: 'toon', + }) + + const cli = create({ name: 'acme' }) + const memoryClient = createMemoryClient(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, + }) + createClient({ + transport: MemoryTransport.create(cli, { env: { ACME_TOKEN: 'dev_secret_123' } }), + }) + + 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: 24, + }) + expectTypeOf(report.data).toEqualTypeOf() + await report.output?.next?.() + + const status = await client.run('project status', { args: { projectId: 'proj_web_2026' } }) + expectTypeOf(status.data.status).toEqualTypeOf<'open' | 'blocked' | 'done'>() + + const cta = report.meta.cta?.commands[0] + if (cta?.runnable) { + expectTypeOf(cta.command).toMatchTypeOf() + await cta.run({ outputFormat: 'toon' }) + } + + try { + await client.run('project deploy', { + args: { projectId: 'proj_web_2026', environment: 'production' }, + }) + } catch (error) { + if (error instanceof ClientError) { + const clientError = error as ClientError + expectTypeOf(clientError.error?.code).toEqualTypeOf() + } + } + + const stream = await client.run('logs tail', { args: { service: 'checkout-api' } }) + for await (const chunk of stream) expectTypeOf(chunk.message).toEqualTypeOf() + expectTypeOf((await stream.final).meta.command).toEqualTypeOf() + for await (const record of stream.records()) + if (record.type === 'chunk') expectTypeOf(record.data.message).toEqualTypeOf() + + const llmsFull = await client.llmsFull({ command: 'project' }) + expectTypeOf(llmsFull.commands[0]?.name).toMatchTypeOf() + const llmsMd = await client.llms({ command: 'project', format: 'md' }) + expectTypeOf(llmsMd).toEqualTypeOf() + const schema = await client.schema('project report') + expectTypeOf(schema.args).toMatchTypeOf | undefined>() + expectTypeOf(await client.help('project report')).toEqualTypeOf() + expectTypeOf((await client.openapi()).info).toMatchTypeOf | undefined>() + expectTypeOf((await client.skills.index()).skills[0]?.name).toEqualTypeOf() + expectTypeOf(await client.skills.get('deploy')).toEqualTypeOf() + expectTypeOf((await client.mcp.tools()).tools[0]).toMatchTypeOf< + Record | undefined + >() + + await memoryClient.skills.list() + await memoryClient.skills.add({ depth: 1, global: true }) + await memoryClient.mcp.add({ agents: ['codex'] }) + // @ts-expect-error local actions are memory-only. + client.skills.add() +}) diff --git a/src/client/createClient.test.ts b/src/client/createClient.test.ts new file mode 100644 index 0000000..88c9e2b --- /dev/null +++ b/src/client/createClient.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, vi } from 'vitest' + +import * as Cli from '../Cli.js' +import type { + Request as RpcRequest, + Response as RpcResponse, + StreamResponse as RpcStreamResponse, +} from './Rpc.js' +import { ClientError } from './ClientError.js' +import { createClient, createHttpClient, createMemoryClient } from './createClient.js' +import * as HttpTransport from './transports/HttpTransport.js' + +function mockTransport(): HttpTransport.HttpTransport { + return (ctx) => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover: vi.fn(), + request: vi.fn( + async (_request: RpcRequest): Promise => ({ + ok: true, + data: { uid: ctx.uid }, + meta: { command: 'status', duration: '1ms' }, + }), + ), + }) +} + +describe('createClient', () => { + test('resolves transport, assigns uid, preserves defaults, and binds actions', async () => { + const client = createClient({ + outputFormat: 'toon', + transport: mockTransport(), + }) + + expect(client).toMatchObject({ + defaults: { outputFormat: 'toon' }, + transport: { key: 'mock', name: 'Mock', type: 'http' }, + type: 'client', + }) + expect(client.uid).toEqual(expect.any(String)) + await expect(client.run('status' as never)).resolves.toMatchObject({ + ok: true, + data: { uid: client.uid }, + }) + }) + + test('createHttpClient 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 = createHttpClient({ 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('createMemoryClient uses memory transport and exposes local actions', () => { + const cli = Cli.create('app') + const client = createMemoryClient(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 = createClient({ + 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('missing fetch implementation throws ClientError', () => { + const original = globalThis.fetch + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: undefined }) + try { + expect(() => createHttpClient({ baseUrl: 'https://example.com' })).toThrow(ClientError) + } finally { + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: original }) + } + }) +}) diff --git a/src/client/createClient.ts b/src/client/createClient.ts new file mode 100644 index 0000000..6fecf9c --- /dev/null +++ b/src/client/createClient.ts @@ -0,0 +1,140 @@ +import type * as Cli from '../Cli.js' +import * as discovery from './actions/discovery.js' +import * as local from './actions/local.js' +import { run } from './actions/run.js' +import * as HttpTransport from './transports/HttpTransport.js' +import * as MemoryTransport from './transports/MemoryTransport.js' +import type { + AnyCli, + Client, + ClientDefaults, + Commands, + CreateClientOptions, + HttpClient, + MemoryClient, + Transport, +} from './types.js' + +/** Creates a typed client from a transport factory. */ +export function createClient< + const commands = Commands, + const transport extends Transport = Transport, + const defaults extends ClientDefaults = {}, +>(options: CreateClientOptions): Client { + const { transport, ...defaults } = options + const uid = uidValue() + const resolved = transport({ uid }) + const { config, ...capabilities } = resolved + const client = { + defaults, + transport: { ...config, ...capabilities }, + type: 'client', + uid, + } as unknown as Client + + return attachActions(client) as Client +} + +/** Creates an HTTP typed client. */ +export function createHttpClient< + const commands = Commands, + const defaults extends ClientDefaults = {}, +>( + options: HttpTransport.Options & defaults & ClientDefaults, +): HttpClient { + const { baseUrl, fetch, headers, ...defaults } = options + return createClient({ + ...defaults, + transport: HttpTransport.create({ + baseUrl, + ...(fetch ? { fetch } : undefined), + ...(headers ? { headers } : undefined), + }), + } as HttpTransport.Options & defaults & { transport: HttpTransport.HttpTransport }) +} + +/** Creates a memory typed client and infers commands from a concrete CLI. */ +export function createMemoryClient< + const commands extends Cli.CommandsMap, + const defaults extends ClientDefaults = {}, +>( + cli: Cli.Cli, + options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, +): MemoryClient +/** Creates a memory typed client with an explicit command map. */ +export function createMemoryClient< + const commands = Commands, + const defaults extends ClientDefaults = {}, +>( + cli: AnyCli, + options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, +): MemoryClient +export function createMemoryClient( + cli: AnyCli, + options: MemoryTransport.Options & ClientDefaults = {}, +): MemoryClient { + const { env, ...defaults } = options + return createClient({ + ...defaults, + transport: MemoryTransport.create(cli, { env }), + }) +} + +function attachActions(client: client): client { + Object.assign(client, { + run(command: string, input?: unknown) { + return run(client as never, command, input as never) + }, + llms(options?: unknown) { + return discovery.llms(client as never, options as never) + }, + llmsFull(options?: unknown) { + return discovery.llmsFull(client as never, options as never) + }, + schema(command?: string | undefined) { + return discovery.schema(client as never, command) + }, + help(command?: string | undefined) { + return discovery.help(client as never, command) + }, + openapi() { + return discovery.openapi(client as never) + }, + skills: { + index() { + return discovery.skillsIndex(client as never) + }, + get(name: string) { + return discovery.skill(client as never, name) + }, + }, + mcp: { + tools() { + return discovery.mcpTools(client as never) + }, + }, + }) + + if ('transport' in client && 'local' in (client as { transport: object }).transport) { + Object.assign((client as unknown as { skills: object }).skills, { + add(options?: unknown) { + return local.skillsAdd(client as never, options as never) + }, + list(options?: unknown) { + return local.skillsList(client as never, options as never) + }, + }) + Object.assign((client as unknown as { mcp: object }).mcp, { + add(options?: unknown) { + return local.mcpAdd(client as never, options as never) + }, + }) + } + + return client +} + +function uidValue() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() + return `client_${Math.random().toString(36).slice(2)}` +} diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts new file mode 100644 index 0000000..11a605f --- /dev/null +++ b/src/client/index.test-d.ts @@ -0,0 +1,151 @@ +import { Cli, z } from 'incur' +import { + HttpTransport, + MemoryTransport, + createClient, + createHttpClient, + createMemoryClient, +} from 'incur/client' +import type { + Client, + ClientRunResult, + ClientStreamResponse, + HttpClient, + MemoryClient, + Transport, +} 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 + } +} + +test('client creation preserves transport type and defaults', () => { + const http = createHttpClient({ + baseUrl: 'https://example.com', + outputFormat: 'toon', + }) + expectTypeOf(http).toMatchTypeOf>() + expectTypeOf(http.transport.type).toEqualTypeOf<'http'>() + + const primitive = createClient({ + transport: HttpTransport.create({ baseUrl: 'https://example.com' }), + }) + expectTypeOf(primitive).toMatchTypeOf>() +}) + +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 = createMemoryClient(cli) + expectTypeOf(inferred).toMatchTypeOf< + MemoryClient<{ status: { args: { id: string }; options: {} } }> + >() + + const explicit = createMemoryClient(cli) + expectTypeOf(explicit).toMatchTypeOf>() +}) + +test('local actions are memory-only and unavailable on HTTP or broad transports', () => { + const http = createHttpClient({ 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 = createMemoryClient(cli) + expectTypeOf(memory.skills.add).toBeFunction() + expectTypeOf(memory.skills.list).toBeFunction() + expectTypeOf(memory.mcp.add).toBeFunction() + + const broad = createClient({ + 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 = createHttpClient({ 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 = createClient< + 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('discovery overloads and permissive command maps', async () => { + const client = createHttpClient({ baseUrl: 'https://example.com' }) + expectTypeOf(await client.llms()).toMatchTypeOf<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: 'md' })).toEqualTypeOf() + const format = undefined as 'md' | undefined + expectTypeOf(await client.llms({ format })).toMatchTypeOf() + await client.llmsFull({ command: 'project' }) + // @ts-expect-error unknown discovery scope. + await client.llmsFull({ command: 'unknown' }) + await client.schema('project') + await client.help('project report') + + type UnknownCommands = Record + const loose = createHttpClient({ 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 index 577800e..e98cae8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,7 +1,84 @@ export { ClientError } from './ClientError.js' -export * as Resources from './Resources.js' +export { createClient, createHttpClient, createMemoryClient } from './createClient.js' export * as HttpTransport from './transports/HttpTransport.js' export * as Local from './Local.js' export * as MemoryTransport from './transports/MemoryTransport.js' +export * as Resources from './Resources.js' export * as Rpc from './Rpc.js' export * as Transport from './transports/Transport.js' +export type { + Client, + ClientBase, + ClientCta, + ClientCtaBlock, + ClientCtaRunOptions, + ClientDefaults, + ClientMeta, + ClientOutput, + ClientRpcEnvelope, + ClientRpcError, + ClientRpcMeta, + ClientRunResult, + ClientStreamFinal, + ClientStreamOutput, + ClientStreamRecord, + ClientStreamResponse, + CommandArgs, + CommandData, + CommandId, + CommandOptions, + CommandScope, + Commands, + CommandsMap, + CreateClientOptions, + DiscoveryActions, + DiscoveryFormat, + DiscoveryResult, + EffectiveOutput, + EffectiveRunOutput, + HttpClient, + LlmsAction, + LlmsFullAction, + LlmsFullManifest, + LlmsManifest, + LocalActions, + McpToolsResponse, + MemoryClient, + OpenApiDocument, + OutputOptions, + Register, + ResolvedTransport, + RunActions, + RunInput, + RunInputParameters, + RunReturn, + SkillsIndex, + StrictInput, +} from './types.js' +export type { + McpAddOptions, + McpRegistration, + SkillsAddOptions, + SkillsList, + SkillsListOptions, + SyncedSkills, +} from './Local.js' +export type { + Request as ResourcesRequest, + Response as ResourcesResponse, +} from './Resources.js' +export type { + Envelope as RpcEnvelope, + Meta as RpcMeta, + Output as RpcOutput, + Request as RpcRequest, + Response as RpcResponse, + StreamRecord as RpcStreamRecord, + StreamResponse as RpcStreamResponse, +} from './Rpc.js' +export type { HttpTransport, Options as HttpTransportOptions } from './transports/HttpTransport.js' +export type { + MemoryTransport, + Options as MemoryTransportOptions, +} from './transports/MemoryTransport.js' +export type { Factory as TransportFactory } from './transports/Transport.js' diff --git a/src/client/package-exports.test.ts b/src/client/package-exports.test.ts new file mode 100644 index 0000000..d2cbc48 --- /dev/null +++ b/src/client/package-exports.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' + +import packageJson from '../../package.json' with { type: 'json' } + +describe('client package exports', () => { + test('package exposes client subpath and keeps root separate', () => { + expect(packageJson.exports['./client']).toMatchObject({ + types: './dist/client/index.d.ts', + src: './src/client/index.ts', + default: './dist/client/index.js', + }) + expect(packageJson.exports['.']).toMatchObject({ + types: './dist/index.d.ts', + src: './src/index.ts', + default: './dist/index.js', + }) + }) +}) diff --git a/src/client/stream.test.ts b/src/client/stream.test.ts new file mode 100644 index 0000000..2dfe85c --- /dev/null +++ b/src/client/stream.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, vi } from 'vitest' + +import type { + Request as RpcRequest, + Response as RpcResponse, + StreamRecord as RpcStreamRecord, + StreamResponse as RpcStreamResponse, +} from './Rpc.js' +import { ClientError } from './ClientError.js' +import { createClient } from './createClient.js' +import type * as HttpTransport from './transports/HttpTransport.js' + +function streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { + type Commands = { + logs: { args: {}; options: {}; output: unknown; stream: true } + } + 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)! + try { + for (const record of records) yield record + return terminal + } finally { + onReturn() + } + }, + } + }, + })) satisfies HttpTransport.HttpTransport + return createClient({ transport }) +} + +describe('ClientStreamResponse', () => { + test('default async iteration yields chunks and final resolves terminal metadata', async () => { + const client = streamClient([ + { type: 'chunk', data: { line: 1 } }, + { type: 'chunk', data: { line: 2 } }, + { type: 'done', ok: true, data: { lines: 2 }, meta: { command: 'logs', duration: '2ms' } }, + ]) + 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 }, + meta: { command: 'logs' }, + }) + }) + + test('records yields terminal errors without throwing, while iteration and final throw', async () => { + const terminal = { + type: 'error' as const, + ok: false as const, + error: { code: 'DISCONNECTED', message: 'Disconnected.' }, + meta: { command: 'logs', duration: '2ms' }, + } + const recordsStream = await streamClient([{ type: 'chunk', data: 1 }, terminal]).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 streamClient([{ type: 'chunk', data: 1 }, terminal]).run('logs') + await expect(async () => { + for await (const _ of iterStream as AsyncIterable) { + } + }).rejects.toThrow(ClientError) + + const finalStream = await streamClient([terminal]).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( + [ + { type: 'chunk', data: 1 }, + { type: 'done', ok: true, data: undefined, meta: { command: 'logs', duration: '1ms' } }, + ], + onReturn, + ).run('logs') + + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: 1 }) + expect(() => stream.records()).toThrow(ClientError) + await iterator.return?.() + expect(onReturn).toHaveBeenCalled() + }) +}) diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..4c14a54 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,523 @@ +import type * as Cli from '../Cli.js' +import type * as Formatter from '../Formatter.js' +import type { + McpAddOptions, + McpRegistration, + SkillsAddOptions, + SkillsListOptions, + SyncedSkills, +} from './Local.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 { + Request as ResourcesRequest, + Response as ResourcesResponse, +} from './Resources.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 transports. */ +export type Transport = HttpTransport | MemoryTransport + +/** Resolved transport value attached to a client. */ +export type ResolvedTransport = ReturnType['config'] & + Omit, 'config'> + +/** Client defaults used by run actions. */ +export type ClientDefaults = { + /** 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 ClientBase = { + /** Defaults applied by actions before transport requests. */ + defaults: defaults + /** Resolved transport metadata and capabilities. */ + transport: ResolvedTransport + /** Client discriminator. */ + type: 'client' + /** Unique client id. */ + uid: string +} + +/** Typed client instance. */ +export type Client< + commands = Commands, + transport extends Transport = Transport, + defaults extends ClientDefaults = {}, +> = ClientBase & + RunActions & + DiscoveryActions & + ([transport] extends [MemoryTransport] ? LocalActions : {}) + +/** HTTP client instance. */ +export type HttpClient = Client< + commands, + HttpTransport, + defaults +> + +/** Memory client instance. */ +export type MemoryClient = Client< + commands, + MemoryTransport, + defaults +> + +/** Options for `createClient`. */ +export type CreateClientOptions< + transport extends Transport, + defaults extends ClientDefaults, +> = defaults & + ClientDefaults & { + /** Transport factory to resolve. */ + transport: transport + } + +/** Canonical command id. */ +export type CommandId = keyof commands & string + +/** Command prefix usable by discovery actions. */ +export type CommandPrefix = command extends `${infer head} ${infer tail}` + ? head | `${head} ${CommandPrefix}` + : never + +/** Command or command-group scope usable by discovery actions. */ +export type CommandScope = CommandId | CommandPrefix> + +/** Command args type. */ +export type CommandArgs> = commands[command] extends { + args: infer args +} + ? args + : unknown + +/** Command options type. */ +export type CommandOptions< + commands, + command extends CommandId, +> = commands[command] extends { options: infer options } ? options : unknown + +/** Command output data type. */ +export type CommandData> = 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 } + +/** Output controls for command runs. */ +export type OutputOptions = ClientDefaults + +/** Run input for a command. */ +export type RunInput> = Field< + 'args', + CommandArgs +> & + Field<'options', CommandOptions> & + (commands[command] extends { stream: true } + ? Omit + : OutputOptions) + +/** Run input parameter tuple. */ +export type RunInputParameters< + commands, + command extends CommandId, + input extends RunInput | undefined, +> = + RequiredKeys> extends never + ? [input?: StrictInput> | undefined] + : [input: StrictInput> & RunInput] + +/** Rejects keys outside an expected input shape. */ +export type StrictInput = input extends undefined + ? undefined + : input & { [key in Exclude]: never } + +/** 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 RunReturn< + commands, + command extends CommandId, + input extends RunInput | undefined, + defaults extends ClientDefaults, +> = commands[command] extends { stream: true } + ? ClientStreamResponse< + EffectiveRunOutput, input, defaults>, + unknown, + commands + > + : ClientRunResult, input, defaults>, commands> + +/** Run action set. */ +export type RunActions = { + run< + const command extends CommandId, + const input extends RunInput | undefined = undefined, + >( + command: command, + ...input: RunInputParameters + ): Promise> +} + +/** Successful non-streaming command result. */ +export type ClientRunResult = { + /** Success discriminator. */ + ok: true + /** Structured command data. */ + data: data + /** Rendered output text and pagination controls. */ + output?: ClientOutput | undefined + /** Command metadata. */ + meta: ClientMeta +} + +/** Rendered command output. */ +export type ClientOutput = { + /** 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 ClientMeta = { + /** Canonical command id. */ + command: string + /** Wall-clock duration. */ + duration: string + /** Normalized call-to-action metadata. */ + cta?: ClientCtaBlock | undefined +} + +/** CTA block. */ +export type ClientCtaBlock = { + /** CTA block description. */ + description?: string | undefined + /** CTA commands. */ + commands: ClientCta[] +} + +/** CTA command. */ +export type ClientCta = + | ClientRunnableCta> + | ClientUnresolvedCta + +/** Runnable CTA command. */ +export type ClientRunnableCta> = { + /** Canonical command id. */ + command: command + /** CLI-ready command text. */ + cliCommand: string + /** CTA description. */ + description?: string | undefined + /** Structured args. */ + args?: CommandArgs | undefined + /** Structured options. */ + options?: CommandOptions | undefined + /** Raw source CTA. */ + raw: unknown + /** Runnable discriminator. */ + runnable: true + run( + options?: options, + ): Promise> +} + +/** Unresolved CTA command. */ +export type ClientUnresolvedCta = { + /** CLI-ready command text when one could be derived. */ + cliCommand?: string | undefined + /** CTA description. */ + description?: string | undefined + /** Raw source CTA. */ + raw: unknown + /** Runnable discriminator. */ + runnable: false + /** Reason the CTA could not be converted into a typed run action. */ + unresolvedReason: 'unknown-command' | 'invalid-input' | 'unstructured' +} + +/** CTA run output controls. */ +export type ClientCtaRunOptions = OutputOptions + +/** CTA run return type. */ +export type CtaRunReturn< + commands, + command extends CommandId, + options extends ClientCtaRunOptions | undefined, +> = RunReturn, {}> + +/** Stream response wrapper. */ +export type ClientStreamResponse< + chunk, + finalData = unknown, + commands = Commands, +> = AsyncIterable & { + /** Terminal stream result. */ + final: Promise> + /** Iterates over chunk and terminal records. */ + records(): AsyncIterable> +} + +/** Successful terminal stream result. */ +export type ClientStreamFinal = { + /** Success discriminator. */ + ok: true + /** Terminal structured data. */ + data?: finalData | undefined + /** Terminal metadata. */ + meta: ClientMeta +} + +/** Stream output attached to a chunk. */ +export type ClientStreamOutput = { + /** Rendered chunk text. */ + text: string + /** Rendered chunk format. */ + format?: Formatter.Format | undefined +} + +/** Normalized stream record. */ +export type ClientStreamRecord = + | { type: 'chunk'; data: chunk; output?: ClientStreamOutput | undefined } + | { type: 'done'; ok: true; data?: finalData | undefined; meta: ClientMeta } + | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } + +/** Discovery format. */ +export type DiscoveryFormat = 'md' | 'json' | 'yaml' | 'toon' + +/** Discovery result for a structured type and format option. */ +export type DiscoveryResult = [format] extends [undefined] + ? structured + : undefined extends format + ? structured | string + : string + +/** LLM manifest. */ +export type LlmsManifest< + commands = Commands, + scope extends CommandScope | undefined = undefined, +> = { + /** Manifest version. */ + version: string + /** Available commands. */ + commands: LlmsCommand[] +} + +/** Full LLM manifest. */ +export type LlmsFullManifest< + commands = Commands, + scope extends CommandScope | undefined = undefined, +> = LlmsManifest + +/** LLM command entry. */ +export type LlmsCommand< + commands = Commands, + scope extends CommandScope | undefined = undefined, +> = { + /** Command name. */ + name: scope extends undefined + ? CommandId + : Extract, `${scope}` | `${scope} ${string}`> + /** Command description. */ + description?: string | undefined + /** Command schemas. */ + schema?: CommandSchema> | undefined +} + +/** JSON-ish command schema. */ +export type CommandSchema<_commands = 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[] }[] +} + +/** Local skills list. */ +export type SkillsList = { + /** Listed skills. */ + skills: unknown[] +} + +/** MCP tool descriptor response. */ +export type McpToolsResponse<_commands = Commands> = { + /** MCP tools. */ + tools: Record[] +} + +/** Discovery action set. */ +export type DiscoveryActions = { + llms: LlmsAction + llmsFull: LlmsFullAction + schema(command?: CommandScope | undefined): Promise> + help(command?: CommandScope | undefined): Promise + openapi(): Promise + skills: { + index(): Promise + get(name: string): Promise + } + mcp: { + tools(): Promise> + } +} + +/** Compact LLM discovery action. */ +export type LlmsAction = { + < + const scope extends CommandScope | undefined = undefined, + const format extends DiscoveryFormat | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} + +/** Full LLM discovery action. */ +export type LlmsFullAction = { + < + const scope extends CommandScope | undefined = undefined, + const format extends DiscoveryFormat | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} + +/** Memory-only local actions. */ +export type LocalActions = { + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} + +/** Public RPC envelope alias. */ +export type ClientRpcEnvelope = RpcFullEnvelope + +/** Public RPC metadata alias. */ +export type ClientRpcMeta = RpcMeta + +/** Public RPC output alias. */ +export type ClientRpcOutput = RpcOutput + +/** Public RPC error object. */ +export type ClientRpcError = Extract['error'] + +/** Client implementation shape used by actions. */ +export type ActionClient = { + defaults: ClientDefaults + transport: { + request(request: RpcRequest): Promise + discover(request: ResourcesRequest): Promise + } & ResolvedTransport +} + +/** CLI value accepted by memory clients. */ +export type AnyCli = Cli.Cli + +export type { + McpAddOptions, + McpRegistration, + RpcRequest, + RpcResponse, + RpcStreamRecord, + RpcStreamResponse, + SkillsAddOptions, + SkillsListOptions, + SyncedSkills, +} diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 0743564..11c91ce 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1601,33 +1601,64 @@ describe('--llms', () => { describe('typegen', () => { test('generates correct .d.ts for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + /** Generated command: auth login */ + "auth login": { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } + /** Generated command: auth logout */ + "auth logout": { args: {}; options: {} } + /** Generated command: auth status */ + "auth status": { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } + /** Generated command: config */ + config: { args: { key?: string | undefined }; options: {} } + /** Generated command: echo */ + echo: { args: { message: string; repeat?: number | undefined }; options: { upper: boolean; prefix: string } } + /** Generated command: explode */ + explode: { args: {}; options: {} } + /** Generated command: explode-clac */ + "explode-clac": { args: {}; options: {} } + /** Generated command: noop */ + noop: { args: {}; options: {} } + /** Generated command: ping */ + ping: { args: {}; options: {} } + /** Generated command: project create */ + "project create": { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } + /** Generated command: project delete */ + "project delete": { args: { id: string }; options: { force: boolean } } + /** Generated command: project deploy create */ + "project deploy create": { args: { env: string }; options: { branch: string; dryRun: boolean }; output: { deployId: string; url: string; status: string } } + /** Generated command: project deploy rollback */ + "project deploy rollback": { args: { deployId: string }; options: {} } + /** Generated command: project deploy status */ + "project deploy status": { args: { deployId: string }; options: {}; output: { deployId: string; status: string; progress: number } } + /** Generated command: project get */ + "project get": { args: { id: string }; options: {}; output: { id: string; name: string; description: string; members: { userId: string; role: string }[] } } + /** Generated command: project list */ + "project list": { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean }; output: { items: { id: string; name: string; archived: boolean }[]; total: number } } + /** Generated command: slow */ + slow: { args: {}; options: {} } + /** Generated command: stream */ + stream: { args: {}; options: {}; stream: true } + /** Generated command: stream-error */ + "stream-error": { args: {}; options: {}; stream: true } + /** Generated command: stream-ok */ + "stream-ok": { args: {}; options: {}; stream: true } + /** Generated command: stream-text */ + "stream-text": { args: {}; options: {}; stream: true } + /** Generated command: stream-throw */ + "stream-throw": { args: {}; options: {}; stream: true } + /** Generated command: validate-fail */ + "validate-fail": { args: { email: string; age: number }; options: {} } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { interface Register { - 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: {} } - } + commands: Commands } } " diff --git a/src/index.ts b/src/index.ts index c622838..e6876af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { z } from 'zod' export * as Cli from './Cli.js' +export { create } from './Cli.js' export * as Completions from './Completions.js' export { default as middleware } from './middleware.js' export type { Handler as MiddlewareHandler, Context as MiddlewareContext } from './middleware.js' From d61b412f43adb05c750d90197b108f535642ffae Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 10:18:17 +0200 Subject: [PATCH 02/21] fix: tighten typed client typegen surface --- docs/api_example.ts | 4 +- src/Typegen.test.ts | 86 ++++++++++++-- src/Typegen.ts | 192 +++++++++++++++++++++++++------ src/client/api-example.test-d.ts | 4 +- src/e2e.test.ts | 23 ---- src/index.ts | 1 - 6 files changed, 242 insertions(+), 68 deletions(-) diff --git a/docs/api_example.ts b/docs/api_example.ts index 4b928d8..42e631d 100644 --- a/docs/api_example.ts +++ b/docs/api_example.ts @@ -1,4 +1,4 @@ -import { create } from 'incur' +import { Cli } from 'incur' import { ClientError, createClient, @@ -32,7 +32,7 @@ const _clientViaTransport = createClient({ }) // Or create an in-process memory client. -const cli = create({ name: 'acme' }) // ... +const cli = Cli.create({ name: 'acme' }) // ... // Memory clients run in-process, so explicit env injection is allowed here. const memoryClient = createMemoryClient(cli, { env: { ACME_TOKEN: 'dev_secret_123' }, diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 2240660..4ebb9c0 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -14,9 +14,7 @@ describe('fromCli', () => { expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: get */ get: { args: { id: number }; options: {} } - /** Generated command: list */ list: { args: {}; options: { limit: number } } } @@ -40,7 +38,6 @@ describe('fromCli', () => { expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: ping */ ping: { args: {}; options: {} } } @@ -74,9 +71,7 @@ describe('fromCli', () => { expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: pr create */ "pr create": { args: { title: string }; options: {} } - /** Generated command: pr list */ "pr list": { args: {}; options: { state: string } } } @@ -107,7 +102,6 @@ describe('fromCli', () => { expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: pr review approve */ "pr review approve": { args: { id: number }; options: {} } } @@ -167,11 +161,32 @@ describe('fromCli', () => { run: () => [{ id: 'one', active: true }], }) +<<<<<<< HEAD const output = Typegen.fromCli(cli) expect(output).toContain('read: { args: {}; options: {}; output: string }') expect(output).toContain( 'list: { args: {}; options: {}; output: { id: string; active: boolean }[] }', ) +======= + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "export type Commands = { + read: { args: {}; options: {}; output: string } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands + } + } + " + `) +>>>>>>> 0a77e57 (fix: tighten typed client typegen surface) }) test('marks async generator commands as streams', () => { @@ -182,10 +197,31 @@ describe('fromCli', () => { }, }) +<<<<<<< HEAD const output = Typegen.fromCli(cli) expect(output).toContain( 'tail: { args: {}; options: {}; output: { line: string }; stream: true }', ) +======= + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "export type Commands = { + list: { args: {}; options: {}; output: { id: string; active: boolean }[] } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + + declare module 'incur/client' { + interface Register { + commands: Commands + } + } + " + `) +>>>>>>> 0a77e57 (fix: tighten typed client typegen surface) }) test('commands are sorted alphabetically', () => { @@ -262,9 +298,7 @@ describe('fromCli', () => { expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: ping */ ping: { args: {}; options: {} } - /** Generated command: pr list */ "pr list": { args: {}; options: {} } } @@ -312,4 +346,40 @@ describe('fromCli', () => { expect(output).toContain('"quote\\"key": number') expect(output).toContain('nested: { "child-key"?: string | undefined }') }) + + test('catchall index signatures include optional property undefined', () => { + const cli = Cli.create('test').command('shape', { + output: z.object({ maybe: z.string().optional() }).catchall(z.boolean()), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain( + 'shape: { args: {}; options: {}; output: { maybe?: string | undefined; [key: string]: boolean | string | undefined } }', + ) + }) + + test('wraps JSON Schema conversion failures in TypegenError', () => { + const cli = Cli.create('test').command('created', { + output: z.date(), + run: () => new Date(), + }) + + expect(() => Typegen.fromCli(cli)).toThrow(Typegen.TypegenError) + expect(() => Typegen.fromCli(cli)).toThrow( + 'Cannot generate TypeScript for command "created" output', + ) + }) + + test('throws TypegenError for unsupported JSON Schema refs', () => { + let node: z.ZodType + node = z.lazy(() => z.object({ next: node.optional() })) + const cli = Cli.create('test').command('broken', { + output: node, + run: () => ({ next: {} }), + }) + + expect(() => Typegen.fromCli(cli)).toThrow(Typegen.TypegenError) + expect(() => Typegen.fromCli(cli)).toThrow('unsupported JSON Schema reference "#"') + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 7c24afc..ce25b2c 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -7,9 +7,15 @@ import { importCli } from './internal/utils.js' /** Error thrown when command type generation cannot emit a stable TypeScript type. */ export class TypegenError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + } + override name = 'Incur.TypegenError' } +type JsonSchema = Record | boolean + /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ export async function generate(input: string, output: string): Promise { const cli = await importCli(input) @@ -23,9 +29,9 @@ export function fromCli(cli: Cli.Cli): string { const lines: string[] = ['export type Commands = {'] for (const { id, command } of entries) { - lines.push(` /** Generated command: ${id} */`) + const context = `command ${JSON.stringify(id)}` lines.push( - ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args, `${context} args`)}; options: ${objectSchemaToType(command.options, `${context} options`)}${command.output ? `; output: ${schemaToType(command.output, `${context} output`)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) } @@ -49,45 +55,97 @@ export function fromCli(cli: Cli.Cli): string { } /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function objectSchemaToType(schema: z.ZodType | undefined): string { +function objectSchemaToType(schema: z.ZodType | undefined, context: string): string { if (!schema) return '{}' - return schemaToType(schema) + return schemaToType(schema, context) } /** 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> - return resolveType(json, defs) +function schemaToType(schema: z.ZodType, context: string): string { + const json = (() => { + try { + return z.toJSONSchema(schema) + } catch (error) { + throw new TypegenError( + `Cannot generate TypeScript for ${context}: Zod could not convert the schema to JSON Schema. ${errorMessage(error)}`, + { cause: error }, + ) + } + })() + if (!isRecord(json)) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema root is invalid.`, + ) + const defs = json.$defs + if (defs !== undefined && !isSchemaMap(defs)) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema $defs is invalid.`, + ) + return resolveType(json, defs ?? {}, context) } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ function resolveType( - schema: Record, - defs: Record>, + schema: JsonSchema, + defs: Record, + context: string, + seen: Set = new Set(), ): string { + if (typeof schema === 'boolean') return schema ? 'unknown' : 'never' + if (schema.$ref) { - const ref = (schema.$ref as string).replace('#/$defs/', '') + if (typeof schema.$ref !== 'string') + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema $ref is invalid.`, + ) + if (!schema.$ref.startsWith('#/$defs/')) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: unsupported JSON Schema reference "${schema.$ref}".`, + ) + const ref = schema.$ref.replace('#/$defs/', '') + if (seen.has(ref)) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: recursive JSON Schema reference "${schema.$ref}" is not supported.`, + ) const resolved = defs[ref] - if (resolved) return resolveType(resolved, defs) - return 'unknown' + if (resolved) return resolveType(resolved, defs, context, new Set([...seen, ref])) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: unresolved JSON Schema reference "${schema.$ref}".`, + ) } - if ('const' in schema) return JSON.stringify(schema.const) - if (schema.enum) return (schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(' | ') + if ('const' in schema) return literalType(schema.const, context) + if (schema.enum) { + if (!Array.isArray(schema.enum) || schema.enum.length === 0) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema enum is invalid.`, + ) + return schema.enum.map((v) => literalType(v, context)).join(' | ') + } if (schema.anyOf) - return (schema.anyOf as Record[]).map((s) => resolveType(s, defs)).join(' | ') - if (schema.not && Object.keys(schema).length === 1) return 'never' + return union( + schemaArray(schema.anyOf, context, 'anyOf').map((s) => resolveType(s, defs, context, seen)), + ) + if (schema.not && semanticKeys(schema).length === 1) return 'never' const type = schema.type as string | string[] | undefined if (Array.isArray(type)) return type - .map((t) => (t === 'null' ? 'null' : resolveType({ ...schema, type: t }, defs))) + .map((t) => { + if (typeof t !== 'string' || t.length === 0) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema type array is invalid.`, + ) + return t === 'null' ? 'null' : resolveType({ ...schema, type: t }, defs, context, seen) + }) .join(' | ') switch (type) { case undefined: - return 'unknown' + if (semanticKeys(schema).length === 0) return 'unknown' + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema node is missing a supported type.`, + ) case 'string': return 'string' case 'number': @@ -98,39 +156,58 @@ function resolveType( case 'null': return 'null' case 'array': { - const items = schema.items as Record | undefined - const prefixItems = schema.prefixItems as Record[] | undefined + const items = schema.items as JsonSchema | undefined + const prefixItems = schema.prefixItems as JsonSchema[] | undefined if (prefixItems) { - const values = prefixItems.map((item) => resolveType(item, defs)) - const rest = items ? `, ...${arrayType(resolveType(items, defs))}` : '' + const values = schemaArray(prefixItems, context, 'prefixItems').map((item) => + resolveType(item, defs, context, seen), + ) + const rest = + items !== undefined ? `, ...${arrayType(resolveType(items, defs, context, seen))}` : '' return `[${values.join(', ')}${rest}]` } - const itemType = items ? resolveType(items, defs) : 'unknown' + const itemType = items !== undefined ? resolveType(items, defs, context, seen) : 'unknown' return arrayType(itemType) } case 'object': { - const properties = schema.properties as Record> | undefined - const additional = schema.additionalProperties as - | Record - | boolean - | undefined + const properties = schema.properties + if (properties !== undefined && !isSchemaMap(properties)) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema object properties are invalid.`, + ) + assertSupportedPropertyNames(schema, context) + const additional = schema.additionalProperties as JsonSchema | boolean | undefined if ((!properties || Object.keys(properties).length === 0) && additional === undefined) return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) const entries = Object.entries(properties ?? {}).map(([key, value]) => { +<<<<<<< HEAD const type = resolveType(value, defs) if (required.has(key)) return `${propertyKey(key)}: ${type}` return `${propertyKey(key)}?: ${type} | undefined` +======= + const type = resolveType(value, defs, context, seen) + return required.has(key) + ? `${propertyKey(key)}: ${type}` + : `${propertyKey(key)}?: ${type} | undefined` +>>>>>>> 0a77e57 (fix: tighten typed client typegen surface) }) if (additional && typeof additional === 'object') { - const values = Object.values(properties ?? {}).map((value) => resolveType(value, defs)) - entries.push(`[key: string]: ${union([resolveType(additional, defs), ...values])}`) + const values = Object.entries(properties ?? {}).map(([key, value]) => { + const type = resolveType(value, defs, context, seen) + return required.has(key) ? type : `${type} | undefined` + }) + entries.push( + `[key: string]: ${union([resolveType(additional, defs, context, seen), ...values])}`, + ) } if (additional === true) entries.push('[key: string]: unknown') return `{ ${entries.join('; ')} }` } default: - return 'unknown' + throw new TypegenError( + `Cannot generate TypeScript for ${context}: unsupported JSON Schema type "${String(type)}".`, + ) } } @@ -142,7 +219,58 @@ function union(types: string[]) { return [...new Set(types)].join(' | ') } +<<<<<<< HEAD function isStream(command: Cli.CommandDefinition) { +======= +function semanticKeys(schema: Record) { + return Object.keys(schema).filter((key) => !['$schema', 'description', 'title'].includes(key)) +} + +function schemaArray(value: unknown, context: string, key: string): JsonSchema[] { + if (!Array.isArray(value) || value.length === 0) + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema ${key} is invalid.`, + ) + if (value.every((item) => typeof item === 'boolean' || isRecord(item))) return value + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema ${key} is invalid.`, + ) +} + +function isSchemaMap(value: unknown): value is Record { + return ( + isRecord(value) && + Object.values(value).every((schema) => typeof schema === 'boolean' || isRecord(schema)) + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function literalType(value: unknown, context: string) { + const type = JSON.stringify(value) + if (type !== undefined) return type + throw new TypegenError( + `Cannot generate TypeScript for ${context}: JSON Schema literal is invalid.`, + ) +} + +function assertSupportedPropertyNames(schema: Record, context: string) { + if (schema.propertyNames === undefined) return + if (schema.propertyNames === true) return + if (isRecord(schema.propertyNames) && schema.propertyNames.type === 'string') return + throw new TypegenError( + `Cannot generate TypeScript for ${context}: non-string JSON Schema property names are not supported.`, + ) +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +function isStream(command: CommandTree.CommandDefinition) { +>>>>>>> 0a77e57 (fix: tighten typed client typegen surface) return command.run.constructor.name === 'AsyncGeneratorFunction' } diff --git a/src/client/api-example.test-d.ts b/src/client/api-example.test-d.ts index a711043..694dae1 100644 --- a/src/client/api-example.test-d.ts +++ b/src/client/api-example.test-d.ts @@ -1,4 +1,4 @@ -import { create } from 'incur' +import { Cli } from 'incur' import { ClientError, HttpTransport, @@ -60,7 +60,7 @@ test('docs api example client surface typechecks conceptually', async () => { outputFormat: 'toon', }) - const cli = create({ name: 'acme' }) + const cli = Cli.create({ name: 'acme' }) const memoryClient = createMemoryClient(cli, { env: { ACME_TOKEN: 'dev_secret_123' }, }) diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 11c91ce..7f0412b 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1602,51 +1602,28 @@ describe('typegen', () => { test('generates correct .d.ts for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` "export type Commands = { - /** Generated command: auth login */ "auth login": { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } - /** Generated command: auth logout */ "auth logout": { args: {}; options: {} } - /** Generated command: auth status */ "auth status": { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } - /** Generated command: config */ config: { args: { key?: string | undefined }; options: {} } - /** Generated command: echo */ echo: { args: { message: string; repeat?: number | undefined }; options: { upper: boolean; prefix: string } } - /** Generated command: explode */ explode: { args: {}; options: {} } - /** Generated command: explode-clac */ "explode-clac": { args: {}; options: {} } - /** Generated command: noop */ noop: { args: {}; options: {} } - /** Generated command: ping */ ping: { args: {}; options: {} } - /** Generated command: project create */ "project create": { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } - /** Generated command: project delete */ "project delete": { args: { id: string }; options: { force: boolean } } - /** Generated command: project deploy create */ "project deploy create": { args: { env: string }; options: { branch: string; dryRun: boolean }; output: { deployId: string; url: string; status: string } } - /** Generated command: project deploy rollback */ "project deploy rollback": { args: { deployId: string }; options: {} } - /** Generated command: project deploy status */ "project deploy status": { args: { deployId: string }; options: {}; output: { deployId: string; status: string; progress: number } } - /** Generated command: project get */ "project get": { args: { id: string }; options: {}; output: { id: string; name: string; description: string; members: { userId: string; role: string }[] } } - /** Generated command: project list */ "project list": { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean }; output: { items: { id: string; name: string; archived: boolean }[]; total: number } } - /** Generated command: slow */ slow: { args: {}; options: {} } - /** Generated command: stream */ stream: { args: {}; options: {}; stream: true } - /** Generated command: stream-error */ "stream-error": { args: {}; options: {}; stream: true } - /** Generated command: stream-ok */ "stream-ok": { args: {}; options: {}; stream: true } - /** Generated command: stream-text */ "stream-text": { args: {}; options: {}; stream: true } - /** Generated command: stream-throw */ "stream-throw": { args: {}; options: {}; stream: true } - /** Generated command: validate-fail */ "validate-fail": { args: { email: string; age: number }; options: {} } } diff --git a/src/index.ts b/src/index.ts index e6876af..c622838 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ export { z } from 'zod' export * as Cli from './Cli.js' -export { create } from './Cli.js' export * as Completions from './Completions.js' export { default as middleware } from './middleware.js' export type { Handler as MiddlewareHandler, Context as MiddlewareContext } from './middleware.js' From 83d307c9667e318aa48db43ae2e4c135022d42b9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 10:29:16 +0200 Subject: [PATCH 03/21] docs: remove generated command jsdoc claims --- docs/typed-client-implementation-plan.md | 1 - docs/typed-client-spec.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/typed-client-implementation-plan.md b/docs/typed-client-implementation-plan.md index 3e2fc89..0723e64 100644 --- a/docs/typed-client-implementation-plan.md +++ b/docs/typed-client-implementation-plan.md @@ -707,7 +707,6 @@ Rules: - streaming `output` is the chunk type; - generated files export `Commands`; - generated files augment both `incur` and `incur/client`; -- generated command properties include JSDoc; - optional properties include `| undefined`; - invalid object keys and command keys are escaped; - unsupported schemas fail with a clear typegen error. diff --git a/docs/typed-client-spec.md b/docs/typed-client-spec.md index d6d82a5..c9f946b 100644 --- a/docs/typed-client-spec.md +++ b/docs/typed-client-spec.md @@ -1262,7 +1262,6 @@ Rules: - missing `output` infers `unknown`; - streaming commands include `stream: true`; - streaming command `output` is the chunk type; -- each generated command property has JSDoc that names the generated command; - object keys that are not valid TypeScript identifiers are quoted; - command keys are emitted with `JSON.stringify`-compatible escaping; - optional properties include `| undefined` for `exactOptionalPropertyTypes`; From 9d17b44459359f5e5bcaeb4ff3166abcf2d5c036 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 12:27:16 +0200 Subject: [PATCH 04/21] fix: align client actions with flattened transports --- src/client/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/types.ts b/src/client/types.ts index 4c14a54..760f653 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -3,7 +3,9 @@ import type * as Formatter from '../Formatter.js' import type { McpAddOptions, McpRegistration, + Runtime as LocalRuntime, SkillsAddOptions, + SkillsList, SkillsListOptions, SyncedSkills, } from './Local.js' @@ -504,6 +506,7 @@ export type ActionClient = { transport: { request(request: RpcRequest): Promise discover(request: ResourcesRequest): Promise + local?: LocalRuntime | undefined } & ResolvedTransport } @@ -518,6 +521,7 @@ export type { RpcStreamRecord, RpcStreamResponse, SkillsAddOptions, + SkillsList, SkillsListOptions, SyncedSkills, } From d5e5c320d7bb7adf16c9e7c3d246a0d51e482f7f Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 12:39:20 +0200 Subject: [PATCH 05/21] refactor: remove unused client uid --- docs/typed-client-spec.md | 7 +------ src/client/createClient.test.ts | 7 +++---- src/client/createClient.ts | 9 +-------- src/client/types.ts | 2 -- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/docs/typed-client-spec.md b/docs/typed-client-spec.md index c9f946b..ad8f92c 100644 --- a/docs/typed-client-spec.md +++ b/docs/typed-client-spec.md @@ -144,7 +144,6 @@ type ClientBase = defaults: defaults transport: ResolvedTransport type: 'client' - uid: string } ``` @@ -267,10 +266,6 @@ type Transport = HttpTransport | MemoryTransport type TransportType = 'http' | 'memory' -type TransportContext = { - uid: string -} - type TransportConfig = { key: string name: string @@ -282,7 +277,7 @@ type TransportCapabilities = Record type TransportFactory< type extends TransportType, capabilities extends TransportCapabilities, -> = (context: TransportContext) => { config: TransportConfig } & capabilities +> = () => { config: TransportConfig } & capabilities ``` Resolved transport: diff --git a/src/client/createClient.test.ts b/src/client/createClient.test.ts index 88c9e2b..8f81484 100644 --- a/src/client/createClient.test.ts +++ b/src/client/createClient.test.ts @@ -11,14 +11,14 @@ import { createClient, createHttpClient, createMemoryClient } from './createClie import * as HttpTransport from './transports/HttpTransport.js' function mockTransport(): HttpTransport.HttpTransport { - return (ctx) => ({ + return () => ({ config: { key: 'mock', name: 'Mock', type: 'http' as const }, baseUrl: new URL('https://example.com'), discover: vi.fn(), request: vi.fn( async (_request: RpcRequest): Promise => ({ ok: true, - data: { uid: ctx.uid }, + data: { ok: true }, meta: { command: 'status', duration: '1ms' }, }), ), @@ -37,10 +37,9 @@ describe('createClient', () => { transport: { key: 'mock', name: 'Mock', type: 'http' }, type: 'client', }) - expect(client.uid).toEqual(expect.any(String)) await expect(client.run('status' as never)).resolves.toMatchObject({ ok: true, - data: { uid: client.uid }, + data: { ok: true }, }) }) diff --git a/src/client/createClient.ts b/src/client/createClient.ts index 6fecf9c..8765ebe 100644 --- a/src/client/createClient.ts +++ b/src/client/createClient.ts @@ -22,14 +22,12 @@ export function createClient< const defaults extends ClientDefaults = {}, >(options: CreateClientOptions): Client { const { transport, ...defaults } = options - const uid = uidValue() - const resolved = transport({ uid }) + const resolved = transport() const { config, ...capabilities } = resolved const client = { defaults, transport: { ...config, ...capabilities }, type: 'client', - uid, } as unknown as Client return attachActions(client) as Client @@ -133,8 +131,3 @@ function attachActions(client: client): client { return client } - -function uidValue() { - if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() - return `client_${Math.random().toString(36).slice(2)}` -} diff --git a/src/client/types.ts b/src/client/types.ts index 760f653..10ba8a5 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -78,8 +78,6 @@ export type ClientBase /** Client discriminator. */ type: 'client' - /** Unique client id. */ - uid: string } /** Typed client instance. */ From 6e33ae48be445a00e29c7b71329d2c09ee890b39 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 13:46:30 +0200 Subject: [PATCH 06/21] refactor: keep public surface typegen scoped --- src/Typegen.test.ts | 9 +- src/Typegen.ts | 147 +++++++++---------------------- src/client/api-example.test-d.ts | 3 +- src/client/index.test-d.ts | 6 +- src/client/index.ts | 7 +- src/e2e.test.ts | 4 +- 6 files changed, 57 insertions(+), 119 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 4ebb9c0..9a82c02 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -286,7 +286,7 @@ describe('fromCli', () => { }) const output = Typegen.fromCli(cli) - expect(output).toContain('verbose?: boolean | undefined') + expect(output).toContain('verbose?: boolean') expect(output).toContain('output: string') }) @@ -330,7 +330,11 @@ describe('fromCli', () => { expect(output).toContain("declare module 'incur/client'") }) +<<<<<<< HEAD test('escapes command and property keys', () => { +======= + test('escapes command keys', () => { +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) const cli = Cli.create('test').command('bad key "quoted"', { options: z.object({ 'bad-key': z.string().optional(), @@ -342,6 +346,7 @@ describe('fromCli', () => { const output = Typegen.fromCli(cli) expect(output).toContain('"bad key \\"quoted\\""') +<<<<<<< HEAD expect(output).toContain('"bad-key"?: string | undefined') expect(output).toContain('"quote\\"key": number') expect(output).toContain('nested: { "child-key"?: string | undefined }') @@ -381,5 +386,7 @@ describe('fromCli', () => { expect(() => Typegen.fromCli(cli)).toThrow(Typegen.TypegenError) expect(() => Typegen.fromCli(cli)).toThrow('unsupported JSON Schema reference "#"') +======= +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index ce25b2c..4dc0a44 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -2,20 +2,13 @@ import fs from 'node:fs/promises' import { z } from 'zod' import * as Cli from './Cli.js' +<<<<<<< HEAD import * as RuntimeContext from './internal/runtime-context.js' +======= +import * as RuntimeContext from './internal/client-runtime-context.js' +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) import { importCli } from './internal/utils.js' -/** Error thrown when command type generation cannot emit a stable TypeScript type. */ -export class TypegenError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options) - } - - override name = 'Incur.TypegenError' -} - -type JsonSchema = Record | boolean - /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ export async function generate(input: string, output: string): Promise { const cli = await importCli(input) @@ -24,14 +17,17 @@ 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 { +<<<<<<< HEAD const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli)) +======= + const entries = RuntimeContext.collectClientCommands(RuntimeContext.fromCli(cli)) +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) const lines: string[] = ['export type Commands = {'] for (const { id, command } of entries) { - const context = `command ${JSON.stringify(id)}` lines.push( - ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args, `${context} args`)}; options: ${objectSchemaToType(command.options, `${context} options`)}${command.output ? `; output: ${schemaToType(command.output, `${context} output`)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) } @@ -55,97 +51,42 @@ export function fromCli(cli: Cli.Cli): string { } /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function objectSchemaToType(schema: z.ZodType | undefined, context: string): string { +function objectSchemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' - return schemaToType(schema, context) + return schemaToType(schema) } /** Converts a Zod schema to a TypeScript type string. */ -function schemaToType(schema: z.ZodType, context: string): string { - const json = (() => { - try { - return z.toJSONSchema(schema) - } catch (error) { - throw new TypegenError( - `Cannot generate TypeScript for ${context}: Zod could not convert the schema to JSON Schema. ${errorMessage(error)}`, - { cause: error }, - ) - } - })() - if (!isRecord(json)) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema root is invalid.`, - ) - const defs = json.$defs - if (defs !== undefined && !isSchemaMap(defs)) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema $defs is invalid.`, - ) - return resolveType(json, defs ?? {}, context) +function schemaToType(schema: z.ZodType): string { + const json = z.toJSONSchema(schema) as Record + const defs = (json.$defs ?? {}) as Record> + return resolveType(json, defs) } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ function resolveType( - schema: JsonSchema, - defs: Record, - context: string, - seen: Set = new Set(), + schema: Record, + defs: Record>, ): string { - if (typeof schema === 'boolean') return schema ? 'unknown' : 'never' - if (schema.$ref) { - if (typeof schema.$ref !== 'string') - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema $ref is invalid.`, - ) - if (!schema.$ref.startsWith('#/$defs/')) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: unsupported JSON Schema reference "${schema.$ref}".`, - ) - const ref = schema.$ref.replace('#/$defs/', '') - if (seen.has(ref)) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: recursive JSON Schema reference "${schema.$ref}" is not supported.`, - ) + const ref = (schema.$ref as string).replace('#/$defs/', '') const resolved = defs[ref] - if (resolved) return resolveType(resolved, defs, context, new Set([...seen, ref])) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: unresolved JSON Schema reference "${schema.$ref}".`, - ) + if (resolved) return resolveType(resolved, defs) + return 'unknown' } - if ('const' in schema) return literalType(schema.const, context) - if (schema.enum) { - if (!Array.isArray(schema.enum) || schema.enum.length === 0) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema enum is invalid.`, - ) - return schema.enum.map((v) => literalType(v, context)).join(' | ') - } + if ('const' in schema) return JSON.stringify(schema.const) + if (schema.enum) return (schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(' | ') if (schema.anyOf) - return union( - schemaArray(schema.anyOf, context, 'anyOf').map((s) => resolveType(s, defs, context, seen)), - ) - if (schema.not && semanticKeys(schema).length === 1) return 'never' + return (schema.anyOf as Record[]).map((s) => resolveType(s, defs)).join(' | ') const type = schema.type as string | string[] | undefined if (Array.isArray(type)) return type - .map((t) => { - if (typeof t !== 'string' || t.length === 0) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema type array is invalid.`, - ) - return t === 'null' ? 'null' : resolveType({ ...schema, type: t }, defs, context, seen) - }) + .map((t) => (t === 'null' ? 'null' : resolveType({ ...schema, type: t }, defs))) .join(' | ') switch (type) { - case undefined: - if (semanticKeys(schema).length === 0) return 'unknown' - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema node is missing a supported type.`, - ) case 'string': return 'string' case 'number': @@ -156,30 +97,15 @@ function resolveType( case 'null': return 'null' case 'array': { - const items = schema.items as JsonSchema | undefined - const prefixItems = schema.prefixItems as JsonSchema[] | undefined - if (prefixItems) { - const values = schemaArray(prefixItems, context, 'prefixItems').map((item) => - resolveType(item, defs, context, seen), - ) - const rest = - items !== undefined ? `, ...${arrayType(resolveType(items, defs, context, seen))}` : '' - return `[${values.join(', ')}${rest}]` - } - const itemType = items !== undefined ? resolveType(items, defs, context, seen) : 'unknown' - return arrayType(itemType) + const items = schema.items as Record | undefined + const itemType = items ? resolveType(items, defs) : 'unknown' + return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]` } case 'object': { - const properties = schema.properties - if (properties !== undefined && !isSchemaMap(properties)) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema object properties are invalid.`, - ) - assertSupportedPropertyNames(schema, context) - const additional = schema.additionalProperties as JsonSchema | boolean | undefined - if ((!properties || Object.keys(properties).length === 0) && additional === undefined) - return '{}' + const properties = schema.properties as Record> | undefined + if (!properties || Object.keys(properties).length === 0) return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) +<<<<<<< HEAD const entries = Object.entries(properties ?? {}).map(([key, value]) => { <<<<<<< HEAD const type = resolveType(value, defs) @@ -202,15 +128,19 @@ function resolveType( ) } if (additional === true) entries.push('[key: string]: unknown') +======= + const entries = Object.entries(properties).map( + ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, + ) +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) return `{ ${entries.join('; ')} }` } default: - throw new TypegenError( - `Cannot generate TypeScript for ${context}: unsupported JSON Schema type "${String(type)}".`, - ) + return 'unknown' } } +<<<<<<< HEAD function arrayType(type: string) { return type.includes(' | ') ? `(${type})[]` : `${type}[]` } @@ -271,6 +201,9 @@ function errorMessage(error: unknown) { function isStream(command: CommandTree.CommandDefinition) { >>>>>>> 0a77e57 (fix: tighten typed client typegen surface) +======= +function isStream(command: Cli.CommandDefinition) { +>>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) return command.run.constructor.name === 'AsyncGeneratorFunction' } diff --git a/src/client/api-example.test-d.ts b/src/client/api-example.test-d.ts index 694dae1..d7dc0f4 100644 --- a/src/client/api-example.test-d.ts +++ b/src/client/api-example.test-d.ts @@ -94,8 +94,7 @@ test('docs api example client surface typechecks conceptually', async () => { }) } catch (error) { if (error instanceof ClientError) { - const clientError = error as ClientError - expectTypeOf(clientError.error?.code).toEqualTypeOf() + expectTypeOf(error.error?.code).toEqualTypeOf() } } diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts index 11a605f..fce21ad 100644 --- a/src/client/index.test-d.ts +++ b/src/client/index.test-d.ts @@ -12,7 +12,6 @@ import type { ClientStreamResponse, HttpClient, MemoryClient, - Transport, } from 'incur/client' import { expectTypeOf, test } from 'vitest' @@ -77,7 +76,10 @@ test('local actions are memory-only and unavailable on HTTP or broad transports' expectTypeOf(memory.skills.list).toBeFunction() expectTypeOf(memory.mcp.add).toBeFunction() - const broad = createClient({ + const broad = createClient< + Commands, + HttpTransport.HttpTransport | MemoryTransport.MemoryTransport + >({ transport: MemoryTransport.create(cli), }) // @ts-expect-error broad Transport clients do not expose local actions. diff --git a/src/client/index.ts b/src/client/index.ts index e98cae8..a92b475 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -76,9 +76,6 @@ export type { StreamRecord as RpcStreamRecord, StreamResponse as RpcStreamResponse, } from './Rpc.js' -export type { HttpTransport, Options as HttpTransportOptions } from './transports/HttpTransport.js' -export type { - MemoryTransport, - Options as MemoryTransportOptions, -} from './transports/MemoryTransport.js' +export type { Options as HttpTransportOptions } from './transports/HttpTransport.js' +export type { Options as MemoryTransportOptions } from './transports/MemoryTransport.js' export type { Factory as TransportFactory } from './transports/Transport.js' diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 7f0412b..a2ecb3f 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1605,8 +1605,8 @@ describe('typegen', () => { "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 } } + 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: {} } From 1292b7272f18d8f1b169255273bb312859a4a159 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 15:51:02 +0200 Subject: [PATCH 07/21] docs: remove docs folder --- docs/api_example.ts | 392 ------ docs/typed-client-implementation-plan.md | 817 ------------ docs/typed-client-spec.md | 1455 ---------------------- 3 files changed, 2664 deletions(-) delete mode 100644 docs/api_example.ts delete mode 100644 docs/typed-client-implementation-plan.md delete mode 100644 docs/typed-client-spec.md diff --git a/docs/api_example.ts b/docs/api_example.ts deleted file mode 100644 index 42e631d..0000000 --- a/docs/api_example.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { Cli } from 'incur' -import { - ClientError, - createClient, - createHttpClient, - createMemoryClient, - httpTransport, - memoryTransport, -} from 'incur/client' - -import type { Commands } from './generated/incur-client.js' - -/** - * Client - */ -const client = createHttpClient({ - baseUrl: 'https://ops.acme.test', - // Optional, defaults to globalThis.fetch. - fetch, - - // Defaults for every client.run(). Per-call options override these. - // output* options affect result.output.text but not the (full) result.data. - outputFormat: 'toon', // --format toon -}) - -// which is exactly the same as: -const _clientViaTransport = createClient({ - transport: httpTransport({ - baseUrl: 'https://ops.acme.test', - }), - outputFormat: 'toon', -}) - -// Or create an in-process memory client. -const cli = Cli.create({ name: 'acme' }) // ... -// Memory clients run in-process, so explicit env injection is allowed here. -const memoryClient = createMemoryClient(cli, { - env: { ACME_TOKEN: 'dev_secret_123' }, -}) - -// identical to: -const _memoryClientViaTransport = createClient({ - transport: memoryTransport(cli, { - env: { ACME_TOKEN: 'dev_secret_123' }, - }), -}) - -/** - * Running - */ -// `acme project report proj_web_2026 --include-closed=false --filter-output summary items[0:3] nextCursor --format md --token-count --token-limit 24 --full-output` -const report = await client.run('project report', { - args: { projectId: 'proj_web_2026' }, - options: { includeClosed: false }, - - // Applies first to structured data (report.data), so report.data is typed as unknown. - selection: ['summary', 'items[0:3]', 'nextCursor'], - - // output* options apply only to report.output. - // They format/count/page report.output.text; they never change report.data. - outputFormat: 'md', - outputTokenCount: true, - outputTokenLimit: 24, -}) - -console.log(report) -/// ClientRunResult -// { -// 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: 24, -// tokenOffset: 0, -// next: [Function] -// }, -// meta: { -// command: 'project report', -// duration: '18ms', -// cta: { ... } -// } -// } - -console.log(typeof report.data) // unknown - -if (report.output?.next) { - const nextPage = await report.output.next() - console.log(nextPage?.output?.text) - // '- open: Publish launch checklist' -} - -// `acme project status proj_web_2026 --full-output` -const status = await client.run('project status', { - args: { projectId: 'proj_web_2026' }, -}) - -console.log(status) -/// ClientRunResult -// ... - -/** - * CTA - */ -const cta = report.meta.cta?.commands[0] -console.log(cta) -/// ClientCta -// { -// command: 'project unblock', -// cliCommand: 'acme project unblock task_2', -// description: 'Unblock the blocked checkout QA task.', -// args: { taskId: 'task_2' }, -// options: {}, -// runnable: true, -// run: [Function], -// raw: { -// command: 'project unblock', -// args: { taskId: 'task_2' }, -// options: {}, -// description: 'Unblock the blocked checkout QA task.' -// } -// } - -if (cta?.runnable) { - console.log(cta) - /// ClientCta - // ... - const unblock = await cta.run({ - // Equivalent to: - // client.run('project unblock', { - // args: { taskId: 'task_2' }, - // options: {}, - // outputFormat: 'toon', - // }) - // - // CTA run() does not inherit output controls from the original report run. - outputFormat: 'toon', - }) - - console.log(unblock) - /// ClientRunResult - // ... -} - -/** - * Errors - */ -try { - // acme project deploy proj_web_2026 production --full-output - await client.run('project deploy', { - args: { projectId: 'proj_web_2026', environment: 'production' }, - }) -} catch (error) { - if (error instanceof ClientError) { - console.log(error) - /// ClientError - // 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: 'acme auth login', - // description: 'Log in to Acme.', - // args: {}, - // options: {}, - // runnable: true, - // run: [Function], - // raw: { command: 'auth login', description: 'Log in to Acme.' } - // } - // ] - // } - // }, - // 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: { ... } - // } - // } - // } - - // Needs to be typed explicitly - const clientError = error as ClientError - console.log(clientError) - /// ClientError - // ... - } -} - -/** - * Streaming - */ -// `acme logs tail checkout-api --format toon` -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' } -} - -console.log(await stream.final) -/// ClientStreamFinal -// { -// ok: true, -// data: { lines: 124 }, -// meta: { command: 'logs tail', duration: '30s' } -// } - -// A stream can only be consumed once: either for await (...) or records(). -const rawStream = await client.run('logs tail', { - args: { service: 'checkout-api' }, -}) - -// records() yields every stream record, including error records. -// It does not throw when an error record arrives. -for await (const record of rawStream.records()) { - console.log(record) - /// ClientStreamRecord - // ... - if (record.type === 'chunk') { - console.log(record.data) - // ... - } - - if (record.type === 'done') { - console.log(record.data) - /// string | undefined - // { lines: 124 } - console.log(record.meta) - /// ClientMeta - // { command: 'logs tail', duration: '30s' } - } - - if (record.type === 'error') { - console.log(record.error) - /// ClientRpcError - // { code: 'LOG_STREAM_DISCONNECTED', message: 'Log stream disconnected.' } - } -} - -/** - * DiscoveryActions - * - * These actions are read-only and available on both HttpClient and MemoryClient: - * - client.llms(options?): Promise - * Compact LLM manifest; structured by default, string with format. - * - * - client.llmsFull(options?): Promise - * Full LLM manifest; structured by default, string with format. - * - * - client.schema(command?): Promise - * JSON Schema for root or command args/env/options/output. - * - * - client.help(command?): Promise - * CLI help text for root or command. - * - * - client.openapi(): Promise - * Parsed OpenAPI JSON document. - * - * - client.skills.index(): Promise - * Structured generated skills index. - * - * - client.skills.get(name): Promise - * Generated SKILL.md markdown. - * - * - client.mcp.tools(): Promise> - * Structured MCP tool descriptors. - * - * LocalActions - * - * These actions are available only on MemoryClient. They are not exposed by - * HttpClient, HTTP routes, RPC, or MCP tools: - * - memoryClient.skills.add(options?): Promise - * Sync generated skill files to local agent skill directories. - * - * - memoryClient.skills.list(options?): Promise - * List generated skills with local install status. - * - * - memoryClient.mcp.add(options?): Promise - * Register this CLI as a local MCP server with supported agents. - */ -const llmsFull = await client.llmsFull({ command: 'project' }) -console.log(llmsFull.commands[0]) -/// LlmsFullManifest['commands'][number] -// { -// 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' } } } -// } -// } - -// Discovery methods are not command runs, so they use `format`. -// `format` changes the discovery response itself from typed data to text. -const llmsMd = await client.llms({ command: 'project', format: 'md' }) -console.log(llmsMd) -/// string -// '# Project commands\n\n- `project report` - Summarize project progress.\n- `project status` - Show project status.' - -const schema = await client.schema('project report') -console.log(schema.args) -// CommandSchema['args'] -// { type: 'object', required: ['projectId'], properties: { projectId: { type: 'string' } } } - -const help = await client.help('project report') -console.log(help) -// string -// 'Usage: acme project report [--include-closed]\n\nSummarize project progress.' - -const openapi = await client.openapi() -console.log(openapi.info) -// OpenApiDocument['info'] -// { title: 'Acme CLI API', version: '1.0.0' } - -const skills = await client.skills.index() -console.log(skills.skills[0]) -// SkillsIndex['skills'][number] -// { name: 'deploy', description: 'Deploy safely with preflight checks.', files: ['SKILL.md'] } - -const deploySkill = await client.skills.get('deploy') -console.log(deploySkill) -// string -// '# Deploy\n\nRun preflight checks, inspect the deployment plan, then deploy.' - -const localSkills = await memoryClient.skills.list() -console.log(localSkills.skills[0]) -/// SkillsList['skills'][number] -// ... - -const syncedSkills = await memoryClient.skills.add({ - depth: 1, - global: true, -}) -console.log(syncedSkills.skills[0]) -/// SyncedSkills['skills'][number] -// { name: 'deploy', description: 'Deploy safely with preflight checks.' } - -// You can't use local actions on a http client. -client.skills.add() -// Type error: LocalActions exist only on MemoryClient. - -const mcpTools = await client.mcp.tools() -console.log(mcpTools.tools[0]) -// McpToolsResponse['tools'][number] -// { -// name: 'project_report', -// description: 'Summarize project progress.', -// inputSchema: { type: 'object', properties: { projectId: { type: 'string' } } }, -// outputSchema: { type: 'object', properties: { summary: { type: 'string' } } } -// } - -const mcpRegistration = await memoryClient.mcp.add({ - agents: ['codex'], -}) -console.log(mcpRegistration) -/// McpRegistration -// {command: 'pnpm acme --mcp', agents: ['Codex']} diff --git a/docs/typed-client-implementation-plan.md b/docs/typed-client-implementation-plan.md deleted file mode 100644 index 0723e64..0000000 --- a/docs/typed-client-implementation-plan.md +++ /dev/null @@ -1,817 +0,0 @@ -# TypeScript Client Implementation Plan - -This plan splits the TypeScript client work into two implementation PRs. - -The split is intentional: - -1. Build the shared runtime and transports first, so command execution, discovery, and local setup can be tested without the final typed client surface. -2. Build the public client and action types second, as a typed wrapper over the tested transport capabilities. - -This mirrors the intended architecture: transports do the work, actions are typed transport consumers, and clients compose actions around a resolved transport. - -The implementation must not carry forward obsolete client shapes from earlier experimental branches: - -- no curried `client(command)(input)` API; -- no HTTP-only `createClient({ baseUrl })`; -- no root-module client creation exports; -- no data-only run return; -- no bare async iterable stream return; -- no stream terminal records without full metadata; -- no RPC alias command identity; -- no HTTP/RPC/MCP local setup actions. - -## PR 1: Runtime And Transport Foundation - -Goal: create the shared runtime contracts that both HTTP and memory transports use. - -This PR should make command execution and discovery available through transport-level APIs, but it does not need to expose the final public client action surface. - -### 1. Extract Command Tree Utilities - -Create an internal command-tree module. - -Suggested file: - -```txt -src/internal/command-tree.ts -``` - -Move or expose the command graph utilities embedded in `Cli.ts`: - -- command entry types; -- alias detection; -- group detection; -- fetch gateway detection; -- canonical command resolution; -- command traversal helpers; -- mounted sub-CLI traversal behavior. - -The module should define canonical command IDs as CLI token paths joined by single spaces. - -Command identity rules: - -- aliases are CLI-only and are not generated client command IDs; -- root CLIs are callable by their own name; -- mounted root CLIs keep their own command ID; -- mounted router CLIs prefix their leaf commands with the router name; -- nested router CLIs flatten into single-space command IDs; -- raw fetch gateways are traversable for HTTP routing but are not RPC/client command IDs; -- OpenAPI-mounted fetch gateways contribute generated operation command IDs. - -Consumers: - -- HTTP RPC runtime; -- memory transport runtime; -- discovery builders; -- MCP tool discovery; -- typegen where useful. - -### 2. Extract Shared Command Runtime - -Create an internal client runtime module. - -Suggested file: - -```txt -src/internal/client-runtime.ts -``` - -This module should expose a runtime function equivalent to: - -```ts -type ExecuteClientCommand = ( - cli: RuntimeCliContext, - request: RpcRequest, -) => Promise -``` - -Responsibilities: - -- validate `RpcRequest`; -- resolve canonical command IDs; -- reject unknown commands; -- reject command groups; -- reject structured RPC calls to raw fetch gateways; -- call `Command.execute()`; -- execute through a structured args/options parse mode rather than argv, split HTTP, or MCP flat-param parsing; -- call `Command.execute()` with `agent: true`; -- call `Command.execute()` with empty `argv`; -- call `Command.execute()` with explicit JSON/full-output semantics; -- preserve middleware behavior; -- preserve root, group, and command middleware order; -- preserve env/vars behavior for in-process execution; -- preserve CLI env and command env validation; -- preserve validation `fieldErrors`; -- preserve root command identity and mounted CLI identity; -- apply `selection`; -- format `output.text`; -- compute token count/limit/offset metadata; -- compute `nextOffset`; -- preserve CTA metadata; -- produce full success/error envelopes; -- produce streaming records for streaming commands; -- include full metadata on terminal stream records; -- call command stream `return()` on cancellation; -- defer streaming middleware after-hooks until stream consumption or cancellation. - -HTTP RPC and memory transport request execution must both call this shared runtime. - -### 3. Define RPC Contracts - -Add shared types for: - -```ts -type RpcRequest -type RpcFullEnvelope -type RpcResponse -type RpcOutput -type RpcMeta -type RpcStreamRecord -type RpcStreamResponse -``` - -These are runtime/protocol contracts, not public `ClientRunResult` types. - -Validation behavior belongs here and should be tested independently. - -RPC contract tests should cover: - -- command trimming and empty-command validation; -- canonical command metadata; -- structured args validation independent from options validation; -- structured options validation independent from args validation; -- root command execution; -- mounted root CLI execution; -- mounted router command execution; -- raw fetch gateway rejection; -- alias rejection for typed-client RPC command identity; -- JSON validation errors before command execution. - -### 4. Implement HTTP RPC Through Shared Runtime - -Keep: - -```http -POST /_incur/rpc -``` - -Route behavior: - -- parse JSON request body; -- delegate validation/execution to the shared runtime; -- serialize non-streaming envelopes as JSON; -- serialize streaming command results as NDJSON; -- return JSON validation errors before a stream starts; -- advertise and accept `application/json, application/x-ndjson`; -- treat `Accept` as capability advertisement, not as a command-shape override; -- call `return()` on command streams when the HTTP response body is cancelled; -- preserve existing direct HTTP route behavior outside `/_incur/rpc`. - -Direct command HTTP routes must preserve existing streaming behavior while RPC is added: - -- async generator commands stream NDJSON chunks; -- terminal `c.ok(..., { cta })` metadata is preserved; -- terminal `c.error()` values become terminal error records; -- thrown stream errors become terminal error records; -- response cancellation closes the command stream. - -Tests: - -- success envelope; -- command error envelope; -- validation error; -- unknown command; -- command group rejection; -- fetch gateway rejection; -- output formatting; -- selection; -- token count; -- token limit/offset; -- streaming chunk/done records; -- streaming error records; -- terminal stream metadata; -- stream cancellation cleanup. - -### 5. Extract Discovery Builders - -Create an internal client discovery module. - -Suggested file: - -```txt -src/internal/client-discovery.ts -``` - -Expose a shared function equivalent to: - -```ts -type DiscoverClientResource = ( - cli: RuntimeCliContext, - request: DiscoveryRequest, -) => Promise -``` - -Discovery builders: - -- `llms`; -- `llmsFull`; -- `schema`; -- `help`; -- `openapi`; -- `skillsIndex`; -- `skill`; -- `mcpTools`. - -Reuse existing primitives: - -- `Skill.index()`; -- `Skill.generate()`; -- `Skill.split()`; -- `Openapi.fromCli()`; -- `Mcp.collectTools()`; -- existing help/schema formatting logic. - -Discovery builders must include OpenAPI-mounted operation commands everywhere command discovery is expected, and must exclude raw fetch gateways from command-run discovery. - -Avoid duplicated traversal between: - -- CLI `--llms`; -- CLI `--llms-full`; -- well-known skills routes; -- `_incur` discovery routes; -- memory discovery. - -### 6. Add HTTP Discovery Routes - -Add client discovery routes: - -```http -GET /_incur/llms -GET /_incur/llms-full -GET /_incur/schema -GET /_incur/help -GET /_incur/mcp/tools -GET /_incur/skills -GET /_incur/skill -``` - -Keep existing public routes: - -```http -GET /openapi.json -GET /openapi.yml -GET /openapi.yaml -GET /.well-known/openapi.json -GET /.well-known/skills/index.json -GET /.well-known/skills/{name}/SKILL.md -POST /mcp -``` - -HTTP discovery routes should delegate to shared discovery builders. - -Tests: - -- structured discovery payloads; -- formatted discovery payloads; -- content types; -- invalid query params; -- unknown command; -- command group handling where valid; -- unknown skill; -- unsafe skill names; -- matching payloads with existing well-known skills where applicable; -- matching MCP tool descriptors with `Mcp.collectTools()`. - -### 7. Extract Local Setup Runtime - -Create an internal local setup module. - -Suggested file: - -```txt -src/internal/client-local.ts -``` - -Expose wrappers for memory local actions: - -```ts -type LocalRuntime = { - skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise - } - mcp: { - add(options?: McpAddOptions | undefined): Promise - } -} -``` - -Reuse existing local implementations: - -- `SyncSkills.sync()`; -- `SyncSkills.list()`; -- `SyncMcp.register()`. - -This module should use TypeScript-shaped options: - -- `global?: boolean | undefined`; -- `agents?: string[] | undefined`; -- `command?: string | undefined`; -- `depth?: number | undefined`. - -Parity details: - -- `skills.add()` uses configured sync depth when present, otherwise `1`; -- `skills.add({ global: false })` maps to CLI `--no-global`; -- `skills.list()` uses the same depth default as CLI `skills list`; -- `mcp.add()` defaults `global` to `true`; -- `mcp.add({ agents })` maps to repeated CLI agents; -- `mcp.add({ command })` maps to CLI command override. - -It should not expose shell completions. - -### 8. Implement Transports - -Add transport constructors. - -Suggested files: - -```txt -src/client/transports/createTransport.ts -src/client/transports/http.ts -src/client/transports/memory.ts -``` - -The exact file layout can differ, but keep transport code separate from action code. - -Transport constructors: - -```ts -httpTransport(options): HttpTransport -memoryTransport(cli, options): MemoryTransport -``` - -Transport behavior: - -- `httpTransport(...).request()` calls `POST /_incur/rpc`; -- `httpTransport(...).discover()` calls HTTP discovery routes; -- `memoryTransport(...).request()` calls shared command runtime; -- `memoryTransport(...).discover()` calls shared discovery builders; -- `memoryTransport(...).local` calls shared local setup runtime. - -HTTP transport details: - -- use `options.fetch ?? globalThis.fetch`; -- throw `ClientError` when no fetch implementation exists; -- wrap fetch/network rejections in `ClientError` with message `RPC request failed`; -- normalize base URLs with and without trailing slashes; -- preserve base URL path prefixes; -- serialize omitted `args` and `options` as `{}`; -- send required protocol headers; -- merge custom headers predictably; -- parse JSON envelopes; -- parse NDJSON streams split across network chunks; -- ignore blank NDJSON lines; -- accept final NDJSON records without trailing newline; -- throw `ClientError` for invalid JSON, malformed envelopes, malformed stream records, missing stream bodies, and EOF before terminal stream records; -- cancel the underlying HTTP reader when the consumer stops early. - -Memory transport details: - -- execute in process without calling `cli.fetch()`; -- use explicit `env` option as the environment source; -- do not read CLI config defaults; -- close in-process streams when the consumer stops early. - -Transport tests should directly exercise transports without the final public client: - -- HTTP request success/error; -- HTTP stream parsing at transport level; -- missing fetch implementation; -- fetch/network rejection wrapping; -- HTTP base URL normalization; -- omitted `args`/`options` serializing as `{}`; -- required protocol headers; -- HTTP custom headers; -- non-JSON envelope errors; -- malformed envelope errors; -- HTTP malformed-response errors; -- NDJSON records split across chunks; -- blank NDJSON lines; -- final NDJSON record without trailing newline; -- missing stream body errors; -- malformed stream record errors; -- truncated stream errors; -- HTTP discovery routing; -- memory request behavior matching the HTTP runtime; -- memory env injection; -- memory middleware ordering; -- memory stream cancellation; -- memory discovery behavior matching the HTTP discovery builders; -- memory local actions; -- no local capability on HTTP transport. - -### 9. Implement OpenAPI Command Generation - -OpenAPI-mounted fetch handlers must generate command entries and command-map types before the public client layer is built. - -Runtime behavior: - -- dereference `$ref` pointers; -- support standard HTTP methods plus OpenAPI 3.2 `query`; -- merge path-level and operation-level parameters; -- use `operationId` as the command leaf name; -- derive fallback names from method and path when `operationId` is absent; -- apply `basePath`; -- URL-encode path parameters; -- map query parameters into `URLSearchParams`; -- flatten JSON request body object properties into options; -- infer output schemas from the first `200` response, then first `2xx` response; -- convert only `application/json` request and response bodies; -- return command errors with `HTTP_${status}` for failed fetch responses. - -Type behavior: - -- OpenAPI-mounted commands are included in `Cli.Cli`; -- OpenAPI-mounted commands are included in generated `Commands`; -- raw fetch gateways are excluded from generated command maps; -- generated OpenAPI args/options/output types match runtime command schemas. - -Tests: - -- path-level parameters; -- operation-level parameters; -- optional and required query parameters; -- optional and required JSON body fields; -- optional request body semantics; -- success output inference; -- operation fallback naming; -- OpenAPI 3.2 `query`; -- path parameter URL encoding; -- boolean and number path/query coercion; -- strict boolean string coercion; -- raw fetch gateway exclusion; -- no serving required before OpenAPI-mounted command generation; -- generated command round trip through memory transport. - -### 10. PR 1 Non-Goals - -Do not complete the final typed public client surface in this PR. - -Do not add final `RunActions`, `DiscoveryActions`, or `LocalActions` method binding except where needed for low-level transport tests. - -Do not change MCP tool scope to include setup/admin commands. - -Do not add shell completions to any client/transport API. - -## PR 2: Public Client And Type Surface - -Goal: build the final typed API over the tested transport/runtime foundation. - -This PR should make `docs/api_example.ts` typecheck conceptually against the public client surface. - -### 1. Implement Client Creation - -Implement: - -```ts -createClient({ transport, ...defaults }) -createHttpClient(options) -createMemoryClient(cli, options) -``` - -`createClient` should: - -- generate a `uid`; -- resolve the transport factory; -- store client defaults; -- expose resolved transport metadata; -- attach action sets. - -Convenience factories must remain thin wrappers. - -`createMemoryClient(cli)` should infer `commands` from `Cli.Cli` when possible, and should allow an explicit generic override when inference is not desired. - -An explicit permissive command map such as `Record` should be supported as an intentional escape hatch. - -### 2. Implement Action Binding - -Add action modules. - -Suggested layout: - -```txt -src/client/actions/run.ts -src/client/actions/discovery.ts -src/client/actions/local.ts -``` - -Actions should be standalone functions that consume a client. - -The bound client methods should call those standalone actions. - -The action model should stay close to viem's pattern: - -- action implementation receives `client`; -- action calls `client.transport` capabilities; -- convenience client creators compose action sets; -- future overrides/extensions remain possible. - -### 3. Add RunActions - -Implement: - -```ts -client.run(command, input?) -``` - -Runtime behavior: - -- merge client defaults and per-call output controls; -- build `RpcRequest`; -- call `client.transport.request()`; -- normalize successful envelopes into `ClientRunResult`; -- throw `ClientError` for command failures; -- normalize CTAs; -- attach `output.next()` where applicable; -- return stream wrapper for streaming commands. - -Type behavior: - -- command IDs are generated canonical command IDs; -- aliases are not accepted by generated client types; -- required args/options require `input`; -- selected data is `unknown`; -- `selection: undefined` clears default selection; -- streaming commands return `ClientStreamResponse`; -- non-streaming commands return `ClientRunResult`. - -Tests: - -- `.test-d.ts` for required/optional input; -- `.test-d.ts` for root command IDs; -- `.test-d.ts` for mounted root CLI IDs; -- `.test-d.ts` for mounted router CLI IDs; -- `.test-d.ts` for permissive command maps; -- `.test-d.ts` for memory client command inference and explicit override; -- `.test-d.ts` for selected data; -- `.test-d.ts` for default selection clearing; -- runtime tests for output controls; -- runtime tests for `ClientError`; -- runtime tests for `output.next()`. - -### 4. Add CTA Normalization - -Normalize RPC CTA metadata into public client CTA objects. - -Rules: - -- CTA data lives under `meta.cta`; -- runnable CTAs expose typed `run()`; -- unresolved CTAs expose `runnable: false` and `unresolvedReason`; -- `cliCommand` is CLI-ready text; -- `cliCommand` includes the CLI/root command prefix exactly once; -- structured CTA args render as positional values; -- structured CTA args with value `true` render as placeholders; -- structured CTA options render as `--key value` flags; -- structured CTA options with value `true` render as placeholders; -- `raw` preserves source CTA data; -- CTA `run()` inherits client defaults, not source-run output controls. - -Tests: - -- string CTA; -- structured CTA; -- command CTA; -- unknown command CTA; -- invalid input CTA; -- error CTA; -- streaming terminal CTA. - -### 5. Add Stream Wrapper - -Implement `ClientStreamResponse`. - -Behavior: - -- default async iteration yields chunks; -- `records()` yields all normalized records; -- `final` resolves/rejects from the terminal record; -- stream is single-consumer; -- protocol errors throw `ClientError`; -- terminal command errors are yielded by `records()` and thrown by default iteration/final; -- split NDJSON records are parsed correctly; -- blank NDJSON lines are ignored; -- final NDJSON records do not require a trailing newline; -- early consumer exit cancels or returns the underlying stream. - -Tests: - -- chunk iteration; -- final metadata; -- terminal error; -- records mode; -- single-consumer enforcement; -- cancellation behavior; -- invalid JSON record errors; -- malformed record errors; -- missing body errors; -- EOF before terminal record errors. - -### 6. Add DiscoveryActions - -Implement: - -```ts -client.llms() -client.llmsFull() -client.schema(command?) -client.help(command?) -client.openapi() -client.skills.index() -client.skills.get(name) -client.mcp.tools() -``` - -Runtime behavior: - -- call `client.transport.discover()`; -- normalize discovery errors into `ClientError`; -- preserve structured return by default; -- return strings for explicit `format`. - -Type behavior: - -- omitted `format` returns structured data; -- literal `format` returns `string`; -- variable `DiscoveryFormat | undefined` returns structured-or-string; -- command scopes are typed from generated command maps; -- `skills.get(name)` accepts safe strings and server/runtime validates existence. - -Tests: - -- `.test-d.ts` for overloads; -- `.test-d.ts` for command scope narrowing; -- runtime tests for all discovery actions over HTTP transport; -- runtime tests for all discovery actions over memory transport. - -### 7. Add LocalActions - -Implement local actions only for memory clients: - -```ts -memory.skills.add(options?) -memory.skills.list(options?) -memory.mcp.add(options?) -``` - -Runtime behavior: - -- actions call `client.transport.local`; -- no HTTP route is involved; -- no RPC call is involved; -- no MCP tool is involved; -- local action defaults match the spec. - -Type behavior: - -- `MemoryClient` exposes local actions; -- `HttpClient` does not expose local actions; -- `Client` does not expose local actions; -- `Client` exposes local actions. - -Tests: - -- `.test-d.ts` for action availability; -- runtime tests for skills add/list; -- runtime tests for MCP registration; -- runtime tests for default local-action option mapping; -- runtime tests or route tests proving HTTP/RPC/MCP do not expose local setup/admin commands. - -### 8. Update Typegen - -Generated command maps should include: - -- canonical command IDs; -- `args`; -- `options`; -- optional `output`; -- `stream: true` for streaming commands. - -Rules: - -- command groups are not command IDs; -- aliases are not command IDs; -- mounted CLI commands are flattened; -- missing output schema maps to `unknown`; -- streaming `output` is the chunk type; -- generated files export `Commands`; -- generated files augment both `incur` and `incur/client`; -- optional properties include `| undefined`; -- invalid object keys and command keys are escaped; -- unsupported schemas fail with a clear typegen error. - -Schema support: - -- primitives, literals, enums, unions, arrays; -- records and enum-key records; -- tuples and rest tuples; -- nested objects; -- catchall/index signatures; -- non-object top-level outputs; -- void, undefined, never, and unknown fallbacks. - -Tests: - -- typegen command ID output; -- stream marker output; -- outputless command typing; -- mounted command typing; -- alias exclusion; -- exported `Commands` shape; -- module augmentation shape; -- exact optional property output; -- non-object output schemas; -- records and enum-key records; -- tuples and rest tuples; -- escaped keys; -- catchall output; -- unsupported schema errors; -- OpenAPI-mounted command output. - -### 9. Add Public Error Types - -Expose public client error types from `incur/client`: - -```ts -ClientError -ClientRpcEnvelope -ClientRpcError -ClientRpcErrorEnvelope -ClientRpcMeta -isClientRpcError -isClientRpcErrorEnvelope -``` - -Tests: - -- `ClientError` fields; -- narrowing `ClientError.error` with `isClientRpcError`; -- narrowing `ClientError.data` with `isClientRpcErrorEnvelope`; -- `ClientError.data`; -- `ClientError.error`; -- `ClientError.status`; -- `ClientError.meta`; -- `ClientError.code`; -- `ClientError.retryable`; -- `ClientError.fieldErrors`; -- malformed response errors preserve diagnostic `data`; -- wrapped fetch failures preserve `cause`; -- failed RPC envelopes preserve error payloads and status. - -### 10. Package Export - -Expose the client subpath. - -Add or update package exports so this works: - -```ts -import { createHttpClient } from 'incur/client' -``` - -Ensure generated declarations and runtime files are emitted for the subpath. - -Do not export client creation APIs from the root `incur` module. - -### 11. Documentation And Example - -Finalize: - -- `docs/typed-client-spec.md`; -- `docs/api_example.ts`; -- public README/API docs as needed. - -The example should show: - -- `createHttpClient`; -- equivalent `createClient({ transport: httpTransport(...) })`; -- `createMemoryClient`; -- equivalent `createClient({ transport: memoryTransport(...) })`; -- run actions; -- output controls; -- CTAs; -- streaming; -- discovery actions; -- memory-only local actions. - -### 12. PR 2 Non-Goals - -Do not add shell completions to TS clients. - -Do not expose local actions over HTTP, RPC, or MCP. - -Do not add config default loading to TS clients. - -Do not add a data-only run API. - -Do not introduce additional transports beyond HTTP and memory. diff --git a/docs/typed-client-spec.md b/docs/typed-client-spec.md deleted file mode 100644 index ad8f92c..0000000 --- a/docs/typed-client-spec.md +++ /dev/null @@ -1,1455 +0,0 @@ -# TypeScript Client Spec - -This document specifies the target TypeScript client architecture for incur. It is written as a final-state contract: every section describes the API, runtime, protocol, and type behavior that exists after implementation. - -The design follows the same core model as viem: - -- transports own the execution mechanics; -- clients hold a transport and defaults; -- actions are typed wrappers over client transport capabilities; -- convenience clients are thin compositions over `createClient`; -- transport capabilities determine which actions are present. - -## Overview - -The TypeScript client has three layers: - -1. **Transports** perform work. - - `HttpTransport` serializes requests to incur HTTP routes. - - `MemoryTransport` executes against an in-process CLI instance. - -2. **Clients** hold a transport and client defaults. - - `createClient({ transport, ...defaults })` is the primitive. - - `createHttpClient(options)` wraps `createClient({ transport: httpTransport(...) })`. - - `createMemoryClient(cli, options)` wraps `createClient({ transport: memoryTransport(...) })`. - -3. **Actions** expose the typed API. - - `RunActions` execute CLI commands. - - `DiscoveryActions` expose read-only discovery. - - `LocalActions` expose local setup/admin commands, and exist only on memory clients. - -Minimal example: - -```ts -const http = createHttpClient({ - baseUrl: 'https://ops.acme.test', -}) - -const memory = createMemoryClient(cli, { - env: { ACME_TOKEN: 'dev_secret_123' }, -}) -``` - -Equivalent primitive form: - -```ts -const http = createClient({ - transport: httpTransport({ baseUrl: 'https://ops.acme.test' }), -}) - -const memory = createClient({ - transport: memoryTransport(cli, { - env: { ACME_TOKEN: 'dev_secret_123' }, - }), -}) -``` - -## Package Surface - -Client APIs are exported from `incur/client`. - -```ts -import { - ClientError, - createClient, - createHttpClient, - createMemoryClient, - httpTransport, - memoryTransport, -} from 'incur/client' - -import type { - Client, - ClientRpcEnvelope, - ClientRpcError, - ClientRpcMeta, - HttpClient, - HttpTransport, - MemoryClient, - MemoryTransport, -} from 'incur/client' -``` - -The root `incur` export remains available for low-level framework APIs. The client subpath keeps runtime/client concepts separate from CLI construction. - -The client creation APIs are exported only from `incur/client`. The root `incur` module must not export `createClient`, `createHttpClient`, `createMemoryClient`, `httpTransport`, or `memoryTransport`. - -Generated command types are importable as normal TypeScript types from the generated file: - -```ts -import type { Commands } from './generated/incur-client.js' -``` - -The generated file also augments client typing so projects can omit the explicit generic when they want global generated commands. See [Generated Command Maps](#generated-command-maps). - -## Rejected Shapes - -These shapes are not part of the TypeScript client contract: - -- no curried command client such as `client('project report')(input)`; -- no HTTP-only `createClient({ baseUrl })`; -- no client creation APIs exported from root `incur`; -- no data-only command result API; -- no bare async iterable stream return without `final` and `records()`; -- no chunk-only stream terminal behavior; -- no stream terminal records without full metadata; -- no RPC alias command identity; -- no local setup/admin actions over HTTP, RPC, or MCP. - -## Client Model - -`createClient` creates a typed client by resolving a transport and attaching action sets. - -```ts -type Client< - commands = Commands, - transport extends Transport = Transport, - defaults extends ClientDefaults = {}, -> = ClientBase & - RunActions & - DiscoveryActions & - ([transport] extends [MemoryTransport] ? LocalActions : {}) -``` - -Use a non-distributive conditional for `LocalActions`. A client whose transport type is the broad union `Transport` must not expose local actions just because one union member is `MemoryTransport`. - -```ts -type HttpClient = Client< - commands, - HttpTransport, - defaults -> - -type MemoryClient = Client< - commands, - MemoryTransport, - defaults -> -``` - -Client base: - -```ts -type ClientBase = { - defaults: defaults - transport: ResolvedTransport - type: 'client' -} -``` - -`defaults` are used by actions. They are not sent to transports as opaque state; actions merge defaults into typed request objects before calling transport methods. - -Client defaults: - -```ts -type ClientDefaults = { - outputFormat?: Formatter.Format | undefined - selection?: string[] | undefined - outputTokenCount?: boolean | undefined - outputTokenLimit?: number | undefined - outputTokenOffset?: number | undefined -} -``` - -Factory types: - -```ts -type CreateClientOptions< - transport extends Transport, - defaults extends ClientDefaults, -> = defaults & { - transport: transport -} - -declare function createClient< - const commands = Commands, - const transport extends Transport = Transport, - const defaults extends ClientDefaults = {}, ->(options: CreateClientOptions): Client - -declare function createHttpClient< - const commands = Commands, - const defaults extends ClientDefaults = {}, ->(options: HttpTransportOptions & defaults): HttpClient - -declare function createMemoryClient< - const commands extends Cli.CommandsMap, - const defaults extends ClientDefaults = {}, ->( - cli: Cli.Cli, - options?: (MemoryTransportOptions & defaults) | undefined, -): MemoryClient - -declare function createMemoryClient< - const commands = Commands, - const defaults extends ClientDefaults = {}, ->( - cli: Cli.Any, - options?: (MemoryTransportOptions & defaults) | undefined, -): MemoryClient -``` - -`createMemoryClient(cli)` infers the command map from `cli` when the CLI value carries a concrete `Cli.Cli` type. Passing an explicit generic overrides inference: - -```ts -const inferred = createMemoryClient(cli) -const explicit = createMemoryClient(cli) -``` - -Explicit generics are useful when the CLI value is widened, when a generated command map is preferred, or when a permissive command map is intentionally used. - -Permissive clients are supported through an explicit unknown command map: - -```ts -type UnknownCommands = Record< - string, - { - args: unknown - options: unknown - output: unknown - } -> - -const client = createHttpClient({ baseUrl }) - -await client.run('runtime-only command', { - args: { any: 'value' }, - options: { shape: ['accepted'] }, -}) -``` - -This is an escape hatch. It disables command-name and input-shape inference for the chosen client instance only. - -Convenience factories are thin wrappers: - -```ts -function createHttpClient( - options: HttpTransportOptions & defaults, -) { - const { baseUrl, fetch, headers, ...defaults } = options - return createClient({ - ...defaults, - transport: httpTransport({ baseUrl, fetch, headers }), - }) -} - -function createMemoryClient( - cli: Cli.Any, - options: MemoryTransportOptions & defaults = {} as MemoryTransportOptions & defaults, -) { - const { env, ...defaults } = options - return createClient({ - ...defaults, - transport: memoryTransport(cli, { env }), - }) -} -``` - -## Transport Model - -Transports are factories. `createClient` invokes the transport factory and stores the resolved transport on the client. - -This mirrors viem's pattern: transport constructors such as `httpTransport(...)` return a transport factory, and `createClient` resolves that factory with client runtime context. - -```ts -type Transport = HttpTransport | MemoryTransport - -type TransportType = 'http' | 'memory' - -type TransportConfig = { - key: string - name: string - type: type -} - -type TransportCapabilities = Record - -type TransportFactory< - type extends TransportType, - capabilities extends TransportCapabilities, -> = () => { config: TransportConfig } & capabilities -``` - -Resolved transport: - -```ts -type ResolvedTransport = ReturnType['config'] & - Omit, 'config'> -``` - -HTTP transport: - -```ts -type HttpTransport = TransportFactory< - 'http', - { - baseUrl: URL - request(request: RpcRequest): Promise - discover(request: DiscoveryRequest): Promise - } -> - -type HttpTransportOptions = { - baseUrl: string | URL - fetch?: typeof globalThis.fetch | undefined - headers?: HeadersInit | undefined -} - -declare function httpTransport(options: HttpTransportOptions): HttpTransport -``` - -`httpTransport` uses `options.fetch ?? globalThis.fetch`. If no fetch implementation exists, transport creation throws `ClientError`. Fetch and network rejections are wrapped in `ClientError` with message `RPC request failed` and the original error as `cause`. - -Memory transport: - -```ts -type MemoryTransport = TransportFactory< - 'memory', - { - request(request: RpcRequest): Promise - discover(request: DiscoveryRequest): Promise - local: LocalActionTransportApi - } -> - -type MemoryTransportOptions = { - env?: Record | undefined -} - -declare function memoryTransport( - cli: Cli.Any, - options?: MemoryTransportOptions | undefined, -): MemoryTransport -``` - -Local transport capability: - -```ts -type LocalActionTransportApi = { - skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise - } - mcp: { - add(options?: McpAddOptions | undefined): Promise - } -} -``` - -Transport responsibilities: - -- `HttpTransport.request()` calls `POST /_incur/rpc`. -- `MemoryTransport.request()` calls the shared in-process command execution runtime. -- `HttpTransport.discover()` calls HTTP discovery routes. -- `MemoryTransport.discover()` calls shared in-process discovery builders. -- `MemoryTransport.local` calls shared local setup/admin builders. - -HTTP transport serialization rules: - -- `baseUrl` is normalized so `https://api.example.com`, `https://api.example.com/`, and `https://api.example.com/v1` produce `/_incur/rpc` under that base path. -- omitted `args` serialize as `{}`. -- omitted `options` serialize as `{}`. -- command requests use `POST`. -- request headers include `content-type: application/json`. -- request headers include `accept: application/json, application/x-ndjson`. -- custom `headers` are merged into discovery and RPC requests without removing required protocol headers unless a custom header intentionally overrides the same key. - -HTTP transport stream parsing rules: - -- match the response media type by essence; `application/x-ndjson; charset=utf-8` is NDJSON. -- parse records separated by `\n`. -- accept records split across network chunks. -- ignore blank lines. -- accept a final record without a trailing newline. -- throw `ClientError` for invalid JSON records. -- throw `ClientError` for malformed records. -- throw `ClientError` when a streaming response has no body. -- throw `ClientError` when the stream ends before a terminal `done` or `error` record. -- cancel the underlying reader when the consumer stops early. - -Memory transport execution rules: - -- memory request execution never calls `cli.fetch()`. -- memory request execution uses the same shared command runtime as HTTP RPC. -- memory request execution accepts explicit `env` from `MemoryTransportOptions`. -- memory request execution does not apply CLI config-file defaults. -- memory streams call `return()` on the command generator when the consumer stops early. - -Actions do not duplicate transport work. Actions build typed request objects, call transport capabilities, and normalize results for the public client API. - -## Action Model - -Actions are transport consumers. They are implemented as standalone functions that accept a client, then exposed as methods on client instances. - -```ts -async function run(client, command, input) { - const request = toRpcRequest(command, input, client.defaults) - const response = await client.transport.request(request) - return normalizeRunResponse(client, request, response) -} -``` - -The public method form is a bound action: - -```ts -await client.run('project report', { - args: { projectId: 'proj_web_2026' }, -}) -``` - -Action composition: - -```ts -type RunActions = { - run< - const command extends CommandId, - const input extends RunInput | undefined = undefined, - >( - command: command, - ...input: RunInputParameters - ): Promise> -} - -type DiscoveryActions = { - llms: LlmsAction - llmsFull: LlmsFullAction - schema: SchemaAction - help: HelpAction - openapi(): Promise - skills: { - index(): Promise - get(name: string): Promise - } - mcp: { - tools(): Promise> - } -} - -type LocalActions = { - skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise - } - mcp: { - add(options?: McpAddOptions | undefined): Promise - } -} -``` - -Memory clients merge `LocalActions` into the same `skills` and `mcp` namespaces used by discovery: - -```ts -const memory = createMemoryClient(cli) - -await memory.skills.index() -await memory.skills.get('deploy') -await memory.skills.list() -await memory.skills.add() - -await memory.mcp.tools() -await memory.mcp.add() -``` - -HTTP clients do not expose local actions: - -```ts -const http = createHttpClient({ baseUrl }) - -await http.skills.index() -await http.mcp.tools() - -await http.skills.add() -// ^ type error -``` - -## Run Actions - -`client.run(command, input)` executes a leaf command by canonical command ID. - -Canonical command IDs are CLI token paths joined by single spaces: - -```ts -await client.run('project report', { - args: { projectId: 'proj_web_2026' }, - options: { includeClosed: false }, -}) -``` - -Aliases are accepted by CLI argv parsing but are not generated command IDs. Typed clients use canonical command IDs only. - -Aliases are CLI-only for typed client purposes. `client.run()` is typed against canonical command IDs, generated command maps omit aliases, and RPC requests produced by typed clients always send canonical IDs. A raw RPC request that sends an alias is not part of the typed client contract and must not be required for client correctness. - -Root command IDs: - -- a root CLI created with `Cli.create('status', { run })` has command ID `'status'`; -- a root CLI mounted on a parent keeps its own command ID, such as `'status'`, not `'app status'`; -- a router CLI mounted as a command group prefixes its leaf command IDs, such as `'project list'`; -- nested command groups flatten with single spaces, such as `'project deploy create'`. - -Run input: - -```ts -type CommandArgs> = commands[command] extends { - args: infer args -} - ? args - : unknown - -type CommandOptions> = commands[command] extends { - options: infer options -} - ? options - : unknown - -type CommandData> = commands[command] extends { - output: infer output -} - ? output - : unknown - -type RunInput> = Field< - 'args', - CommandArgs -> & - Field<'options', CommandOptions> & - OutputOptions -``` - -Required args/options determine whether the input argument itself is required. - -```ts -type RunInputParameters< - commands, - command extends CommandId, - input extends RunInput | undefined, -> = - RequiredKeys> extends never - ? [input?: input | undefined] - : [input: input & RunInput] -``` - -Run return: - -```ts -type RunReturn< - commands, - command extends CommandId, - input extends RunInput | undefined, - defaults extends ClientDefaults, -> = commands[command] extends { stream: true } - ? ClientStreamResponse< - EffectiveRunOutput, input, defaults>, - unknown, - commands - > - : ClientRunResult, input, defaults>, commands> -``` - -Non-streaming commands return a full success result. Command failures throw `ClientError`. - -```ts -type ClientRunResult = { - ok: true - data: data - output?: ClientOutput | undefined - meta: ClientMeta -} -``` - -There is no public data-only run API. Consumers use the field they need: - -```ts -const result = await client.run('status') - -result.data -result.output?.text -result.meta -``` - -## Output Controls - -Output controls are set as client defaults or per-run options. - -```ts -const client = createHttpClient({ - baseUrl, - outputFormat: 'toon', - selection: ['items[0:10]'], - outputTokenLimit: 1_000, -}) - -await client.run('project report', { - args: { projectId: 'proj_web_2026' }, - outputFormat: 'md', - outputTokenLimit: 24, -}) -``` - -Options: - -```ts -type OutputOptions = { - outputFormat?: Formatter.Format | undefined - selection?: string[] | undefined - outputTokenCount?: boolean | undefined - outputTokenLimit?: number | undefined - outputTokenOffset?: number | undefined -} -``` - -Rules: - -- `selection` applies to structured `data`. -- `outputFormat`, `outputTokenCount`, `outputTokenLimit`, and `outputTokenOffset` apply to `output`. -- Output controls never mutate `data`. -- Any effective `selection` changes returned `data` to `unknown`. -- Literal `selection: undefined` clears a client-level selection. -- Omitting `selection` preserves a client-level selection. -- A `string[] | undefined` variable is conservatively treated as selected data. -- Token controls imply formatted output. If no `outputFormat` is effective, use `toon`. -- `output.next()` reruns the same command with the next `outputTokenOffset`. - -Type behavior: - -```ts -type EffectiveRunOutput = EffectiveOutput< - output, - input extends { selection: infer selection } - ? selection - : defaults extends { selection: infer selection } - ? selection - : undefined -> - -type EffectiveOutput = [selection] extends [undefined] ? output : unknown -``` - -Client output: - -```ts -type ClientOutput = { - text: string - format?: Formatter.Format | undefined - tokenCount?: number | undefined - tokenLimit?: number | undefined - tokenOffset?: number | undefined - next?: (() => Promise>) | undefined -} -``` - -Streaming commands accept `selection` and `outputFormat`. They reject `outputTokenCount`, `outputTokenLimit`, and `outputTokenOffset` because stream pagination requires an aggregate buffering design that this API does not define. - -## CTA Model - -CTAs are normalized under `meta.cta`. - -```ts -type ClientMeta = { - command: string - duration: string - cta?: ClientCtaBlock | undefined -} - -type ClientCtaBlock = { - description?: string | undefined - commands: ClientCta[] -} -``` - -CTA commands preserve raw data and expose CLI-ready text: - -```ts -type ClientCta = - | ClientRunnableCta> - | ClientUnresolvedCta - -type ClientRunnableCta> = { - command: command - cliCommand: string - description?: string | undefined - args?: CommandArgs | undefined - options?: CommandOptions | undefined - raw: unknown - runnable: true - run( - options?: options, - ): Promise> -} - -type ClientUnresolvedCta = { - cliCommand?: string | undefined - description?: string | undefined - raw: unknown - runnable: false - unresolvedReason: 'unknown-command' | 'invalid-input' | 'unstructured' -} -``` - -`cta.run()` is equivalent to: - -```ts -client.run(cta.command, { - args: cta.args, - options: cta.options, - ...ctaRunOptions, -}) -``` - -CTA `run()` inherits client defaults. It does not inherit output controls from the command that produced the CTA. - -CTA formatting rules: - -- `cliCommand` is CLI-ready text. -- `cliCommand` includes the CLI/root command prefix exactly once. -- string CTAs are interpreted relative to the current CLI name when needed. -- structured CTA `args` render as positional values. -- structured CTA `args` with value `true` render as placeholders, such as ``. -- structured CTA `options` render as `--key value` flags. -- structured CTA `options` with value `true` render as placeholders, such as `--project-id `. -- `raw` preserves the original CTA value without normalization. - -## Streaming - -Streaming commands return a stream object, not a bare async iterable. - -```ts -const stream = await client.run('logs tail', { - args: { service: 'checkout-api' }, -}) - -for await (const chunk of stream) { - console.log(chunk) -} - -const final = await stream.final -``` - -Shape: - -```ts -type ClientStreamResponse< - chunk, - finalData = unknown, - commands = Commands, -> = AsyncIterable & { - final: Promise> - records: () => AsyncIterable> -} - -type ClientStreamFinal = { - ok: true - data?: finalData | undefined - meta: ClientMeta -} - -type ClientStreamRecord = - | { type: 'chunk'; data: chunk; output?: ClientStreamOutput | undefined } - | { type: 'done'; ok: true; data?: finalData | undefined; meta: ClientMeta } - | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } -``` - -Rules: - -- A stream is single-consumer. -- Default async iteration yields `chunk.data`. -- Default async iteration throws `ClientError` when the terminal record is `error`. -- `records()` yields normalized records and does not throw for command error records. -- `final` resolves for terminal `done`. -- `final` rejects with `ClientError` for terminal `error`. -- Every stream has exactly one terminal `done` or `error` record. - -## Discovery Actions - -Discovery actions are read-only and available on both HTTP and memory clients. - -```ts -await client.llms() -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() -``` - -Format behavior: - -- Omitted `format` returns structured data. -- Literal `format` returns formatted text. -- `format: 'json'` returns JSON text. -- Omit `format` to receive parsed structured data. - -Discovery formats: - -```ts -type DiscoveryFormat = 'md' | 'json' | 'yaml' | 'toon' - -type DiscoveryResult = [format] extends [undefined] - ? structured - : undefined extends format - ? structured | string - : string -``` - -Command scopes: - -```ts -type CommandId = keyof commands & string - -type CommandPrefix = command extends `${infer head} ${infer tail}` - ? head | `${head} ${CommandPrefix}` - : never - -type CommandScope = CommandId | CommandPrefix> -``` - -Discovery request kinds: - -```ts -type DiscoveryRequest = - | { kind: 'llms'; command?: string | undefined; format?: DiscoveryFormat | undefined } - | { kind: 'llmsFull'; command?: string | undefined; format?: DiscoveryFormat | undefined } - | { kind: 'schema'; command?: string | undefined } - | { kind: 'help'; command?: string | undefined } - | { kind: 'openapi' } - | { kind: 'skillsIndex' } - | { kind: 'skill'; name: string } - | { kind: 'mcpTools' } -``` - -`client.skills.index()` and `client.skills.get(name)` are generated-skill discovery APIs. They do not report local install status and do not install skills. - -`client.mcp.tools()` returns the MCP tool descriptors the CLI exposes through MCP `tools/list`. It does not register MCP servers. - -## OpenAPI Discovery Documents - -`client.openapi()` returns the OpenAPI document generated from the CLI command tree. - -Generation rules: - -- aliases are omitted; -- command groups are omitted as operations and only contribute their leaf commands; -- raw fetch gateways are omitted; -- root commands are included under their root command ID; -- mounted root CLIs keep their own command ID; -- mounted router CLI leaf commands are flattened; -- operation IDs are stable and derived from command IDs; -- command descriptions map to operation summaries; -- command args become path parameters where possible; -- optional args create path variants so shorter paths remain valid; -- `get` and `delete` commands use query parameters for options; -- other commands use JSON request bodies for options; -- command output schemas become success response schemas; -- error responses use the standard incur error envelope; -- response bodies use the same full envelope shape as RPC and direct HTTP command APIs. - -Generated OpenAPI documents are discovery output. They do not change the RPC command protocol, and they do not expose local setup/admin actions. - -## Local Actions - -Local actions are available only on `MemoryClient`. - -```ts -const memory = createMemoryClient(cli) - -await memory.skills.list() -await memory.skills.add({ depth: 1, global: true }) -await memory.mcp.add({ agents: ['codex'] }) -``` - -Local actions are not exposed by: - -- `HttpClient`; -- HTTP routes; -- `POST /_incur/rpc`; -- MCP tools. - -Local action options: - -```ts -type SkillsAddOptions = { - depth?: number | undefined - global?: boolean | undefined -} - -type SkillsListOptions = { - depth?: number | undefined -} - -type McpAddOptions = { - agents?: string[] | undefined - command?: string | undefined - global?: boolean | undefined -} -``` - -Local action payloads: - -```ts -type SyncedSkills = { - agents: SkillAgentInstall[] - paths: string[] - skills: SyncedSkill[] -} - -type SkillsList = { - skills: ListedSkill[] -} - -type McpRegistration = { - agents: string[] - command: string -} -``` - -Option names are TypeScript-shaped: - -- use `global?: boolean | undefined`, not `noGlobal`; -- use `agents?: string[] | undefined`, not repeated `--agent`; -- use `command?: string | undefined`, not `--command` / `-c`. - -Local action mapping: - -- `memory.skills.add()` maps to CLI `skills add`; -- `memory.skills.list()` maps to CLI `skills list`; -- `memory.mcp.add()` maps to CLI `mcp add`. - -Local action defaults: - -- `memory.skills.add()` uses the same default depth as CLI `skills add`: configured sync depth when available, otherwise `1`. -- `memory.skills.add({ depth })` maps to CLI `--depth`. -- `memory.skills.add({ global: false })` maps to CLI `--no-global`. -- `memory.skills.add({ global: true })` maps to global installation behavior. -- `memory.skills.list()` uses the same default depth as CLI `skills list`. -- `memory.skills.list({ depth })` maps to CLI `skills list --depth`. -- `memory.mcp.add()` defaults `global` to `true`. -- `memory.mcp.add({ global: false })` maps to project/local registration behavior. -- `memory.mcp.add({ agents })` maps to repeated CLI `--agent` values. -- `memory.mcp.add({ command })` maps to CLI `--command` / `-c`. - -Shell completions remain CLI-only and are not local actions. - -## RPC Protocol - -The RPC protocol is the command execution wire contract used by `HttpTransport.request()`. - -HTTP endpoint: - -```http -POST /_incur/rpc -``` - -Request: - -```ts -type RpcRequest = { - command: string - args?: Record | undefined - options?: Record | undefined - outputFormat?: Formatter.Format | undefined - selection?: string[] | undefined - outputTokenCount?: boolean | undefined - outputTokenLimit?: number | undefined - outputTokenOffset?: number | undefined -} -``` - -Response: - -```ts -type RpcResponse = RpcFullEnvelope - -type RpcFullEnvelope = - | { - ok: true - data: unknown - output?: RpcOutput | undefined - meta: RpcMeta - } - | { - ok: false - error: ClientRpcError - output?: RpcOutput | undefined - meta: RpcMeta - } - -type RpcMeta = { - command: string - duration: string - cta?: RpcCtaBlock | undefined -} - -type RpcOutput = { - text: string - format?: Formatter.Format | undefined - tokenCount?: number | undefined - tokenLimit?: number | undefined - tokenOffset?: number | undefined - nextOffset?: number | undefined -} -``` - -Validation: - -- request body must be JSON object; -- `command` must be a non-empty string; -- `args` and `options` must be objects when present; -- `selection` must be omitted or a non-empty array of non-empty strings; -- unsupported output-control combinations return `400 VALIDATION_ERROR`; -- unknown command returns `404 COMMAND_NOT_FOUND`; -- fetch gateways return `400 FETCH_GATEWAY_UNSUPPORTED`. - -Command normalization: - -- `command` is trimmed before validation. -- empty trimmed command returns `400 VALIDATION_ERROR`. -- canonical command IDs use single spaces between tokens. -- clients generated from command maps send canonical IDs. -- the shared runtime returns canonical resolved command IDs in `meta.command`. - -Structured parsing: - -- RPC uses structured parsing, distinct from CLI argv, direct HTTP path/query/body routing, and MCP flat params. -- `args` are validated only against the command args schema. -- `options` are validated only against the command options schema. -- path segments are never decoded into args for RPC. -- query strings are never decoded into options for RPC. -- MCP flat-param splitting is not used for RPC. - -Streaming request uses the same endpoint and request body. Clients advertise support for both response shapes with `Accept: application/json, application/x-ndjson`. - -Content negotiation: - -- non-streaming command results return JSON envelopes; -- streaming command results return NDJSON records; -- `Accept` advertises supported response types but does not convert a streaming command into a non-streaming response or a non-streaming command into NDJSON; -- validation errors before stream creation return JSON envelopes even when the client accepts NDJSON. - -Streaming response media type: - -```http -application/x-ndjson -``` - -Records: - -```ts -type RpcStreamRecord = - | { type: 'chunk'; data: chunk; output?: RpcStreamOutput | undefined } - | { type: 'done'; ok: true; data?: finalData | undefined; meta: RpcMeta } - | { type: 'error'; ok: false; error: ClientRpcError; meta: RpcMeta } - -type RpcStreamOutput = { - text: string - format?: Formatter.Format | undefined -} -``` - -Rules: - -- validation errors before stream start return normal JSON envelopes; -- once a stream starts, every line is one JSON record; -- every stream ends with exactly one terminal `done` or `error`; -- the HTTP transport must match media type essence and ignore parameters such as `charset=utf-8`; -- a `done` record always includes full `RpcMeta`, including `command` and `duration`; -- an `error` record always includes full `RpcMeta`, including `command` and `duration`; -- terminal stream CTAs are preserved in `meta.cta`; -- server-side HTTP cancellation calls `return()` on the command stream; -- middleware after-hooks for streaming commands run after the stream is consumed or cancelled. - -Direct command HTTP routes keep equivalent streaming behavior where applicable: - -- async generator command chunks are emitted as NDJSON; -- terminal `c.ok(..., { cta })` metadata is preserved; -- terminal `c.error()` results become terminal error records; -- thrown stream errors become terminal error records; -- response cancellation closes the command stream. - -## HTTP Discovery Routes - -`HttpTransport.discover()` uses read-only HTTP routes. - -Existing routes: - -```http -GET /openapi.json -GET /openapi.yml -GET /openapi.yaml -GET /.well-known/openapi.json -GET /.well-known/skills/index.json -GET /.well-known/skills/{name}/SKILL.md -POST /mcp -``` - -Client discovery routes: - -```http -GET /_incur/llms -GET /_incur/llms-full -GET /_incur/schema?command=project%20report -GET /_incur/help?command=project%20report -GET /_incur/mcp/tools -GET /_incur/skills -GET /_incur/skill?name=deploy -``` - -Mapping: - -```ts -client.llms() // GET /_incur/llms -client.llmsFull() // GET /_incur/llms-full -client.schema(command) // GET /_incur/schema?command=... -client.help(command) // GET /_incur/help?command=... -client.openapi() // GET /openapi.json -client.skills.index() // GET /_incur/skills -client.skills.get(name) // GET /_incur/skill?name=... -client.mcp.tools() // GET /_incur/mcp/tools -``` - -Discovery error behavior: - -- invalid query params return `400 VALIDATION_ERROR`; -- unknown commands return `404 COMMAND_NOT_FOUND`; -- unknown safe skill names return `404 SKILL_NOT_FOUND`; -- errors use JSON envelopes with `ok: false`, `error`, and discovery `meta`. - -Discovery metadata: - -```ts -type DiscoveryMeta = { - route: string - duration?: string | undefined - requestId?: string | undefined - helpRoute?: string | undefined -} -``` - -## Shared Runtime Builders - -HTTP routes and memory transports must share runtime logic. They differ only in transport serialization and process boundary. - -Shared command runtime: - -```ts -type ExecuteClientCommand = ( - cli: RuntimeCliContext, - request: RpcRequest, -) => Promise -``` - -Responsibilities: - -- validate request shape; -- resolve canonical command IDs; -- reject command groups and fetch gateways where appropriate; -- call `Command.execute()`; -- use structured args/options parsing; -- call execution with `agent: true`; -- call execution with empty `argv`; -- call execution with explicit JSON/full-output semantics; -- do not decode path/query/MCP flat params for RPC; -- preserve validation `fieldErrors`; -- preserve root command identity; -- apply selection; -- format output; -- compute token metadata; -- create pagination offsets; -- preserve CTA metadata; -- emit streaming records; -- return canonical metadata; -- close command streams on cancellation. - -Shared discovery runtime: - -```ts -type DiscoverClientResource = ( - cli: RuntimeCliContext, - request: DiscoveryRequest, -) => Promise -``` - -Responsibilities: - -- build `llms`; -- build `llmsFull`; -- build `schema`; -- build `help`; -- build `openapi`; -- build `skills.index`; -- build `skills.get`; -- build `mcp.tools`. - -Shared local runtime: - -```ts -type LocalRuntime = { - skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise - } - mcp: { - add(options?: McpAddOptions | undefined): Promise - } -} -``` - -Implementation modules keep these boundaries explicit: - -- command graph traversal and resolution; -- command execution and output shaping; -- discovery builders; -- local setup/admin wrappers; -- HTTP serialization; -- TS client actions. - -## Generated Command Maps - -Generated command maps drive client typing. - -```ts -export type Commands = { - 'project report': { - args: { projectId: string } - options: { includeClosed?: boolean | undefined } - output: ProjectReport - } - 'logs tail': { - args: { service: string } - options: {} - output: LogLine - stream: true - } -} - -declare module 'incur' { - interface Register { - commands: Commands - } -} - -declare module 'incur/client' { - interface Register { - commands: Commands - } -} -``` - -Generated files are normal TypeScript modules. They export `Commands` so callers can import it directly, and they augment both root and client modules so default command registration works in either import style. - -Rules: - -- command IDs are canonical command paths joined by spaces; -- aliases are excluded; -- command groups are excluded from run command IDs; -- mounted sub-CLI commands are flattened into canonical IDs; -- `output` is omitted when no output schema exists; -- missing `output` infers `unknown`; -- streaming commands include `stream: true`; -- streaming command `output` is the chunk type; -- object keys that are not valid TypeScript identifiers are quoted; -- command keys are emitted with `JSON.stringify`-compatible escaping; -- optional properties include `| undefined` for `exactOptionalPropertyTypes`; -- unsupported schemas throw a typegen error instead of silently emitting `unknown`. - -Streaming detection: - -- a command is streaming when its handler is declared as an async generator function, `async *run`; -- generated type maps mark streaming commands with `stream: true`; -- generated type maps use the declared command `output` schema as the stream chunk type; -- commands that return an async generator from a non-generator `run()` are not part of the typed streaming contract; -- authors should use `async *run` whenever generated clients need streaming-aware types. - -Typegen schema support: - -- object schemas; -- optional object properties; -- string, number, integer, boolean, null, void, undefined, never, and unknown; -- literals and enums; -- unions emitted from JSON Schema `anyOf`; -- arrays, including arrays of union items; -- records, including enum-key records when JSON Schema property names allow it; -- tuples and rest tuples; -- nested objects; -- object catchalls widened into compatible index signatures; -- non-object top-level output schemas. - -Unsupported typegen inputs: - -- schemas that cannot be converted to JSON Schema; -- transforms whose output type cannot be represented from JSON Schema; -- any schema where typegen cannot produce a stable TypeScript type. - -Unsupported inputs throw `TypegenError` with a clear message. - -OpenAPI-mounted fetch gateways participate in generated command maps when they are mounted with an OpenAPI spec. Raw fetch gateways are excluded. - -Generated OpenAPI command map rules: - -- command IDs are `${mountName} ${operationName}`; -- `operationId` defines `operationName`; -- when `operationId` is absent, `operationName` is derived from method and path; -- path parameters become command `args`; -- query parameters become command `options`; -- JSON request body object properties become command `options`; -- JSON success response schema becomes command `output`; -- absent success response schema means missing `output`, which infers `unknown`; -- path-level parameters are merged with operation-level parameters; -- required path parameters are required args; -- required query parameters are required options; -- request body properties are required only when the OpenAPI request body is required and the schema property is required; -- only JSON request and response bodies are projected into command types. - -Type tests must cover: - -- `createClient` preserving transport type; -- `createHttpClient` exposing no local actions; -- `createMemoryClient` exposing local actions; -- broad `Transport` exposing no local actions; -- required input for required args/options; -- optional input for optional args/options; -- selected data becoming `unknown`; -- `selection: undefined` clearing default selection; -- streaming return shape; -- discovery overloads; -- CTA runnable typing; -- generated file module augmentation; -- memory client inference from `Cli.Cli`; -- explicit command-map overrides; -- permissive unknown command maps; -- root command IDs; -- mounted root CLI IDs; -- mounted router CLI IDs; -- OpenAPI-mounted command IDs and input/output inference; -- exact optional property emission; -- non-object output schemas; -- unsupported schema failure. - -## OpenAPI-Mounted Commands - -OpenAPI-mounted fetch handlers turn OpenAPI operations into incur command entries. - -```ts -const cli = create('acme').command('api', { - fetch: app.fetch, - openapi: spec, -}) - -const client = createMemoryClient(cli) - -await client.run('api getUser', { - args: { id: 123 }, -}) -``` - -Runtime generation rules: - -- `$ref` pointers are dereferenced before commands are generated. -- OpenAPI methods include standard HTTP methods and OpenAPI 3.2 `query`. -- path-level parameters are applied to every operation under that path. -- operation-level parameters are merged with path-level parameters. -- `operationId` is the command leaf name when present. -- fallback names are derived from method and path. -- `basePath` prefixes generated request paths. -- path parameter values are URL-encoded when requests are built. -- query parameters are written to `URLSearchParams`. -- JSON request body object properties are flattened into options. -- only `application/json` request bodies are flattened. -- the first `200` response is preferred for output schema inference. -- if no `200` response exists, the first `2xx` response is used. -- only `application/json` response schemas are converted to output schemas. -- failed HTTP responses return command errors with `HTTP_${status}` codes. - -Parameter coercion: - -- path and query numbers use numeric coercion. -- path and query booleans accept only `true` and `false` string values as booleans. -- other string values remain invalid and fail schema validation. -- body properties do not receive path/query string coercion. - -Generated OpenAPI command maps and runtime OpenAPI commands must match: every generated command ID must be callable through the shared command runtime, HTTP RPC, memory transport, and MCP tool generation when the operation is otherwise MCP-compatible. - -## Error Handling - -Command failures throw `ClientError`. - -```ts -class ClientError extends Error { - data: unknown - error: unknown - status?: number | undefined - meta?: ClientMeta | DiscoveryMeta | undefined - code?: string | undefined - retryable?: boolean | undefined - fieldErrors?: ClientRpcFieldError[] | undefined -} -``` - -RPC payload types: - -```ts -type ClientRpcMeta = { - command?: string | undefined - cta?: unknown | undefined - duration?: string | undefined -} - -type ClientRpcError = { - code: string - fieldErrors?: ClientRpcFieldError[] | undefined - message: string - retryable?: boolean | undefined -} - -type ClientRpcSuccessEnvelope = { - data?: unknown | undefined - meta?: ClientRpcMeta | undefined - ok: true -} - -type ClientRpcEnvelope = - | ClientRpcSuccessEnvelope - | { - error: ClientRpcError - meta?: ClientRpcMeta | undefined - ok: false - } -``` - -Rules: - -- `run()` returns success results only; -- failed command envelopes are preserved in `ClientError.data`; -- normalized metadata is available at `ClientError.meta`; -- error CTAs live under `ClientError.meta?.cta`; -- do not add `ClientError.cta`; -- copy `code`, `retryable`, and `fieldErrors` when available; -- preserve HTTP status for HTTP transport failures; -- malformed transport responses throw `ClientError` with diagnostic `data`. - -## Explicit Non-Support - -HTTP env injection is not supported. HTTP commands read server-side environment. - -CLI config defaults are not applied by TS clients. Clients send explicit `args` and `options`. - -Shell completions are CLI-only. Programmatic command discovery uses `DiscoveryActions`. - -HTTP clients, HTTP routes, RPC, and MCP tools do not expose local setup/admin actions: - -- no HTTP `skills add`; -- no HTTP `skills list`; -- no HTTP `mcp add`; -- no MCP tool for these commands. - -MCP tools expose command-map leaf commands and MCP tool discovery. MCP registration remains CLI or memory-client local setup. From fcd5f698b185713cfd91ce89ecfe43e3e10c7f0d Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 16:09:16 +0200 Subject: [PATCH 08/21] fix: align typed client contracts --- src/Typegen.test.ts | 8 +++- src/Typegen.ts | 8 ++++ src/client/actions/discovery.test.ts | 20 ++++++--- src/client/actions/discovery.ts | 16 ++++++-- src/client/actions/run.test.ts | 34 ++++++++++++++-- src/client/actions/run.ts | 16 +++++--- src/client/api-example.test-d.ts | 4 +- src/client/index.test-d.ts | 6 +++ src/client/types.ts | 61 ++++++++++------------------ src/e2e.test.ts | 4 +- 10 files changed, 114 insertions(+), 63 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 9a82c02..0cb38bd 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -286,7 +286,7 @@ describe('fromCli', () => { }) const output = Typegen.fromCli(cli) - expect(output).toContain('verbose?: boolean') + expect(output).toContain('verbose?: boolean | undefined') expect(output).toContain('output: string') }) @@ -346,6 +346,7 @@ describe('fromCli', () => { const output = Typegen.fromCli(cli) expect(output).toContain('"bad key \\"quoted\\""') +<<<<<<< HEAD <<<<<<< HEAD expect(output).toContain('"bad-key"?: string | undefined') expect(output).toContain('"quote\\"key": number') @@ -388,5 +389,10 @@ describe('fromCli', () => { expect(() => Typegen.fromCli(cli)).toThrow('unsupported JSON Schema reference "#"') ======= >>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) +======= + expect(output).toContain('"bad-key"?: string | undefined') + expect(output).toContain('"quote\\"key": number') + expect(output).toContain('nested: { "child-key"?: string | undefined }') +>>>>>>> dbb43b1 (fix: align typed client contracts) }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 4dc0a44..0e8dea4 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -105,6 +105,7 @@ 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) ?? []) +<<<<<<< HEAD <<<<<<< HEAD const entries = Object.entries(properties ?? {}).map(([key, value]) => { <<<<<<< HEAD @@ -133,6 +134,13 @@ function resolveType( ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, ) >>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) +======= + 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` + }) +>>>>>>> dbb43b1 (fix: align typed client contracts) return `{ ${entries.join('; ')} }` } default: diff --git a/src/client/actions/discovery.test.ts b/src/client/actions/discovery.test.ts index 9cb9ad0..3ebd0f5 100644 --- a/src/client/actions/discovery.test.ts +++ b/src/client/actions/discovery.test.ts @@ -21,14 +21,24 @@ describe('discovery actions', () => { const discover = vi.fn(async (request) => { if (request.resource === 'help') return { contentType: 'text/plain', body: 'help' } if (request.resource === 'skill') return { contentType: 'text/markdown', body: '# Skill' } + if ( + (request.resource === 'llms' || request.resource === 'llmsFull') && + request.format === 'md' + ) + return { contentType: 'text/markdown', body: '# Manifest' } + if ( + (request.resource === 'llms' || request.resource === 'llmsFull') && + request.format === 'json' + ) + return { contentType: 'text/plain', body: JSON.stringify({ resource: request.resource }) } return { contentType: 'application/json', data: { resource: request.resource } } }) const client = clientWith(discover) await expect(client.llms()).resolves.toEqual({ resource: 'llms' }) - await expect(client.llms({ command: 'project' as never, format: 'md' })).resolves.toEqual({ - resource: 'llms', - }) + await expect(client.llms({ command: 'project' as never, format: 'md' })).resolves.toBe( + '# Manifest', + ) await expect(client.llmsFull({ command: 'project' as never })).resolves.toEqual({ resource: 'llmsFull', }) @@ -40,9 +50,9 @@ describe('discovery actions', () => { await expect(client.mcp.tools()).resolves.toEqual({ resource: 'mcpTools' }) expect(discover.mock.calls.map(([request]) => request)).toEqual([ - { resource: 'llms' }, + { resource: 'llms', format: 'json' }, { resource: 'llms', command: 'project', format: 'md' }, - { resource: 'llmsFull', command: 'project' }, + { resource: 'llmsFull', command: 'project', format: 'json' }, { resource: 'schema', command: 'project report' }, { resource: 'help', command: 'project report' }, { resource: 'openapi' }, diff --git a/src/client/actions/discovery.ts b/src/client/actions/discovery.ts index 754165e..06446ed 100644 --- a/src/client/actions/discovery.ts +++ b/src/client/actions/discovery.ts @@ -14,10 +14,11 @@ export async function llms( client: ActionClient, options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, ): Promise { + const { command, format = 'json' } = options return discover(client, { resource: 'llms', - ...(options.command ? { command: options.command } : undefined), - ...(options.format ? { format: options.format } : undefined), + ...(command ? { command } : undefined), + format, }) } @@ -26,10 +27,11 @@ export async function llmsFull( client: ActionClient, options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, ): Promise { + const { command, format = 'json' } = options return discover(client, { resource: 'llmsFull', - ...(options.command ? { command: options.command } : undefined), - ...(options.format ? { format: options.format } : undefined), + ...(command ? { command } : undefined), + format, }) } @@ -78,6 +80,12 @@ export async function mcpTools(client: ActionClient): Promise async function discover(client: ActionClient, request: ResourcesRequest): Promise { try { const response = await client.transport.discover(request) + if ( + 'body' in response && + (request.resource === 'llms' || request.resource === 'llmsFull') && + request.format === 'json' + ) + return JSON.parse(response.body) if ('body' in response) return response.body return response.data } catch (error) { diff --git a/src/client/actions/run.test.ts b/src/client/actions/run.test.ts index 7dadec0..4a8959a 100644 --- a/src/client/actions/run.test.ts +++ b/src/client/actions/run.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test, vi } from 'vitest' +import { ClientError } from '../ClientError.js' +import { createClient } from '../createClient.js' import type { Request as RpcRequest, Response as RpcResponse, StreamResponse as RpcStreamResponse, } from '../Rpc.js' -import { createClient } from '../createClient.js' -import { ClientError } from '../ClientError.js' import type * as HttpTransport from '../transports/HttpTransport.js' function clientWith(request: (request: RpcRequest) => Promise) { @@ -82,6 +82,7 @@ describe('run action', () => { retryable: false, }, meta: { command: 'deploy', duration: '2ms' }, + status: 401, }), ) const client = clientWith(request) @@ -92,6 +93,7 @@ describe('run action', () => { fieldErrors: [expect.objectContaining({ path: 'token' })], meta: { command: 'deploy' }, retryable: false, + status: 401, }) try { await client.run('deploy') @@ -161,13 +163,37 @@ describe('run action', () => { expect(cta).toMatchObject({ command: 'unblock', cliCommand: 'unblock t1 --dry-run ', - runnable: true, raw: expect.any(Object), }) - if (!cta?.runnable) throw new Error('expected runnable CTA') + if (!cta) throw new Error('expected CTA') await expect(cta.run()).resolves.toMatchObject({ data: { unblocked: true } }) expect(request).toHaveBeenLastCalledWith( expect.objectContaining({ command: 'unblock', outputFormat: 'toon' }), ) }) + + test('CTA suggestions fail like normal runs when the command is invalid', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + data: {}, + meta: { + command: 'report', + duration: '1ms', + cta: { commands: ['missing'] }, + }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND', message: 'Missing command.' }, + meta: { command: 'missing', duration: '1ms' }, + }) + const client = clientWith(request) + const result = await client.run('report') + const cta = result.meta.cta?.commands[0] + + expect(cta).toMatchObject({ command: 'missing', cliCommand: 'missing' }) + await expect(cta?.run()).rejects.toMatchObject({ code: 'COMMAND_NOT_FOUND' }) + }) }) diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts index 74c8e0b..4325599 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/run.ts @@ -1,3 +1,4 @@ +import { ClientError } from '../ClientError.js' import type { Envelope as RpcFullEnvelope, Meta as RpcMeta, @@ -6,7 +7,6 @@ import type { StreamRecord as RpcStreamRecord, StreamResponse as RpcStreamResponse, } from '../Rpc.js' -import { ClientError } from '../ClientError.js' import type { ActionClient, ClientCta, @@ -289,15 +289,18 @@ function ctaBlock(client: ActionClient | undefined, value: unknown): ClientCtaBl const commands = Array.isArray(block.commands) ? block.commands : [] return { ...(typeof block.description === 'string' ? { description: block.description } : undefined), - commands: commands.map((command) => cta(client, command)), + commands: commands.flatMap((command) => { + const suggestion = cta(client, command) + return suggestion ? [suggestion] : [] + }), } } -function cta(client: ActionClient | undefined, value: unknown): ClientCta { +function cta(client: ActionClient | undefined, value: unknown): ClientCta | 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 { raw, runnable: false, unresolvedReason: 'unstructured' } + return undefined } function runnableCta( @@ -315,10 +318,11 @@ function runnableCta( args, options, raw, - runnable: true, run(optionsOverride?: OutputOptions) { if (!client) throw new ClientError('CTA is not attached to a client.') - return run(client, command, { args, options, ...optionsOverride }) as Promise + return run(client, command, { args, options, ...optionsOverride }) as Promise< + ClientRunResult + > }, } satisfies ClientCta return result diff --git a/src/client/api-example.test-d.ts b/src/client/api-example.test-d.ts index d7dc0f4..b38f3d8 100644 --- a/src/client/api-example.test-d.ts +++ b/src/client/api-example.test-d.ts @@ -83,8 +83,8 @@ test('docs api example client surface typechecks conceptually', async () => { expectTypeOf(status.data.status).toEqualTypeOf<'open' | 'blocked' | 'done'>() const cta = report.meta.cta?.commands[0] - if (cta?.runnable) { - expectTypeOf(cta.command).toMatchTypeOf() + if (cta) { + expectTypeOf(cta.command).toEqualTypeOf() await cta.run({ outputFormat: 'toon' }) } diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts index fce21ad..d3cba5d 100644 --- a/src/client/index.test-d.ts +++ b/src/client/index.test-d.ts @@ -138,7 +138,13 @@ test('selection defaults and clearing affect data inference', async () => { test('discovery overloads and permissive command maps', async () => { const client = createHttpClient({ baseUrl: 'https://example.com' }) expectTypeOf(await client.llms()).toMatchTypeOf<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: undefined })).toMatchTypeOf<{ commands: unknown[] }>() + expectTypeOf(await client.llms({ format: 'json' })).toMatchTypeOf<{ commands: unknown[] }>() expectTypeOf(await client.llms({ format: 'md' })).toEqualTypeOf() + expectTypeOf(await client.llmsFull()).toMatchTypeOf<{ commands: unknown[] }>() + expectTypeOf(await client.llmsFull({ format: undefined })).toMatchTypeOf<{ + commands: unknown[] + }>() const format = undefined as 'md' | undefined expectTypeOf(await client.llms({ format })).toMatchTypeOf() await client.llmsFull({ command: 'project' }) diff --git a/src/client/types.ts b/src/client/types.ts index 10ba8a5..7c3b4dc 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -271,55 +271,36 @@ export type ClientCtaBlock = { } /** CTA command. */ -export type ClientCta = - | ClientRunnableCta> - | ClientUnresolvedCta - -/** Runnable CTA command. */ -export type ClientRunnableCta> = { - /** Canonical command id. */ - command: command +export type ClientCta = { + /** Suggested command id. */ + command: string /** CLI-ready command text. */ cliCommand: string /** CTA description. */ description?: string | undefined - /** Structured args. */ - args?: CommandArgs | undefined - /** Structured options. */ - options?: CommandOptions | 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 - /** Runnable discriminator. */ - runnable: true + /** Runs the suggested command. Invalid suggestions fail like normal client runs. */ run( options?: options, - ): Promise> -} - -/** Unresolved CTA command. */ -export type ClientUnresolvedCta = { - /** CLI-ready command text when one could be derived. */ - cliCommand?: string | undefined - /** CTA description. */ - description?: string | undefined - /** Raw source CTA. */ - raw: unknown - /** Runnable discriminator. */ - runnable: false - /** Reason the CTA could not be converted into a typed run action. */ - unresolvedReason: 'unknown-command' | 'invalid-input' | 'unstructured' + ): Promise< + ClientRunResult< + EffectiveOutput< + unknown, + options extends { selection: infer selection } ? selection : undefined + >, + commands + > + > } /** CTA run output controls. */ export type ClientCtaRunOptions = OutputOptions -/** CTA run return type. */ -export type CtaRunReturn< - commands, - command extends CommandId, - options extends ClientCtaRunOptions | undefined, -> = RunReturn, {}> - /** Stream response wrapper. */ export type ClientStreamResponse< chunk, @@ -362,9 +343,11 @@ export type DiscoveryFormat = 'md' | 'json' | 'yaml' | 'toon' /** Discovery result for a structured type and format option. */ export type DiscoveryResult = [format] extends [undefined] ? structured - : undefined extends format - ? structured | string - : string + : [format] extends ['json'] + ? structured + : undefined extends format + ? structured | string + : string /** LLM manifest. */ export type LlmsManifest< diff --git a/src/e2e.test.ts b/src/e2e.test.ts index a2ecb3f..7f0412b 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1605,8 +1605,8 @@ describe('typegen', () => { "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 }; options: {} } - echo: { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: 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: {} } From e106c3940077581af82f56825f034ca19ce44de0 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 16:35:00 +0200 Subject: [PATCH 09/21] fix(client): consume rpc output metadata --- src/client/actions/run.test.ts | 8 ++++---- src/client/actions/run.ts | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/client/actions/run.test.ts b/src/client/actions/run.test.ts index 4a8959a..54def3b 100644 --- a/src/client/actions/run.test.ts +++ b/src/client/actions/run.test.ts @@ -111,14 +111,14 @@ describe('run action', () => { .mockResolvedValueOnce({ ok: true, data: { page: 1 }, - output: { text: 'one' }, - meta: { command: 'list', duration: '1ms', nextOffset: 5, outputTokenCount: 10 }, + output: { text: 'one', nextOffset: 5, tokenCount: 10, tokenLimit: 5 }, + meta: { command: 'list', duration: '1ms' }, }) .mockResolvedValueOnce({ ok: true, data: { page: 2 }, - output: { text: 'two' }, - meta: { command: 'list', duration: '1ms', outputTokenCount: 10 }, + output: { text: 'two', tokenCount: 10, tokenLimit: 5, tokenOffset: 5 }, + meta: { command: 'list', duration: '1ms' }, }) const client = clientWith(request) const result = await client.run('list', { outputTokenLimit: 5 }) diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts index 4325599..d7d79ae 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/run.ts @@ -79,17 +79,13 @@ function output( request: RpcRequest, response: Extract, ): ClientOutput { - const nextOffset = - (response.output as { nextOffset?: number | undefined } | undefined)?.nextOffset ?? - response.meta.nextOffset + const nextOffset = response.output?.nextOffset return { text: response.output?.text ?? '', ...(response.output?.format !== undefined ? { format: response.output.format } : undefined), ...(response.output?.tokenCount !== undefined ? { tokenCount: response.output.tokenCount } - : response.meta.outputTokenCount !== undefined - ? { tokenCount: response.meta.outputTokenCount } - : undefined), + : undefined), ...(response.output?.tokenLimit !== undefined ? { tokenLimit: response.output.tokenLimit } : request.outputTokenLimit !== undefined From 6e463a809677085b18a27ba537066072a5a9dd22 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:00:34 +0200 Subject: [PATCH 10/21] fix(client): consume canonical runtime contracts --- src/client/actions/discovery.test.ts | 9 +++++ src/client/actions/discovery.ts | 6 ---- src/client/actions/local.test.ts | 4 ++- src/client/actions/local.ts | 15 +++++--- src/client/actions/run.test.ts | 19 +++++++++- src/client/actions/run.ts | 53 ++++++++++++---------------- src/client/stream.test.ts | 9 ++++- src/client/types.ts | 12 +++++-- 8 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/client/actions/discovery.test.ts b/src/client/actions/discovery.test.ts index 3ebd0f5..d1629ed 100644 --- a/src/client/actions/discovery.test.ts +++ b/src/client/actions/discovery.test.ts @@ -29,6 +29,11 @@ describe('discovery actions', () => { if ( (request.resource === 'llms' || request.resource === 'llmsFull') && request.format === 'json' + ) + return { contentType: 'application/json', data: { resource: request.resource } } + if ( + (request.resource === 'llms' || request.resource === 'llmsFull') && + request.format === 'jsonl' ) return { contentType: 'text/plain', body: JSON.stringify({ resource: request.resource }) } return { contentType: 'application/json', data: { resource: request.resource } } @@ -39,6 +44,9 @@ describe('discovery actions', () => { await expect(client.llms({ command: 'project' as never, format: 'md' })).resolves.toBe( '# Manifest', ) + await expect(client.llms({ command: 'project' as never, format: 'jsonl' })).resolves.toBe( + '{"resource":"llms"}', + ) await expect(client.llmsFull({ command: 'project' as never })).resolves.toEqual({ resource: 'llmsFull', }) @@ -52,6 +60,7 @@ describe('discovery actions', () => { expect(discover.mock.calls.map(([request]) => request)).toEqual([ { resource: 'llms', format: 'json' }, { resource: 'llms', command: 'project', format: 'md' }, + { resource: 'llms', command: 'project', format: 'jsonl' }, { resource: 'llmsFull', command: 'project', format: 'json' }, { resource: 'schema', command: 'project report' }, { resource: 'help', command: 'project report' }, diff --git a/src/client/actions/discovery.ts b/src/client/actions/discovery.ts index 06446ed..f02d54b 100644 --- a/src/client/actions/discovery.ts +++ b/src/client/actions/discovery.ts @@ -80,12 +80,6 @@ export async function mcpTools(client: ActionClient): Promise async function discover(client: ActionClient, request: ResourcesRequest): Promise { try { const response = await client.transport.discover(request) - if ( - 'body' in response && - (request.resource === 'llms' || request.resource === 'llmsFull') && - request.format === 'json' - ) - return JSON.parse(response.body) if ('body' in response) return response.body return response.data } catch (error) { diff --git a/src/client/actions/local.test.ts b/src/client/actions/local.test.ts index 8ece2e5..446d428 100644 --- a/src/client/actions/local.test.ts +++ b/src/client/actions/local.test.ts @@ -16,7 +16,9 @@ function memoryClient() { skills: [{ name: 'deploy' }], options, })), - list: vi.fn(async () => [{ description: 'Deploy', installed: false, name: 'deploy' }]), + list: vi.fn(async () => ({ + skills: [{ description: 'Deploy', installed: false, name: 'deploy' }], + })), }, mcp: { add: vi.fn(async (options) => ({ agents: options?.agents ?? [], command: 'pnpm app' })), diff --git a/src/client/actions/local.ts b/src/client/actions/local.ts index 9754b71..4ce99a0 100644 --- a/src/client/actions/local.ts +++ b/src/client/actions/local.ts @@ -1,4 +1,10 @@ -import type { ActionClient, McpAddOptions, SkillsAddOptions, SkillsListOptions } from '../types.js' +import type { + ActionClient, + McpAddOptions, + SkillsAddOptions, + SkillsList, + SkillsListOptions, +} from '../types.js' /** Runs memory-local `skills add`. */ export function skillsAdd(client: ActionClient, options?: SkillsAddOptions | undefined) { @@ -6,9 +12,8 @@ export function skillsAdd(client: ActionClient, options?: SkillsAddOptions | und } /** Runs memory-local `skills list`. */ -export async function skillsList(client: ActionClient, options?: SkillsListOptions | undefined) { - const result = await local(client).skills.list(options) - return Array.isArray(result) ? { skills: result } : result +export function skillsList(client: ActionClient, options?: SkillsListOptions | undefined) { + return local(client).skills.list(options) } /** Runs memory-local `mcp add`. */ @@ -20,7 +25,7 @@ function local(client: ActionClient) { return client.transport.local as { skills: { add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise } mcp: { add(options?: McpAddOptions | undefined): Promise diff --git a/src/client/actions/run.test.ts b/src/client/actions/run.test.ts index 54def3b..94bf07f 100644 --- a/src/client/actions/run.test.ts +++ b/src/client/actions/run.test.ts @@ -111,7 +111,7 @@ describe('run action', () => { .mockResolvedValueOnce({ ok: true, data: { page: 1 }, - output: { text: 'one', nextOffset: 5, tokenCount: 10, tokenLimit: 5 }, + output: { text: 'one', nextOffset: 5, tokenCount: 10, tokenLimit: 5, tokenOffset: 0 }, meta: { command: 'list', duration: '1ms' }, }) .mockResolvedValueOnce({ @@ -130,6 +130,23 @@ describe('run action', () => { ) }) + 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 = clientWith(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 request = vi .fn() diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts index d7d79ae..44b14a1 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/run.ts @@ -2,6 +2,7 @@ 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, @@ -69,42 +70,33 @@ function normalizeEnvelope( return { ok: true, data: response.data, - ...(response.output ? { output: output(client, request, response) } : undefined), + ...(response.output ? { output: output(client, request, response.output) } : undefined), meta: normalizeMeta(client, response.meta), } } -function output( - client: ActionClient, - request: RpcRequest, - response: Extract, +function output(client: ActionClient, request: RpcRequest, value: RpcOutput): ClientOutput { + return normalizeOutput(value, value.nextOffset, (nextOffset) => + normalizeNext(client, { + ...request, + outputTokenOffset: nextOffset, + }), + ) +} + +function normalizeOutput( + value: RpcOutput, + nextOffset?: number | undefined, + next?: ((nextOffset: number) => Promise>) | undefined, ): ClientOutput { - const nextOffset = response.output?.nextOffset + if (typeof value.text !== 'string') throw new ClientError('Malformed RPC output.') return { - text: response.output?.text ?? '', - ...(response.output?.format !== undefined ? { format: response.output.format } : undefined), - ...(response.output?.tokenCount !== undefined - ? { tokenCount: response.output.tokenCount } - : undefined), - ...(response.output?.tokenLimit !== undefined - ? { tokenLimit: response.output.tokenLimit } - : request.outputTokenLimit !== undefined - ? { tokenLimit: request.outputTokenLimit } - : undefined), - ...(response.output?.tokenOffset !== undefined - ? { tokenOffset: response.output.tokenOffset } - : request.outputTokenOffset !== undefined - ? { tokenOffset: request.outputTokenOffset } - : undefined), - ...(nextOffset !== undefined - ? { - next: () => - normalizeNext(client, { - ...request, - outputTokenOffset: nextOffset, - }), - } - : undefined), + 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), } } @@ -228,6 +220,7 @@ function normalizeStream( type: 'done', ok: true, ...('data' in record ? { data: record.data } : undefined), + ...(record.output ? { output: normalizeOutput(record.output) } : undefined), meta: meta(record.meta), } return { diff --git a/src/client/stream.test.ts b/src/client/stream.test.ts index 2dfe85c..aafb214 100644 --- a/src/client/stream.test.ts +++ b/src/client/stream.test.ts @@ -41,7 +41,13 @@ describe('ClientStreamResponse', () => { const client = streamClient([ { type: 'chunk', data: { line: 1 } }, { type: 'chunk', data: { line: 2 } }, - { type: 'done', ok: true, data: { lines: 2 }, meta: { command: 'logs', duration: '2ms' } }, + { + type: 'done', + ok: true, + data: { lines: 2 }, + output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, + meta: { command: 'logs', duration: '2ms' }, + }, ]) const stream = await client.run('logs') const chunks: unknown[] = [] @@ -50,6 +56,7 @@ describe('ClientStreamResponse', () => { expect(chunks).toEqual([{ line: 1 }, { line: 2 }]) await expect(stream.final).resolves.toMatchObject({ data: { lines: 2 }, + output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, meta: { command: 'logs' }, }) }) diff --git a/src/client/types.ts b/src/client/types.ts index 7c3b4dc..5f258db 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -319,6 +319,8 @@ export type ClientStreamFinal = { ok: true /** Terminal structured data. */ data?: finalData | undefined + /** Terminal rendered output text. */ + output?: ClientOutput | undefined /** Terminal metadata. */ meta: ClientMeta } @@ -334,11 +336,17 @@ export type ClientStreamOutput = { /** Normalized stream record. */ export type ClientStreamRecord = | { type: 'chunk'; data: chunk; output?: ClientStreamOutput | undefined } - | { type: 'done'; ok: true; data?: finalData | undefined; meta: ClientMeta } + | { + type: 'done' + ok: true + data?: finalData | undefined + output?: ClientOutput | undefined + meta: ClientMeta + } | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } /** Discovery format. */ -export type DiscoveryFormat = 'md' | 'json' | 'yaml' | 'toon' +export type DiscoveryFormat = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' /** Discovery result for a structured type and format option. */ export type DiscoveryResult = [format] extends [undefined] From cf00c85550a25c2fd7cf36b8dd995f660be67bf9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:24:12 +0200 Subject: [PATCH 11/21] fix(typegen): keep public surface scoped --- src/Typegen.test.ts | 91 -------------------------- src/Typegen.ts | 109 +------------------------------- src/client/actions/run.ts | 6 +- src/client/createClient.test.ts | 4 +- src/client/createClient.ts | 4 +- src/client/stream.test.ts | 4 +- 6 files changed, 13 insertions(+), 205 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 0cb38bd..e34640c 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -161,32 +161,11 @@ describe('fromCli', () => { run: () => [{ id: 'one', active: true }], }) -<<<<<<< HEAD const output = Typegen.fromCli(cli) expect(output).toContain('read: { args: {}; options: {}; output: string }') expect(output).toContain( 'list: { args: {}; options: {}; output: { id: string; active: boolean }[] }', ) -======= - expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - read: { args: {}; options: {}; output: string } - } - - declare module 'incur' { - interface Register { - commands: Commands - } - } - - declare module 'incur/client' { - interface Register { - commands: Commands - } - } - " - `) ->>>>>>> 0a77e57 (fix: tighten typed client typegen surface) }) test('marks async generator commands as streams', () => { @@ -197,31 +176,10 @@ describe('fromCli', () => { }, }) -<<<<<<< HEAD const output = Typegen.fromCli(cli) expect(output).toContain( 'tail: { args: {}; options: {}; output: { line: string }; stream: true }', ) -======= - expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - list: { args: {}; options: {}; output: { id: string; active: boolean }[] } - } - - declare module 'incur' { - interface Register { - commands: Commands - } - } - - declare module 'incur/client' { - interface Register { - commands: Commands - } - } - " - `) ->>>>>>> 0a77e57 (fix: tighten typed client typegen surface) }) test('commands are sorted alphabetically', () => { @@ -330,11 +288,7 @@ describe('fromCli', () => { expect(output).toContain("declare module 'incur/client'") }) -<<<<<<< HEAD test('escapes command and property keys', () => { -======= - test('escapes command keys', () => { ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) const cli = Cli.create('test').command('bad key "quoted"', { options: z.object({ 'bad-key': z.string().optional(), @@ -346,53 +300,8 @@ describe('fromCli', () => { const output = Typegen.fromCli(cli) expect(output).toContain('"bad key \\"quoted\\""') -<<<<<<< HEAD -<<<<<<< HEAD - expect(output).toContain('"bad-key"?: string | undefined') - expect(output).toContain('"quote\\"key": number') - expect(output).toContain('nested: { "child-key"?: string | undefined }') - }) - - test('catchall index signatures include optional property undefined', () => { - const cli = Cli.create('test').command('shape', { - output: z.object({ maybe: z.string().optional() }).catchall(z.boolean()), - run: () => ({}), - }) - - const output = Typegen.fromCli(cli) - expect(output).toContain( - 'shape: { args: {}; options: {}; output: { maybe?: string | undefined; [key: string]: boolean | string | undefined } }', - ) - }) - - test('wraps JSON Schema conversion failures in TypegenError', () => { - const cli = Cli.create('test').command('created', { - output: z.date(), - run: () => new Date(), - }) - - expect(() => Typegen.fromCli(cli)).toThrow(Typegen.TypegenError) - expect(() => Typegen.fromCli(cli)).toThrow( - 'Cannot generate TypeScript for command "created" output', - ) - }) - - test('throws TypegenError for unsupported JSON Schema refs', () => { - let node: z.ZodType - node = z.lazy(() => z.object({ next: node.optional() })) - const cli = Cli.create('test').command('broken', { - output: node, - run: () => ({ next: {} }), - }) - - expect(() => Typegen.fromCli(cli)).toThrow(Typegen.TypegenError) - expect(() => Typegen.fromCli(cli)).toThrow('unsupported JSON Schema reference "#"') -======= ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) -======= expect(output).toContain('"bad-key"?: string | undefined') expect(output).toContain('"quote\\"key": number') expect(output).toContain('nested: { "child-key"?: string | undefined }') ->>>>>>> dbb43b1 (fix: align typed client contracts) }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 0e8dea4..0903fe6 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -2,11 +2,7 @@ import fs from 'node:fs/promises' import { z } from 'zod' import * as Cli from './Cli.js' -<<<<<<< HEAD import * as RuntimeContext from './internal/runtime-context.js' -======= -import * as RuntimeContext from './internal/client-runtime-context.js' ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) 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`. */ @@ -17,19 +13,14 @@ 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 { -<<<<<<< HEAD const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli)) -======= - const entries = RuntimeContext.collectClientCommands(RuntimeContext.fromCli(cli)) ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) const lines: string[] = ['export type Commands = {'] - for (const { id, command } of entries) { + for (const { id, command } of entries) lines.push( ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) - } lines.push( '}', @@ -105,42 +96,11 @@ 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) ?? []) -<<<<<<< HEAD -<<<<<<< HEAD - const entries = Object.entries(properties ?? {}).map(([key, value]) => { -<<<<<<< HEAD - const type = resolveType(value, defs) - if (required.has(key)) return `${propertyKey(key)}: ${type}` - return `${propertyKey(key)}?: ${type} | undefined` -======= - const type = resolveType(value, defs, context, seen) - return required.has(key) - ? `${propertyKey(key)}: ${type}` - : `${propertyKey(key)}?: ${type} | undefined` ->>>>>>> 0a77e57 (fix: tighten typed client typegen surface) - }) - if (additional && typeof additional === 'object') { - const values = Object.entries(properties ?? {}).map(([key, value]) => { - const type = resolveType(value, defs, context, seen) - return required.has(key) ? type : `${type} | undefined` - }) - entries.push( - `[key: string]: ${union([resolveType(additional, defs, context, seen), ...values])}`, - ) - } - if (additional === true) entries.push('[key: string]: unknown') -======= - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) -======= 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` }) ->>>>>>> dbb43b1 (fix: align typed client contracts) return `{ ${entries.join('; ')} }` } default: @@ -148,73 +108,10 @@ function resolveType( } } -<<<<<<< HEAD -function arrayType(type: string) { - return type.includes(' | ') ? `(${type})[]` : `${type}[]` -} - -function union(types: string[]) { - return [...new Set(types)].join(' | ') -} - -<<<<<<< HEAD -function isStream(command: Cli.CommandDefinition) { -======= -function semanticKeys(schema: Record) { - return Object.keys(schema).filter((key) => !['$schema', 'description', 'title'].includes(key)) -} - -function schemaArray(value: unknown, context: string, key: string): JsonSchema[] { - if (!Array.isArray(value) || value.length === 0) - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema ${key} is invalid.`, - ) - if (value.every((item) => typeof item === 'boolean' || isRecord(item))) return value - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema ${key} is invalid.`, - ) -} - -function isSchemaMap(value: unknown): value is Record { - return ( - isRecord(value) && - Object.values(value).every((schema) => typeof schema === 'boolean' || isRecord(schema)) - ) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function literalType(value: unknown, context: string) { - const type = JSON.stringify(value) - if (type !== undefined) return type - throw new TypegenError( - `Cannot generate TypeScript for ${context}: JSON Schema literal is invalid.`, - ) -} - -function assertSupportedPropertyNames(schema: Record, context: string) { - if (schema.propertyNames === undefined) return - if (schema.propertyNames === true) return - if (isRecord(schema.propertyNames) && schema.propertyNames.type === 'string') return - throw new TypegenError( - `Cannot generate TypeScript for ${context}: non-string JSON Schema property names are not supported.`, - ) -} - -function errorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error) +function propertyKey(key: string) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) } -function isStream(command: CommandTree.CommandDefinition) { ->>>>>>> 0a77e57 (fix: tighten typed client typegen surface) -======= function isStream(command: Cli.CommandDefinition) { ->>>>>>> 3df4c76 (refactor: keep public surface typegen scoped) return command.run.constructor.name === 'AsyncGeneratorFunction' } - -function propertyKey(key: string) { - return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) -} diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts index 44b14a1..31bf233 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/run.ts @@ -75,7 +75,11 @@ function normalizeEnvelope( } } -function output(client: ActionClient, request: RpcRequest, value: RpcOutput): ClientOutput { +function output( + client: ActionClient, + request: RpcRequest, + value: RpcOutput, +): ClientOutput { return normalizeOutput(value, value.nextOffset, (nextOffset) => normalizeNext(client, { ...request, diff --git a/src/client/createClient.test.ts b/src/client/createClient.test.ts index 8f81484..0d53c79 100644 --- a/src/client/createClient.test.ts +++ b/src/client/createClient.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test, vi } from 'vitest' import * as Cli from '../Cli.js' +import { ClientError } from './ClientError.js' +import { createClient, createHttpClient, createMemoryClient } from './createClient.js' import type { Request as RpcRequest, Response as RpcResponse, StreamResponse as RpcStreamResponse, } from './Rpc.js' -import { ClientError } from './ClientError.js' -import { createClient, createHttpClient, createMemoryClient } from './createClient.js' import * as HttpTransport from './transports/HttpTransport.js' function mockTransport(): HttpTransport.HttpTransport { diff --git a/src/client/createClient.ts b/src/client/createClient.ts index 8765ebe..342a3a6 100644 --- a/src/client/createClient.ts +++ b/src/client/createClient.ts @@ -37,9 +37,7 @@ export function createClient< export function createHttpClient< const commands = Commands, const defaults extends ClientDefaults = {}, ->( - options: HttpTransport.Options & defaults & ClientDefaults, -): HttpClient { +>(options: HttpTransport.Options & defaults & ClientDefaults): HttpClient { const { baseUrl, fetch, headers, ...defaults } = options return createClient({ ...defaults, diff --git a/src/client/stream.test.ts b/src/client/stream.test.ts index aafb214..76d2afd 100644 --- a/src/client/stream.test.ts +++ b/src/client/stream.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test, vi } from 'vitest' +import { ClientError } from './ClientError.js' +import { createClient } from './createClient.js' import type { Request as RpcRequest, Response as RpcResponse, StreamRecord as RpcStreamRecord, StreamResponse as RpcStreamResponse, } from './Rpc.js' -import { ClientError } from './ClientError.js' -import { createClient } from './createClient.js' import type * as HttpTransport from './transports/HttpTransport.js' function streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { From 1ca0972a5d16f86797e6414baf9efea0dde0c9ad Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 23:05:00 +0200 Subject: [PATCH 12/21] refactor(client): namespace public client surface --- .../{createClient.test.ts => Client.test.ts} | 23 ++- src/client/Client.ts | 173 ++++++++++++++++++ src/client/HttpClient.ts | 20 ++ src/client/MemoryClient.ts | 30 +++ src/client/actions/local.test.ts | 6 +- src/client/actions/local.ts | 21 +-- .../{discovery.test.ts => resources.test.ts} | 16 +- .../actions/{discovery.ts => resources.ts} | 20 +- src/client/actions/run.test.ts | 4 +- src/client/api-example.test-d.ts | 19 +- src/client/createClient.ts | 131 ------------- src/client/index.test-d.ts | 82 +++++---- src/client/index.ts | 79 +------- src/client/stream.test.ts | 4 +- src/client/types.ts | 101 +++------- 15 files changed, 358 insertions(+), 371 deletions(-) rename src/client/{createClient.test.ts => Client.test.ts} (79%) create mode 100644 src/client/Client.ts create mode 100644 src/client/HttpClient.ts create mode 100644 src/client/MemoryClient.ts rename src/client/actions/{discovery.test.ts => resources.test.ts} (86%) rename src/client/actions/{discovery.ts => resources.ts} (88%) delete mode 100644 src/client/createClient.ts diff --git a/src/client/createClient.test.ts b/src/client/Client.test.ts similarity index 79% rename from src/client/createClient.test.ts rename to src/client/Client.test.ts index 0d53c79..6db01f4 100644 --- a/src/client/createClient.test.ts +++ b/src/client/Client.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, vi } from 'vitest' import * as Cli from '../Cli.js' -import { ClientError } from './ClientError.js' -import { createClient, createHttpClient, createMemoryClient } from './createClient.js' +import * as Client from './Client.js' +import * as HttpClient from './HttpClient.js' +import * as MemoryClient from './MemoryClient.js' import type { Request as RpcRequest, Response as RpcResponse, @@ -25,9 +26,9 @@ function mockTransport(): HttpTransport.HttpTransport { }) } -describe('createClient', () => { +describe('Client.create', () => { test('resolves transport, assigns uid, preserves defaults, and binds actions', async () => { - const client = createClient({ + const client = Client.create({ outputFormat: 'toon', transport: mockTransport(), }) @@ -43,7 +44,7 @@ describe('createClient', () => { }) }) - test('createHttpClient is a thin wrapper over HttpTransport.create', async () => { + test('HttpClient.create is a thin wrapper over HttpTransport.create', async () => { const fetch = vi.fn( async () => new Response( @@ -52,7 +53,7 @@ describe('createClient', () => { ), ) as typeof globalThis.fetch - const client = createHttpClient({ baseUrl: 'https://example.com/api', 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( @@ -61,9 +62,9 @@ describe('createClient', () => { ) }) - test('createMemoryClient uses memory transport and exposes local actions', () => { + test('MemoryClient.create uses memory transport and exposes local actions', () => { const cli = Cli.create('app') - const client = createMemoryClient(cli) + const client = MemoryClient.create(cli) expect(client.transport.type).toBe('memory') expect(typeof client.skills.add).toBe('function') @@ -72,7 +73,7 @@ describe('createClient', () => { }) test('http client has no runtime local action methods', () => { - const client = createClient({ + const client = Client.create({ transport: HttpTransport.create({ baseUrl: 'https://example.com' }), }) expect('add' in client.skills).toBe(false) @@ -84,7 +85,9 @@ describe('createClient', () => { const original = globalThis.fetch Object.defineProperty(globalThis, 'fetch', { configurable: true, value: undefined }) try { - expect(() => createHttpClient({ baseUrl: 'https://example.com' })).toThrow(ClientError) + 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..f52108c --- /dev/null +++ b/src/client/Client.ts @@ -0,0 +1,173 @@ +import * as local from './actions/local.js' +import * as resources from './actions/resources.js' +import { run } from './actions/run.js' +export { ClientError } from './ClientError.js' +import type { + Client, + ClientBase, + ClientCta, + ClientCtaBlock, + ClientCtaRunOptions, + ClientDefaults, + ClientMeta, + ClientOutput, + ClientRpcEnvelope, + ClientRpcError, + ClientRpcMeta, + ClientRunResult, + ClientStreamFinal, + ClientStreamOutput, + ClientStreamRecord, + ClientStreamResponse, + CommandArgs, + CommandData, + CommandId, + CommandOptions, + CommandScope, + Commands, + CommandsMap, + CreateOptions, + EffectiveOutput, + EffectiveRunOutput, + LlmsAction, + LlmsFullAction, + LlmsFullManifest, + LlmsManifest, + LocalActions, + McpToolsResponse, + OpenApiDocument, + OutputOptions, + Register, + ResourcesActions, + ResourcesFormat, + ResourcesResult, + ResolvedTransport, + RunActions, + RunInput, + RunInputParameters, + RunReturn, + SkillsIndex, + StrictInput, + Transport, +} from './types.js' + +export type { + Client, + ClientBase, + ClientCta, + ClientCtaBlock, + ClientCtaRunOptions, + ClientDefaults, + ClientMeta, + ClientOutput, + ClientRpcEnvelope, + ClientRpcError, + ClientRpcMeta, + ClientRunResult, + ClientStreamFinal, + ClientStreamOutput, + ClientStreamRecord, + ClientStreamResponse, + CommandArgs, + CommandData, + CommandId, + CommandOptions, + CommandScope, + Commands, + CommandsMap, + CreateOptions, + EffectiveOutput, + EffectiveRunOutput, + LlmsAction, + LlmsFullAction, + LlmsFullManifest, + LlmsManifest, + LocalActions, + McpToolsResponse, + OpenApiDocument, + OutputOptions, + Register, + ResourcesActions, + ResourcesFormat, + ResourcesResult, + ResolvedTransport, + RunActions, + RunInput, + RunInputParameters, + RunReturn, + SkillsIndex, + StrictInput, + Transport, +} + +/** Creates a typed client from a transport factory. */ +export function create< + const commands = Commands, + const transport extends Transport = Transport, + const defaults extends ClientDefaults = {}, +>(options: CreateOptions): Client { + const { transport, ...defaults } = options + const resolved = transport() + const { config, ...capabilities } = resolved + const client = { + defaults, + transport: { ...config, ...capabilities }, + type: 'client', + } as unknown as Client + + return attachActions(client) as Client +} + +function attachActions(client: client): client { + Object.assign(client, { + run(command: string, input?: unknown) { + return run(client as never, command, input as never) + }, + llms(options?: unknown) { + return resources.llms(client as never, options as never) + }, + llmsFull(options?: unknown) { + return resources.llmsFull(client as never, options as never) + }, + schema(command?: string | undefined) { + return resources.schema(client as never, command) + }, + help(command?: string | undefined) { + return resources.help(client as never, command) + }, + openapi() { + return resources.openapi(client as never) + }, + skills: { + index() { + return resources.skillsIndex(client as never) + }, + get(name: string) { + return resources.skill(client as never, name) + }, + }, + mcp: { + tools() { + return resources.mcpTools(client as never) + }, + }, + }) + + if ('transport' in client && 'local' in (client as { transport: object }).transport) { + Object.assign((client as unknown as { skills: object }).skills, { + add(options?: unknown) { + return local.skillsAdd(client as never, options as never) + }, + list(options?: unknown) { + return local.skillsList(client as never, options as never) + }, + }) + Object.assign((client as unknown as { mcp: object }).mcp, { + add(options?: unknown) { + return local.mcpAdd(client as never, options as never) + }, + }) + } + + return client +} diff --git a/src/client/HttpClient.ts b/src/client/HttpClient.ts new file mode 100644 index 0000000..b75beee --- /dev/null +++ b/src/client/HttpClient.ts @@ -0,0 +1,20 @@ +import * as Client from './Client.js' +import * as HttpTransport from './transports/HttpTransport.js' +import type { ClientDefaults, Commands, HttpClient } from './types.js' + +export type { HttpClient } + +/** Creates an HTTP typed client. */ +export function create( + options: HttpTransport.Options & defaults & ClientDefaults, +): 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/MemoryClient.ts b/src/client/MemoryClient.ts new file mode 100644 index 0000000..1389feb --- /dev/null +++ b/src/client/MemoryClient.ts @@ -0,0 +1,30 @@ +import type * as Cli from '../Cli.js' +import * as Client from './Client.js' +import * as MemoryTransport from './transports/MemoryTransport.js' +import type { AnyCli, ClientDefaults, Commands, MemoryClient } from './types.js' + +export type { MemoryClient } + +/** Creates a memory typed client and infers commands from a concrete CLI. */ +export function create< + const commands extends Cli.CommandsMap, + const defaults extends ClientDefaults = {}, +>( + cli: Cli.Cli, + options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, +): MemoryClient +/** Creates a memory typed client with an explicit command map. */ +export function create( + cli: AnyCli, + options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, +): MemoryClient +export function create( + cli: AnyCli, + options: MemoryTransport.Options & ClientDefaults = {}, +): MemoryClient { + const { env, ...defaults } = options + return Client.create({ + ...defaults, + transport: MemoryTransport.create(cli, { env }), + }) +} diff --git a/src/client/actions/local.test.ts b/src/client/actions/local.test.ts index 446d428..d07210f 100644 --- a/src/client/actions/local.test.ts +++ b/src/client/actions/local.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from 'vitest' -import { createClient } from '../createClient.js' +import * as Client from '../Client.js' import type * as MemoryTransport from '../transports/MemoryTransport.js' function memoryClient() { @@ -25,11 +25,11 @@ function memoryClient() { }, }, })) satisfies MemoryTransport.MemoryTransport - return createClient<{}, MemoryTransport.MemoryTransport>({ transport }) + return Client.create<{}, MemoryTransport.MemoryTransport>({ transport }) } describe('local actions', () => { - test('memory local actions delegate and coexist with discovery namespaces', async () => { + test('memory local actions delegate and coexist with resources namespaces', async () => { const client = memoryClient() await expect(client.skills.index()).resolves.toEqual({}) diff --git a/src/client/actions/local.ts b/src/client/actions/local.ts index 4ce99a0..a36a5f9 100644 --- a/src/client/actions/local.ts +++ b/src/client/actions/local.ts @@ -1,34 +1,29 @@ -import type { - ActionClient, - McpAddOptions, - SkillsAddOptions, - SkillsList, - SkillsListOptions, -} from '../types.js' +import type * as Local from '../Local.js' +import type { ActionClient } from '../types.js' /** Runs memory-local `skills add`. */ -export function skillsAdd(client: ActionClient, options?: SkillsAddOptions | undefined) { +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?: SkillsListOptions | undefined) { +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?: McpAddOptions | undefined) { +export function mcpAdd(client: ActionClient, options?: Local.McpAddOptions | undefined) { return local(client).mcp.add(options) } function local(client: ActionClient) { return client.transport.local as { skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise + add(options?: Local.SkillsAddOptions | undefined): Promise + list(options?: Local.SkillsListOptions | undefined): Promise } mcp: { - add(options?: McpAddOptions | undefined): Promise + add(options?: Local.McpAddOptions | undefined): Promise } } } diff --git a/src/client/actions/discovery.test.ts b/src/client/actions/resources.test.ts similarity index 86% rename from src/client/actions/discovery.test.ts rename to src/client/actions/resources.test.ts index d1629ed..e4bc609 100644 --- a/src/client/actions/discovery.test.ts +++ b/src/client/actions/resources.test.ts @@ -1,23 +1,23 @@ import { describe, expect, test, vi } from 'vitest' -import type { Request as ResourcesRequest, Response as ResourcesResponse } from '../Resources.js' -import { createClient } from '../createClient.js' +import * as Client from '../Client.js' +import type * as Resources from '../Resources.js' import type * as HttpTransport from '../transports/HttpTransport.js' -function clientWith(discover: (request: ResourcesRequest) => Promise) { +function clientWith(discover: (request: Resources.Request) => Promise) { const transport = (() => ({ config: { key: 'mock', name: 'Mock', type: 'http' as const }, baseUrl: new URL('https://example.com'), - discover(request: ResourcesRequest): Promise { + discover(request: Resources.Request): Promise { return discover(request) }, request: vi.fn(), })) satisfies HttpTransport.HttpTransport - return createClient({ transport }) + return Client.create({ transport }) } -describe('discovery actions', () => { - test('routes every discovery action and preserves structured/text returns', async () => { +describe('resources actions', () => { + test('routes every resources action and preserves structured/text returns', async () => { const discover = vi.fn(async (request) => { if (request.resource === 'help') return { contentType: 'text/plain', body: 'help' } if (request.resource === 'skill') return { contentType: 'text/markdown', body: '# Skill' } @@ -71,7 +71,7 @@ describe('discovery actions', () => { ]) }) - test('normalizes discovery failures into ClientError fields', async () => { + test('normalizes resources failures into ClientError fields', async () => { const client = clientWith( vi.fn(async () => { throw Object.assign(new Error('Unknown command'), { diff --git a/src/client/actions/discovery.ts b/src/client/actions/resources.ts similarity index 88% rename from src/client/actions/discovery.ts rename to src/client/actions/resources.ts index f02d54b..1395c5f 100644 --- a/src/client/actions/discovery.ts +++ b/src/client/actions/resources.ts @@ -1,18 +1,18 @@ -import type { Request as ResourcesRequest } from '../Resources.js' import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' import type { ActionClient, CommandScope, - DiscoveryFormat, McpToolsResponse, OpenApiDocument, + ResourcesFormat, SkillsIndex, } from '../types.js' -/** Runs compact LLM discovery. */ +/** Reads compact LLM resources. */ export async function llms( client: ActionClient, - options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, + options: { command?: string | undefined; format?: ResourcesFormat | undefined } = {}, ): Promise { const { command, format = 'json' } = options return discover(client, { @@ -22,10 +22,10 @@ export async function llms( }) } -/** Runs full LLM discovery. */ +/** Reads full LLM resources. */ export async function llmsFull( client: ActionClient, - options: { command?: string | undefined; format?: DiscoveryFormat | undefined } = {}, + options: { command?: string | undefined; format?: ResourcesFormat | undefined } = {}, ): Promise { const { command, format = 'json' } = options return discover(client, { @@ -77,7 +77,7 @@ export async function mcpTools(client: ActionClient): Promise return discover(client, { resource: 'mcpTools' }) as Promise } -async function discover(client: ActionClient, request: ResourcesRequest): Promise { +async function discover(client: ActionClient, request: Resources.Request): Promise { try { const response = await client.transport.discover(request) if ('body' in response) return response.body @@ -88,15 +88,15 @@ async function discover(client: ActionClient, request: ResourcesRequest): Promis ? { ok: false, error: { - code: typeof error.code === 'string' ? error.code : 'DISCOVERY_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 : 'Discovery request failed', { + 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 : 'DISCOVERY_ERROR', + 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, diff --git a/src/client/actions/run.test.ts b/src/client/actions/run.test.ts index 94bf07f..32e8ab3 100644 --- a/src/client/actions/run.test.ts +++ b/src/client/actions/run.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from 'vitest' +import * as Client from '../Client.js' import { ClientError } from '../ClientError.js' -import { createClient } from '../createClient.js' import type { Request as RpcRequest, Response as RpcResponse, @@ -29,7 +29,7 @@ function clientWith(request: (request: RpcRequest) => Promise({ + return Client.create({ outputFormat: 'toon', selection: ['items[0]'], transport, diff --git a/src/client/api-example.test-d.ts b/src/client/api-example.test-d.ts index b38f3d8..3bebc1e 100644 --- a/src/client/api-example.test-d.ts +++ b/src/client/api-example.test-d.ts @@ -1,12 +1,5 @@ import { Cli } from 'incur' -import { - ClientError, - HttpTransport, - MemoryTransport, - createClient, - createHttpClient, - createMemoryClient, -} from 'incur/client' +import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport } from 'incur/client' import { expectTypeOf, test } from 'vitest' type Commands = { @@ -49,22 +42,22 @@ type Commands = { test('docs api example client surface typechecks conceptually', async () => { const fetcher = (() => Promise.resolve(new Response('{}'))) as typeof fetch - const client = createHttpClient({ + const client = HttpClient.create({ baseUrl: 'https://ops.acme.test', fetch: fetcher, outputFormat: 'toon', }) - createClient({ + Client.create({ transport: HttpTransport.create({ baseUrl: 'https://ops.acme.test' }), outputFormat: 'toon', }) const cli = Cli.create({ name: 'acme' }) - const memoryClient = createMemoryClient(cli, { + const memoryClient = MemoryClient.create(cli, { env: { ACME_TOKEN: 'dev_secret_123' }, }) - createClient({ + Client.create({ transport: MemoryTransport.create(cli, { env: { ACME_TOKEN: 'dev_secret_123' } }), }) @@ -93,7 +86,7 @@ test('docs api example client surface typechecks conceptually', async () => { args: { projectId: 'proj_web_2026', environment: 'production' }, }) } catch (error) { - if (error instanceof ClientError) { + if (error instanceof Client.ClientError) { expectTypeOf(error.error?.code).toEqualTypeOf() } } diff --git a/src/client/createClient.ts b/src/client/createClient.ts deleted file mode 100644 index 342a3a6..0000000 --- a/src/client/createClient.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type * as Cli from '../Cli.js' -import * as discovery from './actions/discovery.js' -import * as local from './actions/local.js' -import { run } from './actions/run.js' -import * as HttpTransport from './transports/HttpTransport.js' -import * as MemoryTransport from './transports/MemoryTransport.js' -import type { - AnyCli, - Client, - ClientDefaults, - Commands, - CreateClientOptions, - HttpClient, - MemoryClient, - Transport, -} from './types.js' - -/** Creates a typed client from a transport factory. */ -export function createClient< - const commands = Commands, - const transport extends Transport = Transport, - const defaults extends ClientDefaults = {}, ->(options: CreateClientOptions): Client { - const { transport, ...defaults } = options - const resolved = transport() - const { config, ...capabilities } = resolved - const client = { - defaults, - transport: { ...config, ...capabilities }, - type: 'client', - } as unknown as Client - - return attachActions(client) as Client -} - -/** Creates an HTTP typed client. */ -export function createHttpClient< - const commands = Commands, - const defaults extends ClientDefaults = {}, ->(options: HttpTransport.Options & defaults & ClientDefaults): HttpClient { - const { baseUrl, fetch, headers, ...defaults } = options - return createClient({ - ...defaults, - transport: HttpTransport.create({ - baseUrl, - ...(fetch ? { fetch } : undefined), - ...(headers ? { headers } : undefined), - }), - } as HttpTransport.Options & defaults & { transport: HttpTransport.HttpTransport }) -} - -/** Creates a memory typed client and infers commands from a concrete CLI. */ -export function createMemoryClient< - const commands extends Cli.CommandsMap, - const defaults extends ClientDefaults = {}, ->( - cli: Cli.Cli, - options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, -): MemoryClient -/** Creates a memory typed client with an explicit command map. */ -export function createMemoryClient< - const commands = Commands, - const defaults extends ClientDefaults = {}, ->( - cli: AnyCli, - options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, -): MemoryClient -export function createMemoryClient( - cli: AnyCli, - options: MemoryTransport.Options & ClientDefaults = {}, -): MemoryClient { - const { env, ...defaults } = options - return createClient({ - ...defaults, - transport: MemoryTransport.create(cli, { env }), - }) -} - -function attachActions(client: client): client { - Object.assign(client, { - run(command: string, input?: unknown) { - return run(client as never, command, input as never) - }, - llms(options?: unknown) { - return discovery.llms(client as never, options as never) - }, - llmsFull(options?: unknown) { - return discovery.llmsFull(client as never, options as never) - }, - schema(command?: string | undefined) { - return discovery.schema(client as never, command) - }, - help(command?: string | undefined) { - return discovery.help(client as never, command) - }, - openapi() { - return discovery.openapi(client as never) - }, - skills: { - index() { - return discovery.skillsIndex(client as never) - }, - get(name: string) { - return discovery.skill(client as never, name) - }, - }, - mcp: { - tools() { - return discovery.mcpTools(client as never) - }, - }, - }) - - if ('transport' in client && 'local' in (client as { transport: object }).transport) { - Object.assign((client as unknown as { skills: object }).skills, { - add(options?: unknown) { - return local.skillsAdd(client as never, options as never) - }, - list(options?: unknown) { - return local.skillsList(client as never, options as never) - }, - }) - Object.assign((client as unknown as { mcp: object }).mcp, { - add(options?: unknown) { - return local.mcpAdd(client as never, options as never) - }, - }) - } - - return client -} diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts index d3cba5d..a6f6de8 100644 --- a/src/client/index.test-d.ts +++ b/src/client/index.test-d.ts @@ -1,18 +1,5 @@ import { Cli, z } from 'incur' -import { - HttpTransport, - MemoryTransport, - createClient, - createHttpClient, - createMemoryClient, -} from 'incur/client' -import type { - Client, - ClientRunResult, - ClientStreamResponse, - HttpClient, - MemoryClient, -} from 'incur/client' +import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport } from 'incur/client' import { expectTypeOf, test } from 'vitest' type Commands = { @@ -35,18 +22,36 @@ type Commands = { } } +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 = createHttpClient({ + const http = HttpClient.create({ baseUrl: 'https://example.com', outputFormat: 'toon', }) - expectTypeOf(http).toMatchTypeOf>() + expectTypeOf(http).toMatchTypeOf>() expectTypeOf(http.transport.type).toEqualTypeOf<'http'>() - const primitive = createClient({ + const primitive = Client.create({ transport: HttpTransport.create({ baseUrl: 'https://example.com' }), }) - expectTypeOf(primitive).toMatchTypeOf>() + expectTypeOf(primitive).toMatchTypeOf>() }) test('memory clients infer commands and allow explicit override', () => { @@ -54,29 +59,29 @@ test('memory clients infer commands and allow explicit override', () => { args: z.object({ id: z.string() }), run: () => ({ ok: true }), }) - const inferred = createMemoryClient(cli) + const inferred = MemoryClient.create(cli) expectTypeOf(inferred).toMatchTypeOf< - MemoryClient<{ status: { args: { id: string }; options: {} } }> + MemoryClient.MemoryClient<{ status: { args: { id: string }; options: {} } }> >() - const explicit = createMemoryClient(cli) - expectTypeOf(explicit).toMatchTypeOf>() + const explicit = MemoryClient.create(cli) + expectTypeOf(explicit).toMatchTypeOf>() }) test('local actions are memory-only and unavailable on HTTP or broad transports', () => { - const http = createHttpClient({ baseUrl: 'https://example.com' }) + 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 = createMemoryClient(cli) + const memory = MemoryClient.create(cli) expectTypeOf(memory.skills.add).toBeFunction() expectTypeOf(memory.skills.list).toBeFunction() expectTypeOf(memory.mcp.add).toBeFunction() - const broad = createClient< + const broad = Client.create< Commands, HttpTransport.HttpTransport | MemoryTransport.MemoryTransport >({ @@ -87,7 +92,7 @@ test('local actions are memory-only and unavailable on HTTP or broad transports' }) test('run input and return types follow command map', async () => { - const client = createHttpClient({ baseUrl: 'https://example.com' }) + 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') @@ -96,7 +101,7 @@ test('run input and return types follow command map', async () => { await client.run('project deploy', { args: { projectId: 'p1' } }) const report = await client.run('project report', { args: { projectId: 'p1' } }) - expectTypeOf(report).toEqualTypeOf>() + expectTypeOf(report).toEqualTypeOf>() const selected = await client.run('project report', { args: { projectId: 'p1' }, selection: ['summary'], @@ -104,13 +109,15 @@ test('run input and return types follow command map', async () => { expectTypeOf(selected.data).toEqualTypeOf() const stream = await client.run('logs tail', { args: { service: 'api' } }) - expectTypeOf(stream).toEqualTypeOf>() + expectTypeOf(stream).toEqualTypeOf< + Client.ClientStreamResponse<{ line: string }, unknown, Commands> + >() // @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 = createClient< + const selectedClient = Client.create< Commands, HttpTransport.HttpTransport, { selection: string[] } @@ -135,8 +142,8 @@ test('selection defaults and clearing affect data inference', async () => { expectTypeOf(conservative.data).toEqualTypeOf() }) -test('discovery overloads and permissive command maps', async () => { - const client = createHttpClient({ baseUrl: 'https://example.com' }) +test('resources overloads and permissive command maps', async () => { + const client = HttpClient.create({ baseUrl: 'https://example.com' }) expectTypeOf(await client.llms()).toMatchTypeOf<{ commands: unknown[] }>() expectTypeOf(await client.llms({ format: undefined })).toMatchTypeOf<{ commands: unknown[] }>() expectTypeOf(await client.llms({ format: 'json' })).toMatchTypeOf<{ commands: unknown[] }>() @@ -148,12 +155,21 @@ test('discovery overloads and permissive command maps', async () => { const format = undefined as 'md' | undefined expectTypeOf(await client.llms({ format })).toMatchTypeOf() await client.llmsFull({ command: 'project' }) - // @ts-expect-error unknown discovery scope. + // @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 = createHttpClient({ baseUrl: 'https://example.com' }) + const loose = HttpClient.create({ baseUrl: 'https://example.com' }) await loose.run('runtime-only command', { args: { any: 'value' }, options: ['accepted'] }) }) + +test('old flat factory functions are not exported', () => { + // @ts-expect-error use Client.create. + expectTypeOf(Client.createClient).toBeNever() + // @ts-expect-error use HttpClient.create. + expectTypeOf(HttpClient.createHttpClient).toBeNever() + // @ts-expect-error use MemoryClient.create. + expectTypeOf(MemoryClient.createMemoryClient).toBeNever() +}) diff --git a/src/client/index.ts b/src/client/index.ts index a92b475..e39ed3d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,81 +1,10 @@ -export { ClientError } from './ClientError.js' -export { createClient, createHttpClient, createMemoryClient } from './createClient.js' +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 Transport from './transports/Transport.js' -export type { - Client, - ClientBase, - ClientCta, - ClientCtaBlock, - ClientCtaRunOptions, - ClientDefaults, - ClientMeta, - ClientOutput, - ClientRpcEnvelope, - ClientRpcError, - ClientRpcMeta, - ClientRunResult, - ClientStreamFinal, - ClientStreamOutput, - ClientStreamRecord, - ClientStreamResponse, - CommandArgs, - CommandData, - CommandId, - CommandOptions, - CommandScope, - Commands, - CommandsMap, - CreateClientOptions, - DiscoveryActions, - DiscoveryFormat, - DiscoveryResult, - EffectiveOutput, - EffectiveRunOutput, - HttpClient, - LlmsAction, - LlmsFullAction, - LlmsFullManifest, - LlmsManifest, - LocalActions, - McpToolsResponse, - MemoryClient, - OpenApiDocument, - OutputOptions, - Register, - ResolvedTransport, - RunActions, - RunInput, - RunInputParameters, - RunReturn, - SkillsIndex, - StrictInput, -} from './types.js' -export type { - McpAddOptions, - McpRegistration, - SkillsAddOptions, - SkillsList, - SkillsListOptions, - SyncedSkills, -} from './Local.js' -export type { - Request as ResourcesRequest, - Response as ResourcesResponse, -} from './Resources.js' -export type { - Envelope as RpcEnvelope, - Meta as RpcMeta, - Output as RpcOutput, - Request as RpcRequest, - Response as RpcResponse, - StreamRecord as RpcStreamRecord, - StreamResponse as RpcStreamResponse, -} from './Rpc.js' -export type { Options as HttpTransportOptions } from './transports/HttpTransport.js' -export type { Options as MemoryTransportOptions } from './transports/MemoryTransport.js' -export type { Factory as TransportFactory } from './transports/Transport.js' +export type { Register } from './types.js' diff --git a/src/client/stream.test.ts b/src/client/stream.test.ts index 76d2afd..c688256 100644 --- a/src/client/stream.test.ts +++ b/src/client/stream.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from 'vitest' +import * as Client from './Client.js' import { ClientError } from './ClientError.js' -import { createClient } from './createClient.js' import type { Request as RpcRequest, Response as RpcResponse, @@ -33,7 +33,7 @@ function streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { } }, })) satisfies HttpTransport.HttpTransport - return createClient({ transport }) + return Client.create({ transport }) } describe('ClientStreamResponse', () => { diff --git a/src/client/types.ts b/src/client/types.ts index 5f258db..4c7e17f 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,27 +1,8 @@ import type * as Cli from '../Cli.js' import type * as Formatter from '../Formatter.js' -import type { - McpAddOptions, - McpRegistration, - Runtime as LocalRuntime, - SkillsAddOptions, - SkillsList, - SkillsListOptions, - SyncedSkills, -} from './Local.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 { - Request as ResourcesRequest, - Response as ResourcesResponse, -} from './Resources.js' +import type * as Local from './Local.js' +import type * as Resources from './Resources.js' +import type * as Rpc from './Rpc.js' import type { HttpTransport } from './transports/HttpTransport.js' import type { MemoryTransport } from './transports/MemoryTransport.js' @@ -87,7 +68,7 @@ export type Client< defaults extends ClientDefaults = {}, > = ClientBase & RunActions & - DiscoveryActions & + ResourcesActions & ([transport] extends [MemoryTransport] ? LocalActions : {}) /** HTTP client instance. */ @@ -104,11 +85,8 @@ export type MemoryClient -/** Options for `createClient`. */ -export type CreateClientOptions< - transport extends Transport, - defaults extends ClientDefaults, -> = defaults & +/** Options for `Client.create()`. */ +export type CreateOptions = defaults & ClientDefaults & { /** Transport factory to resolve. */ transport: transport @@ -117,12 +95,12 @@ export type CreateClientOptions< /** Canonical command id. */ export type CommandId = keyof commands & string -/** Command prefix usable by discovery actions. */ +/** 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 discovery actions. */ +/** Command or command-group scope usable by resources actions. */ export type CommandScope = CommandId | CommandPrefix> /** Command args type. */ @@ -345,11 +323,11 @@ export type ClientStreamRecord } | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } -/** Discovery format. */ -export type DiscoveryFormat = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' +/** Resources format. */ +export type ResourcesFormat = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' -/** Discovery result for a structured type and format option. */ -export type DiscoveryResult = [format] extends [undefined] +/** Resources result for a structured type and format option. */ +export type ResourcesResult = [format] extends [undefined] ? structured : [format] extends ['json'] ? structured @@ -418,20 +396,14 @@ export type SkillsIndex = { skills: { name: string; description: string; files: string[] }[] } -/** Local skills list. */ -export type SkillsList = { - /** Listed skills. */ - skills: unknown[] -} - /** MCP tool descriptor response. */ export type McpToolsResponse<_commands = Commands> = { /** MCP tools. */ tools: Record[] } -/** Discovery action set. */ -export type DiscoveryActions = { +/** Resources action set. */ +export type ResourcesActions = { llms: LlmsAction llmsFull: LlmsFullAction schema(command?: CommandScope | undefined): Promise> @@ -446,71 +418,58 @@ export type DiscoveryActions = { } } -/** Compact LLM discovery action. */ +/** Compact LLM resources action. */ export type LlmsAction = { < const scope extends CommandScope | undefined = undefined, - const format extends DiscoveryFormat | undefined = undefined, + const format extends ResourcesFormat | undefined = undefined, >( options?: { command?: scope | undefined; format?: format | undefined } | undefined, - ): Promise, format>> + ): Promise, format>> } -/** Full LLM discovery action. */ +/** Full LLM resources action. */ export type LlmsFullAction = { < const scope extends CommandScope | undefined = undefined, - const format extends DiscoveryFormat | undefined = undefined, + const format extends ResourcesFormat | undefined = undefined, >( options?: { command?: scope | undefined; format?: format | undefined } | undefined, - ): Promise, format>> + ): Promise, format>> } /** Memory-only local actions. */ export type LocalActions = { skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise + add(options?: Local.SkillsAddOptions | undefined): Promise + list(options?: Local.SkillsListOptions | undefined): Promise } mcp: { - add(options?: McpAddOptions | undefined): Promise + add(options?: Local.McpAddOptions | undefined): Promise } } /** Public RPC envelope alias. */ -export type ClientRpcEnvelope = RpcFullEnvelope +export type ClientRpcEnvelope = Rpc.Envelope /** Public RPC metadata alias. */ -export type ClientRpcMeta = RpcMeta +export type ClientRpcMeta = Rpc.Meta /** Public RPC output alias. */ -export type ClientRpcOutput = RpcOutput +export type ClientRpcOutput = Rpc.Output /** Public RPC error object. */ -export type ClientRpcError = Extract['error'] +export type ClientRpcError = Extract['error'] /** Client implementation shape used by actions. */ export type ActionClient = { defaults: ClientDefaults transport: { - request(request: RpcRequest): Promise - discover(request: ResourcesRequest): Promise - local?: LocalRuntime | undefined + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + local?: Local.Handler | undefined } & ResolvedTransport } /** CLI value accepted by memory clients. */ export type AnyCli = Cli.Cli - -export type { - McpAddOptions, - McpRegistration, - RpcRequest, - RpcResponse, - RpcStreamRecord, - RpcStreamResponse, - SkillsAddOptions, - SkillsList, - SkillsListOptions, - SyncedSkills, -} From db86e38c8c23c307759d80e79127b8a886c34336 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 23:31:10 +0200 Subject: [PATCH 13/21] refactor(client): compose action sets --- src/client/Client.ts | 73 ++++++------------- src/client/actions/local.test.ts | 116 ++++++++++++++++++++----------- src/client/actions/local.ts | 28 ++++++-- src/client/actions/resources.ts | 47 ++++++++++--- src/client/actions/run.ts | 20 ++++-- 5 files changed, 172 insertions(+), 112 deletions(-) diff --git a/src/client/Client.ts b/src/client/Client.ts index f52108c..6abfe72 100644 --- a/src/client/Client.ts +++ b/src/client/Client.ts @@ -1,8 +1,9 @@ import * as local from './actions/local.js' import * as resources from './actions/resources.js' -import { run } from './actions/run.js' +import * as run from './actions/run.js' export { ClientError } from './ClientError.js' import type { + ActionClient, Client, ClientBase, ClientCta, @@ -113,61 +114,33 @@ export function create< defaults, transport: { ...config, ...capabilities }, type: 'client', - } as unknown as Client + } satisfies ActionClient & { type: 'client' } - return attachActions(client) as Client + return { + ...client, + ...actions(client), + } as unknown as Client } -function attachActions(client: client): client { - Object.assign(client, { - run(command: string, input?: unknown) { - return run(client as never, command, input as never) - }, - llms(options?: unknown) { - return resources.llms(client as never, options as never) - }, - llmsFull(options?: unknown) { - return resources.llmsFull(client as never, options as never) - }, - schema(command?: string | undefined) { - return resources.schema(client as never, command) - }, - help(command?: string | undefined) { - return resources.help(client as never, command) - }, - openapi() { - return resources.openapi(client as never) - }, +function actions(client: ActionClient) { + const base = { + ...run.actions(client), + ...resources.actions(client), + } + + if (!client.transport.local) return base + const memory = local.actions(client) + + return { + ...base, + ...memory, skills: { - index() { - return resources.skillsIndex(client as never) - }, - get(name: string) { - return resources.skill(client as never, name) - }, + ...base.skills, + ...memory.skills, }, mcp: { - tools() { - return resources.mcpTools(client as never) - }, + ...base.mcp, + ...memory.mcp, }, - }) - - if ('transport' in client && 'local' in (client as { transport: object }).transport) { - Object.assign((client as unknown as { skills: object }).skills, { - add(options?: unknown) { - return local.skillsAdd(client as never, options as never) - }, - list(options?: unknown) { - return local.skillsList(client as never, options as never) - }, - }) - Object.assign((client as unknown as { mcp: object }).mcp, { - add(options?: unknown) { - return local.mcpAdd(client as never, options as never) - }, - }) } - - return client } diff --git a/src/client/actions/local.test.ts b/src/client/actions/local.test.ts index d07210f..0d6175c 100644 --- a/src/client/actions/local.test.ts +++ b/src/client/actions/local.test.ts @@ -1,49 +1,83 @@ -import { describe, expect, test, vi } from 'vitest' - -import * as Client from '../Client.js' -import type * as MemoryTransport from '../transports/MemoryTransport.js' - -function memoryClient() { - const transport = (() => ({ - config: { key: 'memory', name: 'Memory', type: 'memory' as const }, - discover: vi.fn(async () => ({ contentType: 'application/json', data: {} })), - request: vi.fn(), - local: { - skills: { - add: vi.fn(async (options) => ({ - agents: [], - paths: [], - skills: [{ name: 'deploy' }], - options, - })), - list: vi.fn(async () => ({ - skills: [{ description: 'Deploy', installed: false, name: 'deploy' }], - })), - }, - mcp: { - add: vi.fn(async (options) => ({ agents: options?.agents ?? [], command: 'pnpm app' })), - }, - }, - })) satisfies MemoryTransport.MemoryTransport - return Client.create<{}, MemoryTransport.MemoryTransport>({ transport }) -} +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 delegate and coexist with resources namespaces', async () => { - const client = memoryClient() + 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', + }) - await expect(client.skills.index()).resolves.toEqual({}) - await expect(client.mcp.tools()).resolves.toEqual({}) - await expect(client.skills.add({ depth: 1, global: true })).resolves.toMatchObject({ - skills: [{ name: 'deploy' }], - options: { depth: 1, global: true }, + expect(mocks.list).toHaveBeenCalledWith('app', expect.any(Map), { + cwd: '/workspace/app', + depth: 3, + description: 'App', + include: undefined, + rootCommand: undefined, }) - await expect(client.skills.list()).resolves.toEqual({ - skills: [{ description: 'Deploy', installed: false, name: 'deploy' }], + expect(mocks.sync).toHaveBeenCalledWith('app', expect.any(Map), { + cwd: '/workspace/app', + depth: 4, + description: 'App', + global: false, + include: undefined, + rootCommand: undefined, }) - await expect(client.mcp.add({ agents: ['codex'] })).resolves.toEqual({ - agents: ['codex'], - command: 'pnpm app', + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['cursor'], + command: 'pnpm app --mcp', + global: false, }) }) }) diff --git a/src/client/actions/local.ts b/src/client/actions/local.ts index a36a5f9..89b0e9d 100644 --- a/src/client/actions/local.ts +++ b/src/client/actions/local.ts @@ -1,3 +1,4 @@ +import { ClientError } from '../ClientError.js' import type * as Local from '../Local.js' import type { ActionClient } from '../types.js' @@ -16,14 +17,27 @@ export function mcpAdd(client: ActionClient, options?: Local.McpAddOptions | und return local(client).mcp.add(options) } -function local(client: ActionClient) { - return client.transport.local as { +/** Binds memory-local actions to a client. */ +export function actions(client: ActionClient) { + return { skills: { - add(options?: Local.SkillsAddOptions | undefined): Promise - list(options?: Local.SkillsListOptions | undefined): Promise - } + add(options?: Local.SkillsAddOptions | undefined) { + return skillsAdd(client, options) + }, + list(options?: Local.SkillsListOptions | undefined) { + return skillsList(client, options) + }, + }, mcp: { - add(options?: Local.McpAddOptions | undefined): Promise - } + add(options?: Local.McpAddOptions | undefined) { + return mcpAdd(client, options) + }, + }, } } + +function local(client: ActionClient): Local.Handler { + const { local } = client.transport + if (!local) throw new ClientError('Local actions require a memory client.') + return local +} diff --git a/src/client/actions/resources.ts b/src/client/actions/resources.ts index 1395c5f..411e3b2 100644 --- a/src/client/actions/resources.ts +++ b/src/client/actions/resources.ts @@ -9,11 +9,11 @@ import type { SkillsIndex, } from '../types.js' +/** LLM resource action options. */ +export type LlmsOptions = { command?: string | undefined; format?: ResourcesFormat | undefined } + /** Reads compact LLM resources. */ -export async function llms( - client: ActionClient, - options: { command?: string | undefined; format?: ResourcesFormat | undefined } = {}, -): Promise { +export async function llms(client: ActionClient, options: LlmsOptions = {}): Promise { const { command, format = 'json' } = options return discover(client, { resource: 'llms', @@ -23,10 +23,7 @@ export async function llms( } /** Reads full LLM resources. */ -export async function llmsFull( - client: ActionClient, - options: { command?: string | undefined; format?: ResourcesFormat | undefined } = {}, -): Promise { +export async function llmsFull(client: ActionClient, options: LlmsOptions = {}): Promise { const { command, format = 'json' } = options return discover(client, { resource: 'llmsFull', @@ -77,6 +74,40 @@ 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?: CommandScope | undefined) { + return schema(client, command) + }, + help(command?: 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) diff --git a/src/client/actions/run.ts b/src/client/actions/run.ts index 31bf233..82d3a4b 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/run.ts @@ -21,11 +21,14 @@ import type { OutputOptions, } from '../types.js' +/** Runtime input accepted by the untyped run action wrapper. */ +export type Input = OutputOptions & { args?: unknown; options?: unknown } + /** Executes a command through a client transport. */ export async function run( client: ActionClient, command: string, - input: (OutputOptions & { args?: unknown; options?: unknown }) | undefined, + input: Input | undefined, ): Promise { const request = toRequest(client.defaults, command, input) const response = await client.transport.request(request) @@ -33,11 +36,16 @@ export async function run( return normalizeEnvelope(client, request, response) } -function toRequest( - defaults: OutputOptions, - command: string, - input: (OutputOptions & { args?: unknown; options?: unknown }) | undefined, -): RpcRequest { +/** 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: OutputOptions, command: string, input: Input | undefined): RpcRequest { const merged = { ...defaults, ...input, From 1a30a9df76a6b24babac7dec695d49f27223d75e Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 00:21:12 +0200 Subject: [PATCH 14/21] refactor(client): organize public action types --- src/client/Client.ts | 200 ++++---- src/client/HttpClient.ts | 14 +- src/client/MemoryClient.ts | 22 +- src/client/Resources.ts | 116 +++++ src/client/Run.ts | 217 ++++++++ src/client/actions/ActionClient.ts | 14 + .../{local.test.ts => LocalActions.test.ts} | 0 .../actions/{local.ts => LocalActions.ts} | 4 +- ...urces.test.ts => ResourcesActions.test.ts} | 0 .../{resources.ts => ResourcesActions.ts} | 32 +- .../{run.test.ts => RunActions.test.ts} | 92 ++++ src/client/actions/{run.ts => RunActions.ts} | 67 ++- src/client/index.test-d.ts | 19 +- src/client/index.ts | 3 +- src/client/package-exports.test.ts | 18 - src/client/stream.test.ts | 102 ---- src/client/types.ts | 475 ------------------ 17 files changed, 609 insertions(+), 786 deletions(-) create mode 100644 src/client/Run.ts create mode 100644 src/client/actions/ActionClient.ts rename src/client/actions/{local.test.ts => LocalActions.test.ts} (100%) rename src/client/actions/{local.ts => LocalActions.ts} (91%) rename src/client/actions/{resources.test.ts => ResourcesActions.test.ts} (100%) rename src/client/actions/{resources.ts => ResourcesActions.ts} (82%) rename src/client/actions/{run.test.ts => RunActions.test.ts} (66%) rename src/client/actions/{run.ts => RunActions.ts} (87%) delete mode 100644 src/client/package-exports.test.ts delete mode 100644 src/client/stream.test.ts delete mode 100644 src/client/types.ts diff --git a/src/client/Client.ts b/src/client/Client.ts index 6abfe72..29e91f4 100644 --- a/src/client/Client.ts +++ b/src/client/Client.ts @@ -1,111 +1,103 @@ -import * as local from './actions/local.js' -import * as resources from './actions/resources.js' -import * as run from './actions/run.js' +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 { - ActionClient, - Client, - ClientBase, - ClientCta, - ClientCtaBlock, - ClientCtaRunOptions, - ClientDefaults, - ClientMeta, - ClientOutput, - ClientRpcEnvelope, - ClientRpcError, - ClientRpcMeta, - ClientRunResult, - ClientStreamFinal, - ClientStreamOutput, - ClientStreamRecord, - ClientStreamResponse, - CommandArgs, - CommandData, - CommandId, - CommandOptions, - CommandScope, - Commands, - CommandsMap, - CreateOptions, - EffectiveOutput, - EffectiveRunOutput, - LlmsAction, - LlmsFullAction, - LlmsFullManifest, - LlmsManifest, - LocalActions, - McpToolsResponse, - OpenApiDocument, - OutputOptions, - Register, - ResourcesActions, - ResourcesFormat, - ResourcesResult, - ResolvedTransport, - RunActions, - RunInput, - RunInputParameters, - RunReturn, - SkillsIndex, - StrictInput, - Transport, -} from './types.js' - -export type { - Client, - ClientBase, - ClientCta, - ClientCtaBlock, - ClientCtaRunOptions, - ClientDefaults, - ClientMeta, - ClientOutput, - ClientRpcEnvelope, - ClientRpcError, - ClientRpcMeta, - ClientRunResult, - ClientStreamFinal, - ClientStreamOutput, - ClientStreamRecord, - ClientStreamResponse, - CommandArgs, - CommandData, - CommandId, - CommandOptions, - CommandScope, - Commands, - CommandsMap, - CreateOptions, - EffectiveOutput, - EffectiveRunOutput, - LlmsAction, - LlmsFullAction, - LlmsFullManifest, - LlmsManifest, - LocalActions, - McpToolsResponse, - OpenApiDocument, - OutputOptions, - Register, - ResourcesActions, - ResourcesFormat, - ResourcesResult, - ResolvedTransport, - RunActions, - RunInput, - RunInputParameters, - RunReturn, - SkillsIndex, - StrictInput, - Transport, +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 ClientDefaults = {}, + const defaults extends Defaults = {}, >(options: CreateOptions): Client { const { transport, ...defaults } = options const resolved = transport() @@ -124,12 +116,12 @@ export function create< function actions(client: ActionClient) { const base = { - ...run.actions(client), - ...resources.actions(client), + ...RunActions.actions(client), + ...ResourcesActions.actions(client), } if (!client.transport.local) return base - const memory = local.actions(client) + const memory = LocalActions.actions(client) return { ...base, diff --git a/src/client/HttpClient.ts b/src/client/HttpClient.ts index b75beee..94dfe11 100644 --- a/src/client/HttpClient.ts +++ b/src/client/HttpClient.ts @@ -1,13 +1,17 @@ import * as Client from './Client.js' import * as HttpTransport from './transports/HttpTransport.js' -import type { ClientDefaults, Commands, HttpClient } from './types.js' -export type { HttpClient } +/** HTTP client instance. */ +export type HttpClient< + commands = Client.Commands, + defaults extends Client.Defaults = {}, +> = Client.Client /** Creates an HTTP typed client. */ -export function create( - options: HttpTransport.Options & defaults & ClientDefaults, -): HttpClient { +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, diff --git a/src/client/MemoryClient.ts b/src/client/MemoryClient.ts index 1389feb..0e13e4e 100644 --- a/src/client/MemoryClient.ts +++ b/src/client/MemoryClient.ts @@ -1,26 +1,34 @@ import type * as Cli from '../Cli.js' import * as Client from './Client.js' import * as MemoryTransport from './transports/MemoryTransport.js' -import type { AnyCli, ClientDefaults, Commands, MemoryClient } from './types.js' -export type { MemoryClient } +type AnyCli = Cli.Cli + +/** 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 commands extends Cli.CommandsMap, - const defaults extends ClientDefaults = {}, + const defaults extends Client.Defaults = {}, >( cli: Cli.Cli, - options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, + options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined, ): MemoryClient /** Creates a memory typed client with an explicit command map. */ -export function create( +export function create< + const commands = Client.Commands, + const defaults extends Client.Defaults = {}, +>( cli: AnyCli, - options?: (MemoryTransport.Options & defaults & ClientDefaults) | undefined, + options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined, ): MemoryClient export function create( cli: AnyCli, - options: MemoryTransport.Options & ClientDefaults = {}, + options: MemoryTransport.Options & Client.Defaults = {}, ): MemoryClient { const { env, ...defaults } = options return Client.create({ diff --git a/src/client/Resources.ts b/src/client/Resources.ts index 62fc641..8bec9ac 100644 --- a/src/client/Resources.ts +++ b/src/client/Resources.ts @@ -1,4 +1,17 @@ import type * as Formatter from '../Formatter.js' +import type * as Client from './Client.js' + +/** Resources format. */ +export type Format = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' + +/** Resources result for a structured type and format option. */ +export type Result = [format] extends [undefined] + ? structured + : [format] extends ['json'] + ? structured + : undefined extends format + ? structured | string + : string /** Resource request accepted by `transport.discover()`. */ export type Request = @@ -15,3 +28,106 @@ export type Request = export type Response = | { contentType: string; body: string } | { contentType: string; data: unknown } + +/** LLM manifest. */ +export type LlmsManifest< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = { + /** Manifest version. */ + version: string + /** Available commands. */ + commands: LlmsCommand[] +} + +/** Full LLM manifest. */ +export type LlmsFullManifest< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = LlmsManifest + +/** LLM command entry. */ +export type LlmsCommand< + commands = Client.Commands, + scope extends Client.CommandScope | undefined = undefined, +> = { + /** Command name. */ + name: scope extends undefined + ? Client.CommandId + : Extract, `${scope}` | `${scope} ${string}`> + /** Command description. */ + description?: string | undefined + /** Command schemas. */ + schema?: CommandSchema> | undefined +} + +/** JSON-ish command schema. */ +export type CommandSchema<_commands = Client.Commands, _command extends string = string> = Record< + string, + unknown +> & { + /** Args schema. */ + args?: Record | undefined + /** Options schema. */ + options?: Record | undefined + /** Env schema. */ + env?: Record | undefined + /** Output schema. */ + output?: Record | undefined +} + +/** OpenAPI document. */ +export type OpenApiDocument = Record & { + /** OpenAPI version. */ + openapi?: string | undefined + /** OpenAPI info object. */ + info?: Record | undefined +} + +/** Skills index. */ +export type SkillsIndex = { + /** Generated skills. */ + skills: { name: string; description: string; files: string[] }[] +} + +/** MCP tool descriptor response. */ +export type McpToolsResponse<_commands = Client.Commands> = { + /** MCP tools. */ + tools: Record[] +} + +/** Resources action set. */ +export type Actions = { + llms: LlmsAction + llmsFull: LlmsFullAction + schema(command?: Client.CommandScope | undefined): Promise> + help(command?: Client.CommandScope | undefined): Promise + openapi(): Promise + skills: { + index(): Promise + get(name: string): Promise + } + mcp: { + tools(): Promise> + } +} + +/** Compact LLM resources action. */ +export type LlmsAction = { + < + const scope extends Client.CommandScope | undefined = undefined, + const format extends Format | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} + +/** Full LLM resources action. */ +export type LlmsFullAction = { + < + const scope extends Client.CommandScope | undefined = undefined, + const format extends Format | undefined = undefined, + >( + options?: { command?: scope | undefined; format?: format | undefined } | undefined, + ): Promise, format>> +} diff --git a/src/client/Run.ts b/src/client/Run.ts new file mode 100644 index 0000000..a240afa --- /dev/null +++ b/src/client/Run.ts @@ -0,0 +1,217 @@ +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 } + +/** 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/local.test.ts b/src/client/actions/LocalActions.test.ts similarity index 100% rename from src/client/actions/local.test.ts rename to src/client/actions/LocalActions.test.ts diff --git a/src/client/actions/local.ts b/src/client/actions/LocalActions.ts similarity index 91% rename from src/client/actions/local.ts rename to src/client/actions/LocalActions.ts index 89b0e9d..b01e7c1 100644 --- a/src/client/actions/local.ts +++ b/src/client/actions/LocalActions.ts @@ -1,6 +1,6 @@ import { ClientError } from '../ClientError.js' import type * as Local from '../Local.js' -import type { ActionClient } from '../types.js' +import type { ActionClient } from './ActionClient.js' /** Runs memory-local `skills add`. */ export function skillsAdd(client: ActionClient, options?: Local.SkillsAddOptions | undefined) { @@ -36,7 +36,7 @@ export function actions(client: ActionClient) { } } -function local(client: ActionClient): Local.Handler { +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/resources.test.ts b/src/client/actions/ResourcesActions.test.ts similarity index 100% rename from src/client/actions/resources.test.ts rename to src/client/actions/ResourcesActions.test.ts diff --git a/src/client/actions/resources.ts b/src/client/actions/ResourcesActions.ts similarity index 82% rename from src/client/actions/resources.ts rename to src/client/actions/ResourcesActions.ts index 411e3b2..788e3c6 100644 --- a/src/client/actions/resources.ts +++ b/src/client/actions/ResourcesActions.ts @@ -1,16 +1,10 @@ +import type * as Client from '../Client.js' import { ClientError } from '../ClientError.js' import type * as Resources from '../Resources.js' -import type { - ActionClient, - CommandScope, - McpToolsResponse, - OpenApiDocument, - ResourcesFormat, - SkillsIndex, -} from '../types.js' +import type { ActionClient } from './ActionClient.js' /** LLM resource action options. */ -export type LlmsOptions = { command?: string | undefined; format?: ResourcesFormat | undefined } +export type LlmsOptions = { command?: string | undefined; format?: Resources.Format | undefined } /** Reads compact LLM resources. */ export async function llms(client: ActionClient, options: LlmsOptions = {}): Promise { @@ -35,7 +29,7 @@ export async function llmsFull(client: ActionClient, options: LlmsOptions = {}): /** Reads a command schema. */ export async function schema( client: ActionClient, - command?: CommandScope | undefined, + command?: Client.CommandScope | undefined, ): Promise> { return discover(client, { resource: 'schema', @@ -46,7 +40,7 @@ export async function schema( /** Reads help text. */ export async function help( client: ActionClient, - command?: CommandScope | undefined, + command?: Client.CommandScope | undefined, ): Promise { return discover(client, { resource: 'help', @@ -55,13 +49,13 @@ export async function help( } /** Reads the OpenAPI document. */ -export async function openapi(client: ActionClient): Promise { - return discover(client, { resource: 'openapi' }) as Promise +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 +export async function skillsIndex(client: ActionClient): Promise { + return discover(client, { resource: 'skillsIndex' }) as Promise } /** Reads a generated skill file. */ @@ -70,8 +64,8 @@ export async function skill(client: ActionClient, name: string): Promise } /** Reads MCP tool descriptors. */ -export async function mcpTools(client: ActionClient): Promise { - return discover(client, { resource: 'mcpTools' }) as Promise +export async function mcpTools(client: ActionClient): Promise { + return discover(client, { resource: 'mcpTools' }) as Promise } /** Binds resource actions to a client. */ @@ -83,10 +77,10 @@ export function actions(client: ActionClient) { llmsFull(options?: LlmsOptions | undefined) { return llmsFull(client, options) }, - schema(command?: CommandScope | undefined) { + schema(command?: Client.CommandScope | undefined) { return schema(client, command) }, - help(command?: CommandScope | undefined) { + help(command?: Client.CommandScope | undefined) { return help(client, command) }, openapi() { diff --git a/src/client/actions/run.test.ts b/src/client/actions/RunActions.test.ts similarity index 66% rename from src/client/actions/run.test.ts rename to src/client/actions/RunActions.test.ts index 32e8ab3..389c97a 100644 --- a/src/client/actions/run.test.ts +++ b/src/client/actions/RunActions.test.ts @@ -5,6 +5,7 @@ import { ClientError } from '../ClientError.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' @@ -36,6 +37,32 @@ function clientWith(request: (request: RpcRequest) => Promise ({ + 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)! + try { + for (const record of records) yield record + return terminal + } finally { + onReturn() + } + }, + } + }, + })) 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 request = vi.fn( @@ -213,4 +240,69 @@ describe('run action', () => { 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([ + { type: 'chunk', data: { line: 1 } }, + { type: 'chunk', data: { line: 2 } }, + { + type: 'done', + ok: true, + data: { lines: 2 }, + output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, + meta: { command: 'logs', duration: '2ms' }, + }, + ]) + 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: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, + meta: { command: 'logs' }, + }) + }) + + test('records yields terminal errors without throwing, while iteration and final throw', async () => { + const terminal = { + type: 'error' as const, + ok: false as const, + error: { code: 'DISCONNECTED', message: 'Disconnected.' }, + meta: { command: 'logs', duration: '2ms' }, + } + const recordsStream = await streamClient([{ type: 'chunk', data: 1 }, terminal]).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 streamClient([{ type: 'chunk', data: 1 }, terminal]).run('logs') + await expect(async () => { + for await (const _ of iterStream as AsyncIterable) { + } + }).rejects.toThrow(ClientError) + + const finalStream = await streamClient([terminal]).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( + [ + { type: 'chunk', data: 1 }, + { type: 'done', ok: true, data: undefined, meta: { command: 'logs', duration: '1ms' } }, + ], + onReturn, + ).run('logs') + + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: 1 }) + expect(() => stream.records()).toThrow(ClientError) + await iterator.return?.() + expect(onReturn).toHaveBeenCalled() + }) + }) }) diff --git a/src/client/actions/run.ts b/src/client/actions/RunActions.ts similarity index 87% rename from src/client/actions/run.ts rename to src/client/actions/RunActions.ts index 82d3a4b..7801e72 100644 --- a/src/client/actions/run.ts +++ b/src/client/actions/RunActions.ts @@ -1,3 +1,4 @@ +import type * as Client from '../Client.js' import { ClientError } from '../ClientError.js' import type { Envelope as RpcFullEnvelope, @@ -8,21 +9,11 @@ import type { StreamRecord as RpcStreamRecord, StreamResponse as RpcStreamResponse, } from '../Rpc.js' -import type { - ActionClient, - ClientCta, - ClientCtaBlock, - ClientMeta, - ClientOutput, - ClientRunResult, - ClientStreamFinal, - ClientStreamRecord, - ClientStreamResponse, - OutputOptions, -} from '../types.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 = OutputOptions & { args?: unknown; options?: unknown } +export type Input = Client.Defaults & { args?: unknown; options?: unknown } /** Executes a command through a client transport. */ export async function run( @@ -45,7 +36,11 @@ export function actions(client: ActionClient) { } } -function toRequest(defaults: OutputOptions, command: string, input: Input | undefined): RpcRequest { +function toRequest( + defaults: Client.Defaults, + command: string, + input: Input | undefined, +): RpcRequest { const merged = { ...defaults, ...input, @@ -73,7 +68,7 @@ function normalizeEnvelope( client: ActionClient, request: RpcRequest, response: RpcResponse, -): ClientRunResult { +): Run.Result { if (!response.ok) throw errorFromEnvelope(client, response) return { ok: true, @@ -83,11 +78,7 @@ function normalizeEnvelope( } } -function output( - client: ActionClient, - request: RpcRequest, - value: RpcOutput, -): ClientOutput { +function output(client: ActionClient, request: RpcRequest, value: RpcOutput): Run.Output { return normalizeOutput(value, value.nextOffset, (nextOffset) => normalizeNext(client, { ...request, @@ -99,8 +90,8 @@ function output( function normalizeOutput( value: RpcOutput, nextOffset?: number | undefined, - next?: ((nextOffset: number) => Promise>) | undefined, -): ClientOutput { + next?: ((nextOffset: number) => Promise>) | undefined, +): Run.Output { if (typeof value.text !== 'string') throw new ClientError('Malformed RPC output.') return { text: value.text, @@ -115,7 +106,7 @@ function normalizeOutput( async function normalizeNext( client: ActionClient, request: RpcRequest, -): Promise> { +): 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) @@ -125,19 +116,19 @@ function normalizeStream( client: ActionClient, request: RpcRequest, response: RpcStreamResponse, -): ClientStreamResponse { +): Run.StreamResponse { let mode: 'chunks' | 'records' | 'final' | undefined - let terminal: ClientStreamFinal | ClientError | undefined - let resolveFinal: ((value: ClientStreamFinal) => void) | 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) => { + const finalState = new Promise>((resolve, reject) => { resolveFinal = resolve rejectFinal = reject }) void finalState.catch(() => undefined) - async function nextRecord(): Promise> { + 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) @@ -225,7 +216,7 @@ function normalizeStream( } } - function streamRecord(record: RpcStreamRecord): ClientStreamRecord { + function streamRecord(record: RpcStreamRecord): Run.StreamRecord { if (record.type === 'chunk') return record if (record.type === 'done') return { @@ -243,7 +234,7 @@ function normalizeStream( } } - function meta(value: RpcMeta): ClientMeta { + function meta(value: RpcMeta): Run.Meta { return normalizeMeta(client, value) } @@ -266,7 +257,7 @@ function errorFromEnvelope( }) } -function errorFromRecord(record: Extract, { type: 'error' }>) { +function errorFromRecord(record: Extract, { type: 'error' }>) { return new ClientError(record.error.message, { code: record.error.code, data: record, @@ -277,7 +268,7 @@ function errorFromRecord(record: Extract, { type: 'e }) } -function normalizeMeta(client: ActionClient | undefined, value: RpcMeta): ClientMeta { +function normalizeMeta(client: ActionClient | undefined, value: RpcMeta): Run.Meta { return { command: value.command, duration: value.duration, @@ -285,7 +276,7 @@ function normalizeMeta(client: ActionClient | undefined, value: RpcMeta): Client } } -function ctaBlock(client: ActionClient | undefined, value: unknown): ClientCtaBlock { +function ctaBlock(client: ActionClient | undefined, value: unknown): Run.CtaBlock { const block = isRecord(value) ? value : {} const commands = Array.isArray(block.commands) ? block.commands : [] return { @@ -297,7 +288,7 @@ function ctaBlock(client: ActionClient | undefined, value: unknown): ClientCtaBl } } -function cta(client: ActionClient | undefined, value: unknown): ClientCta | undefined { +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) @@ -308,7 +299,7 @@ function runnableCta( client: ActionClient | undefined, value: Record, raw: unknown, -): ClientCta { +): Run.Cta { const command = value.command as string const args = isRecord(value.args) ? value.args : {} const options = isRecord(value.options) ? value.options : {} @@ -319,13 +310,13 @@ function runnableCta( args, options, raw, - run(optionsOverride?: OutputOptions) { + 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< - ClientRunResult + Run.Result > }, - } satisfies ClientCta + } satisfies Run.Cta return result } diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts index a6f6de8..c679b39 100644 --- a/src/client/index.test-d.ts +++ b/src/client/index.test-d.ts @@ -1,5 +1,5 @@ import { Cli, z } from 'incur' -import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport } from 'incur/client' +import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport, Run } from 'incur/client' import { expectTypeOf, test } from 'vitest' type Commands = { @@ -35,7 +35,7 @@ declare module 'incur/client' { test('module registration defaults namespace creators', async () => { const client = HttpClient.create({ baseUrl: 'https://example.com' }) const result = await client.run('registered') - expectTypeOf(result).toEqualTypeOf>() + expectTypeOf(result).toEqualTypeOf>() // @ts-expect-error unregistered commands are rejected without an explicit command map. await client.run('status') }) @@ -101,7 +101,7 @@ test('run input and return types follow command map', async () => { await client.run('project deploy', { args: { projectId: 'p1' } }) const report = await client.run('project report', { args: { projectId: 'p1' } }) - expectTypeOf(report).toEqualTypeOf>() + expectTypeOf(report).toEqualTypeOf>() const selected = await client.run('project report', { args: { projectId: 'p1' }, selection: ['summary'], @@ -109,9 +109,7 @@ test('run input and return types follow command map', async () => { expectTypeOf(selected.data).toEqualTypeOf() const stream = await client.run('logs tail', { args: { service: 'api' } }) - expectTypeOf(stream).toEqualTypeOf< - Client.ClientStreamResponse<{ line: string }, unknown, Commands> - >() + expectTypeOf(stream).toEqualTypeOf>() // @ts-expect-error streaming commands reject token pagination controls. await client.run('logs tail', { args: { service: 'api' }, outputTokenLimit: 1 }) }) @@ -164,12 +162,3 @@ test('resources overloads and permissive command maps', async () => { const loose = HttpClient.create({ baseUrl: 'https://example.com' }) await loose.run('runtime-only command', { args: { any: 'value' }, options: ['accepted'] }) }) - -test('old flat factory functions are not exported', () => { - // @ts-expect-error use Client.create. - expectTypeOf(Client.createClient).toBeNever() - // @ts-expect-error use HttpClient.create. - expectTypeOf(HttpClient.createHttpClient).toBeNever() - // @ts-expect-error use MemoryClient.create. - expectTypeOf(MemoryClient.createMemoryClient).toBeNever() -}) diff --git a/src/client/index.ts b/src/client/index.ts index e39ed3d..7281ff1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,5 +6,6 @@ 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 './types.js' +export type { Register } from './Client.js' diff --git a/src/client/package-exports.test.ts b/src/client/package-exports.test.ts deleted file mode 100644 index d2cbc48..0000000 --- a/src/client/package-exports.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import packageJson from '../../package.json' with { type: 'json' } - -describe('client package exports', () => { - test('package exposes client subpath and keeps root separate', () => { - expect(packageJson.exports['./client']).toMatchObject({ - types: './dist/client/index.d.ts', - src: './src/client/index.ts', - default: './dist/client/index.js', - }) - expect(packageJson.exports['.']).toMatchObject({ - types: './dist/index.d.ts', - src: './src/index.ts', - default: './dist/index.js', - }) - }) -}) diff --git a/src/client/stream.test.ts b/src/client/stream.test.ts deleted file mode 100644 index c688256..0000000 --- a/src/client/stream.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' - -import * as Client from './Client.js' -import { ClientError } from './ClientError.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' - -function streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { - type Commands = { - logs: { args: {}; options: {}; output: unknown; stream: true } - } - 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)! - try { - for (const record of records) yield record - return terminal - } finally { - onReturn() - } - }, - } - }, - })) satisfies HttpTransport.HttpTransport - return Client.create({ transport }) -} - -describe('ClientStreamResponse', () => { - test('default async iteration yields chunks and final resolves terminal metadata', async () => { - const client = streamClient([ - { type: 'chunk', data: { line: 1 } }, - { type: 'chunk', data: { line: 2 } }, - { - type: 'done', - ok: true, - data: { lines: 2 }, - output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, - meta: { command: 'logs', duration: '2ms' }, - }, - ]) - 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: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, - meta: { command: 'logs' }, - }) - }) - - test('records yields terminal errors without throwing, while iteration and final throw', async () => { - const terminal = { - type: 'error' as const, - ok: false as const, - error: { code: 'DISCONNECTED', message: 'Disconnected.' }, - meta: { command: 'logs', duration: '2ms' }, - } - const recordsStream = await streamClient([{ type: 'chunk', data: 1 }, terminal]).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 streamClient([{ type: 'chunk', data: 1 }, terminal]).run('logs') - await expect(async () => { - for await (const _ of iterStream as AsyncIterable) { - } - }).rejects.toThrow(ClientError) - - const finalStream = await streamClient([terminal]).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( - [ - { type: 'chunk', data: 1 }, - { type: 'done', ok: true, data: undefined, meta: { command: 'logs', duration: '1ms' } }, - ], - onReturn, - ).run('logs') - - const iterator = stream[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: 1 }) - expect(() => stream.records()).toThrow(ClientError) - await iterator.return?.() - expect(onReturn).toHaveBeenCalled() - }) -}) diff --git a/src/client/types.ts b/src/client/types.ts deleted file mode 100644 index 4c7e17f..0000000 --- a/src/client/types.ts +++ /dev/null @@ -1,475 +0,0 @@ -import type * as Cli from '../Cli.js' -import type * as Formatter from '../Formatter.js' -import type * as Local from './Local.js' -import type * as Resources from './Resources.js' -import type * as Rpc from './Rpc.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 transports. */ -export type Transport = HttpTransport | MemoryTransport - -/** Resolved transport value attached to a client. */ -export type ResolvedTransport = ReturnType['config'] & - Omit, 'config'> - -/** Client defaults used by run actions. */ -export type ClientDefaults = { - /** 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 ClientBase = { - /** 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 ClientDefaults = {}, -> = ClientBase & - RunActions & - ResourcesActions & - ([transport] extends [MemoryTransport] ? LocalActions : {}) - -/** HTTP client instance. */ -export type HttpClient = Client< - commands, - HttpTransport, - defaults -> - -/** Memory client instance. */ -export type MemoryClient = Client< - commands, - MemoryTransport, - defaults -> - -/** Options for `Client.create()`. */ -export type CreateOptions = defaults & - ClientDefaults & { - /** 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> - -/** Command args type. */ -export type CommandArgs> = commands[command] extends { - args: infer args -} - ? args - : unknown - -/** Command options type. */ -export type CommandOptions< - commands, - command extends CommandId, -> = commands[command] extends { options: infer options } ? options : unknown - -/** Command output data type. */ -export type CommandData> = 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 } - -/** Output controls for command runs. */ -export type OutputOptions = ClientDefaults - -/** Run input for a command. */ -export type RunInput> = Field< - 'args', - CommandArgs -> & - Field<'options', CommandOptions> & - (commands[command] extends { stream: true } - ? Omit - : OutputOptions) - -/** Run input parameter tuple. */ -export type RunInputParameters< - commands, - command extends CommandId, - input extends RunInput | undefined, -> = - RequiredKeys> extends never - ? [input?: StrictInput> | undefined] - : [input: StrictInput> & RunInput] - -/** Rejects keys outside an expected input shape. */ -export type StrictInput = input extends undefined - ? undefined - : input & { [key in Exclude]: never } - -/** 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 RunReturn< - commands, - command extends CommandId, - input extends RunInput | undefined, - defaults extends ClientDefaults, -> = commands[command] extends { stream: true } - ? ClientStreamResponse< - EffectiveRunOutput, input, defaults>, - unknown, - commands - > - : ClientRunResult, input, defaults>, commands> - -/** Run action set. */ -export type RunActions = { - run< - const command extends CommandId, - const input extends RunInput | undefined = undefined, - >( - command: command, - ...input: RunInputParameters - ): Promise> -} - -/** Successful non-streaming command result. */ -export type ClientRunResult = { - /** Success discriminator. */ - ok: true - /** Structured command data. */ - data: data - /** Rendered output text and pagination controls. */ - output?: ClientOutput | undefined - /** Command metadata. */ - meta: ClientMeta -} - -/** Rendered command output. */ -export type ClientOutput = { - /** 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 ClientMeta = { - /** Canonical command id. */ - command: string - /** Wall-clock duration. */ - duration: string - /** Normalized call-to-action metadata. */ - cta?: ClientCtaBlock | undefined -} - -/** CTA block. */ -export type ClientCtaBlock = { - /** CTA block description. */ - description?: string | undefined - /** CTA commands. */ - commands: ClientCta[] -} - -/** CTA command. */ -export type ClientCta = { - /** 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< - ClientRunResult< - EffectiveOutput< - unknown, - options extends { selection: infer selection } ? selection : undefined - >, - commands - > - > -} - -/** CTA run output controls. */ -export type ClientCtaRunOptions = OutputOptions - -/** Stream response wrapper. */ -export type ClientStreamResponse< - chunk, - finalData = unknown, - commands = Commands, -> = AsyncIterable & { - /** Terminal stream result. */ - final: Promise> - /** Iterates over chunk and terminal records. */ - records(): AsyncIterable> -} - -/** Successful terminal stream result. */ -export type ClientStreamFinal = { - /** Success discriminator. */ - ok: true - /** Terminal structured data. */ - data?: finalData | undefined - /** Terminal rendered output text. */ - output?: ClientOutput | undefined - /** Terminal metadata. */ - meta: ClientMeta -} - -/** Stream output attached to a chunk. */ -export type ClientStreamOutput = { - /** Rendered chunk text. */ - text: string - /** Rendered chunk format. */ - format?: Formatter.Format | undefined -} - -/** Normalized stream record. */ -export type ClientStreamRecord = - | { type: 'chunk'; data: chunk; output?: ClientStreamOutput | undefined } - | { - type: 'done' - ok: true - data?: finalData | undefined - output?: ClientOutput | undefined - meta: ClientMeta - } - | { type: 'error'; ok: false; error: ClientRpcError; meta: ClientMeta } - -/** Resources format. */ -export type ResourcesFormat = 'md' | 'json' | 'jsonl' | 'yaml' | 'toon' - -/** Resources result for a structured type and format option. */ -export type ResourcesResult = [format] extends [undefined] - ? structured - : [format] extends ['json'] - ? structured - : undefined extends format - ? structured | string - : string - -/** LLM manifest. */ -export type LlmsManifest< - commands = Commands, - scope extends CommandScope | undefined = undefined, -> = { - /** Manifest version. */ - version: string - /** Available commands. */ - commands: LlmsCommand[] -} - -/** Full LLM manifest. */ -export type LlmsFullManifest< - commands = Commands, - scope extends CommandScope | undefined = undefined, -> = LlmsManifest - -/** LLM command entry. */ -export type LlmsCommand< - commands = Commands, - scope extends CommandScope | undefined = undefined, -> = { - /** Command name. */ - name: scope extends undefined - ? CommandId - : Extract, `${scope}` | `${scope} ${string}`> - /** Command description. */ - description?: string | undefined - /** Command schemas. */ - schema?: CommandSchema> | undefined -} - -/** JSON-ish command schema. */ -export type CommandSchema<_commands = 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 = Commands> = { - /** MCP tools. */ - tools: Record[] -} - -/** Resources action set. */ -export type ResourcesActions = { - llms: LlmsAction - llmsFull: LlmsFullAction - schema(command?: CommandScope | undefined): Promise> - help(command?: 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 CommandScope | undefined = undefined, - const format extends ResourcesFormat | undefined = undefined, - >( - options?: { command?: scope | undefined; format?: format | undefined } | undefined, - ): Promise, format>> -} - -/** Full LLM resources action. */ -export type LlmsFullAction = { - < - const scope extends CommandScope | undefined = undefined, - const format extends ResourcesFormat | undefined = undefined, - >( - options?: { command?: scope | undefined; format?: format | undefined } | undefined, - ): Promise, format>> -} - -/** Memory-only local actions. */ -export type LocalActions = { - skills: { - add(options?: Local.SkillsAddOptions | undefined): Promise - list(options?: Local.SkillsListOptions | undefined): Promise - } - mcp: { - add(options?: Local.McpAddOptions | undefined): Promise - } -} - -/** Public RPC envelope alias. */ -export type ClientRpcEnvelope = Rpc.Envelope - -/** Public RPC metadata alias. */ -export type ClientRpcMeta = Rpc.Meta - -/** Public RPC output alias. */ -export type ClientRpcOutput = Rpc.Output - -/** Public RPC error object. */ -export type ClientRpcError = Extract['error'] - -/** Client implementation shape used by actions. */ -export type ActionClient = { - defaults: ClientDefaults - transport: { - request(request: Rpc.Request): Promise - discover(request: Resources.Request): Promise - local?: Local.Handler | undefined - } & ResolvedTransport -} - -/** CLI value accepted by memory clients. */ -export type AnyCli = Cli.Cli From 6ce7da38830d3b028f4386df2f72e75697fdcfb2 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 00:22:27 +0200 Subject: [PATCH 15/21] test(client): remove api example type test --- src/client/api-example.test-d.ts | 119 ------------------------------- 1 file changed, 119 deletions(-) delete mode 100644 src/client/api-example.test-d.ts diff --git a/src/client/api-example.test-d.ts b/src/client/api-example.test-d.ts deleted file mode 100644 index 3bebc1e..0000000 --- a/src/client/api-example.test-d.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Cli } from 'incur' -import { Client, HttpClient, HttpTransport, MemoryClient, MemoryTransport } from 'incur/client' -import { expectTypeOf, test } from 'vitest' - -type Commands = { - 'project report': { - args: { projectId: string } - options: { includeClosed?: boolean | undefined } - output: { - summary: string - items: { id: string; title: string }[] - nextCursor?: string | undefined - } - } - 'project status': { - args: { projectId: string } - options: {} - output: { status: 'open' | 'blocked' | 'done' } - } - 'project unblock': { - args: { taskId: string } - options: {} - output: { ok: boolean } - } - 'project deploy': { - args: { projectId: string; environment: 'production' | 'staging' } - options: {} - output: { deployId: string } - } - 'auth login': { - args: {} - options: {} - output: { authenticated: boolean } - } - 'logs tail': { - args: { service: string } - options: {} - output: { timestamp: string; level: string; message: string } - stream: true - } -} - -test('docs api example client surface typechecks conceptually', async () => { - const fetcher = (() => Promise.resolve(new Response('{}'))) as typeof fetch - const client = HttpClient.create({ - baseUrl: 'https://ops.acme.test', - fetch: fetcher, - outputFormat: 'toon', - }) - - Client.create({ - transport: HttpTransport.create({ baseUrl: 'https://ops.acme.test' }), - outputFormat: 'toon', - }) - - const cli = Cli.create({ name: 'acme' }) - const memoryClient = MemoryClient.create(cli, { - env: { ACME_TOKEN: 'dev_secret_123' }, - }) - Client.create({ - transport: MemoryTransport.create(cli, { env: { ACME_TOKEN: 'dev_secret_123' } }), - }) - - 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: 24, - }) - expectTypeOf(report.data).toEqualTypeOf() - await report.output?.next?.() - - const status = await client.run('project status', { args: { projectId: 'proj_web_2026' } }) - expectTypeOf(status.data.status).toEqualTypeOf<'open' | 'blocked' | 'done'>() - - const cta = report.meta.cta?.commands[0] - if (cta) { - expectTypeOf(cta.command).toEqualTypeOf() - await cta.run({ outputFormat: 'toon' }) - } - - try { - await client.run('project deploy', { - args: { projectId: 'proj_web_2026', environment: 'production' }, - }) - } catch (error) { - if (error instanceof Client.ClientError) { - expectTypeOf(error.error?.code).toEqualTypeOf() - } - } - - const stream = await client.run('logs tail', { args: { service: 'checkout-api' } }) - for await (const chunk of stream) expectTypeOf(chunk.message).toEqualTypeOf() - expectTypeOf((await stream.final).meta.command).toEqualTypeOf() - for await (const record of stream.records()) - if (record.type === 'chunk') expectTypeOf(record.data.message).toEqualTypeOf() - - const llmsFull = await client.llmsFull({ command: 'project' }) - expectTypeOf(llmsFull.commands[0]?.name).toMatchTypeOf() - const llmsMd = await client.llms({ command: 'project', format: 'md' }) - expectTypeOf(llmsMd).toEqualTypeOf() - const schema = await client.schema('project report') - expectTypeOf(schema.args).toMatchTypeOf | undefined>() - expectTypeOf(await client.help('project report')).toEqualTypeOf() - expectTypeOf((await client.openapi()).info).toMatchTypeOf | undefined>() - expectTypeOf((await client.skills.index()).skills[0]?.name).toEqualTypeOf() - expectTypeOf(await client.skills.get('deploy')).toEqualTypeOf() - expectTypeOf((await client.mcp.tools()).tools[0]).toMatchTypeOf< - Record | undefined - >() - - await memoryClient.skills.list() - await memoryClient.skills.add({ depth: 1, global: true }) - await memoryClient.mcp.add({ agents: ['codex'] }) - // @ts-expect-error local actions are memory-only. - client.skills.add() -}) From a2cd491497c1b1ba7e827aa79828d1e3205351f9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 00:23:26 +0200 Subject: [PATCH 16/21] test(client): update type assertions --- src/client/index.test-d.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/client/index.test-d.ts b/src/client/index.test-d.ts index c679b39..bd94b8d 100644 --- a/src/client/index.test-d.ts +++ b/src/client/index.test-d.ts @@ -45,13 +45,13 @@ test('client creation preserves transport type and defaults', () => { baseUrl: 'https://example.com', outputFormat: 'toon', }) - expectTypeOf(http).toMatchTypeOf>() + expectTypeOf(http).toExtend>() expectTypeOf(http.transport.type).toEqualTypeOf<'http'>() const primitive = Client.create({ transport: HttpTransport.create({ baseUrl: 'https://example.com' }), }) - expectTypeOf(primitive).toMatchTypeOf>() + expectTypeOf(primitive).toExtend>() }) test('memory clients infer commands and allow explicit override', () => { @@ -60,12 +60,12 @@ test('memory clients infer commands and allow explicit override', () => { run: () => ({ ok: true }), }) const inferred = MemoryClient.create(cli) - expectTypeOf(inferred).toMatchTypeOf< + expectTypeOf(inferred).toExtend< MemoryClient.MemoryClient<{ status: { args: { id: string }; options: {} } }> >() const explicit = MemoryClient.create(cli) - expectTypeOf(explicit).toMatchTypeOf>() + expectTypeOf(explicit).toExtend>() }) test('local actions are memory-only and unavailable on HTTP or broad transports', () => { @@ -142,16 +142,16 @@ test('selection defaults and clearing affect data inference', async () => { test('resources overloads and permissive command maps', async () => { const client = HttpClient.create({ baseUrl: 'https://example.com' }) - expectTypeOf(await client.llms()).toMatchTypeOf<{ commands: unknown[] }>() - expectTypeOf(await client.llms({ format: undefined })).toMatchTypeOf<{ commands: unknown[] }>() - expectTypeOf(await client.llms({ format: 'json' })).toMatchTypeOf<{ commands: unknown[] }>() + 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()).toMatchTypeOf<{ commands: unknown[] }>() - expectTypeOf(await client.llmsFull({ format: undefined })).toMatchTypeOf<{ + 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 })).toMatchTypeOf() + expectTypeOf(await client.llms({ format })).toExtend() await client.llmsFull({ command: 'project' }) // @ts-expect-error unknown resources scope. await client.llmsFull({ command: 'unknown' }) From a556fee17ca2496ff2cf3733db92b435f9714264 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 00:47:01 +0200 Subject: [PATCH 17/21] test(client): expand client type coverage --- src/client/Client.test.ts | 63 ++++++++++++++ src/client/HttpClient.test-d.ts | 83 ++++++++++++++++++ src/client/HttpClient.test.ts | 86 ++++++++++++++++++ src/client/Local.test-d.ts | 24 +++++ src/client/MemoryClient.test-d.ts | 85 ++++++++++++++++++ src/client/MemoryClient.test.ts | 77 +++++++++++++++++ src/client/Resources.test-d.ts | 65 ++++++++++++++ src/client/Run.test-d.ts | 101 ++++++++++++++++++++++ src/client/Run.ts | 21 ++++- src/client/transports/Transport.test-d.ts | 53 ++++++++++++ 10 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 src/client/HttpClient.test-d.ts create mode 100644 src/client/HttpClient.test.ts create mode 100644 src/client/Local.test-d.ts create mode 100644 src/client/MemoryClient.test-d.ts create mode 100644 src/client/MemoryClient.test.ts create mode 100644 src/client/Resources.test-d.ts create mode 100644 src/client/Run.test-d.ts create mode 100644 src/client/transports/Transport.test-d.ts diff --git a/src/client/Client.test.ts b/src/client/Client.test.ts index 6db01f4..ab961e8 100644 --- a/src/client/Client.test.ts +++ b/src/client/Client.test.ts @@ -3,6 +3,7 @@ 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 type * as Local from './Local.js' import * as MemoryClient from './MemoryClient.js' import type { Request as RpcRequest, @@ -10,6 +11,7 @@ import type { StreamResponse as RpcStreamResponse, } from './Rpc.js' import * as HttpTransport from './transports/HttpTransport.js' +import type * as MemoryTransport from './transports/MemoryTransport.js' function mockTransport(): HttpTransport.HttpTransport { return () => ({ @@ -27,6 +29,41 @@ function mockTransport(): HttpTransport.HttpTransport { } describe('Client.create', () => { + test('resolves the transport factory exactly once and keeps resolved capabilities', async () => { + const request = vi.fn( + async (_request: RpcRequest): Promise => ({ + ok: true, + data: { ok: true }, + meta: { command: 'status', duration: '1ms' }, + }), + ) + const discover = vi.fn(async () => ({ contentType: 'text/plain', body: 'help' })) + const transport = vi.fn(() => ({ + config: { key: 'mock', name: 'Mock', type: 'http' as const }, + baseUrl: new URL('https://example.com'), + discover, + request, + })) satisfies HttpTransport.HttpTransport + + const client = Client.create({ transport }) + + expect(transport).toHaveBeenCalledTimes(1) + expect(client.transport.request).toBe(request) + expect(client.transport.discover).toBe(discover) + await client.run('status' as never) + await client.help() + expect(request).toHaveBeenCalledTimes(1) + expect(discover).toHaveBeenCalledTimes(1) + }) + + test('propagates transport factory errors', () => { + const transport = (() => { + throw new Error('cannot connect') + }) as HttpTransport.HttpTransport + + expect(() => Client.create({ transport })).toThrow('cannot connect') + }) + test('resolves transport, assigns uid, preserves defaults, and binds actions', async () => { const client = Client.create({ outputFormat: 'toon', @@ -81,6 +118,32 @@ describe('Client.create', () => { expect('add' in client.mcp).toBe(false) }) + test('memory clients merge resource and local methods in shared namespaces', async () => { + const local: Local.Methods = { + skills: { + add: vi.fn(async () => ({ agents: [], paths: [], skills: [] })), + list: vi.fn(async () => ({ skills: [] })), + }, + mcp: { + add: vi.fn(async () => ({ agents: [], command: 'app --mcp' })), + }, + } + const transport = (() => ({ + config: { key: 'memory', name: 'Memory', type: 'memory' as const }, + discover: vi.fn(async () => ({ contentType: 'application/json', data: { skills: [] } })), + local, + request: vi.fn(), + })) satisfies MemoryTransport.MemoryTransport + + const client = Client.create({ transport }) + + await expect(client.skills.index()).resolves.toEqual({ skills: [] }) + await expect(client.skills.list()).resolves.toEqual({ skills: [] }) + await expect(client.skills.add()).resolves.toEqual({ agents: [], paths: [], skills: [] }) + await expect(client.mcp.add()).resolves.toEqual({ agents: [], command: 'app --mcp' }) + expect(typeof client.mcp.tools).toBe('function') + }) + test('missing fetch implementation throws ClientError', () => { const original = globalThis.fetch Object.defineProperty(globalThis, 'fetch', { configurable: true, value: 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..5af51f9 --- /dev/null +++ b/src/client/HttpClient.test.ts @@ -0,0 +1,86 @@ +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', + fetch: vi.fn() as unknown as typeof globalThis.fetch, + }) + + 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/Local.test-d.ts b/src/client/Local.test-d.ts new file mode 100644 index 0000000..eb37f6b --- /dev/null +++ b/src/client/Local.test-d.ts @@ -0,0 +1,24 @@ +import { Local } from 'incur/client' +import { expectTypeOf, test } from 'vitest' + +test('local methods expose precise option and result types', async () => { + const local = undefined as unknown as Local.Methods + + expectTypeOf(await local.skills.add()).toEqualTypeOf() + expectTypeOf(await local.skills.list()).toEqualTypeOf() + expectTypeOf(await local.mcp.add()).toEqualTypeOf() + + await local.skills.add({ depth: 2, global: undefined }) + await local.skills.list({ depth: undefined }) + await local.mcp.add({ agents: ['codex'], command: undefined, global: false }) + // @ts-expect-error depth must be a number. + await local.skills.add({ depth: '2' }) + // @ts-expect-error global must be a boolean. + await local.skills.add({ global: 'yes' }) + // @ts-expect-error agents must be an array of strings. + await local.mcp.add({ agents: [1] }) + // @ts-expect-error command must be a string. + await local.mcp.add({ command: 123 }) + // @ts-expect-error extra option keys are rejected. + await local.skills.list({ depth: 1, extra: true }) +}) diff --git a/src/client/MemoryClient.test-d.ts b/src/client/MemoryClient.test-d.ts new file mode 100644 index 0000000..bc41db4 --- /dev/null +++ b/src/client/MemoryClient.test-d.ts @@ -0,0 +1,85 @@ +import { Cli, z } from 'incur' +import { MemoryClient, Run } from 'incur/client' +import { expectTypeOf, test } from 'vitest' + +type Commands = { + report: { + args: { id: string } + options: { verbose?: boolean | undefined } + output: { title: string } + } + logs: { + args: { service: string } + options: {} + output: { line: string } + stream: true + } +} + +test('memory client infers command maps from concrete CLIs', async () => { + const cli = Cli.create('app') + .command('status', { + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().optional() }), + run(c) { + expectTypeOf(c.args).toEqualTypeOf<{ id: string }>() + expectTypeOf(c.options).toEqualTypeOf<{ verbose?: boolean | undefined }>() + return { ok: true as const } + }, + }) + .command('logs', { + args: z.object({ service: z.string() }), + async *run() { + yield { line: 'ready' } + }, + }) + + const client = MemoryClient.create(cli, { outputFormat: 'json' }) + type InferredCommands = + typeof client extends MemoryClient.MemoryClient ? commands : never + + expectTypeOf(client).toExtend< + MemoryClient.MemoryClient<{ + logs: { args: { service: string }; options: {}; output: { line: string }; stream: true } + status: { + args: { id: string } + options: { verbose?: boolean | undefined } + output: { ok: true } + } + }> + >() + expectTypeOf(client.defaults).toExtend<{ outputFormat?: 'json' | undefined }>() + expectTypeOf(client.transport.type).toEqualTypeOf<'memory'>() + expectTypeOf(client.skills.add).toBeFunction() + expectTypeOf(client.skills.list).toBeFunction() + expectTypeOf(client.mcp.add).toBeFunction() + + expectTypeOf(await client.run('status', { args: { id: 'p1' } })).toEqualTypeOf< + Run.Result + >() + expectTypeOf(await client.run('logs', { args: { service: 'api' } })).toEqualTypeOf< + Run.Result + >() + // @ts-expect-error inferred args are required. + await client.run('status') + // @ts-expect-error unknown options are rejected. + await client.run('status', { args: { id: 'p1' }, options: { extra: true } }) +}) + +test('memory client supports explicit command maps and keeps env out of defaults', async () => { + const client = MemoryClient.create(Cli.create('app'), { + env: { TOKEN: 'secret' }, + outputTokenLimit: 32, + }) + + expectTypeOf(client).toExtend>() + expectTypeOf(client.defaults).toEqualTypeOf<{ outputTokenLimit: number }>() + // @ts-expect-error transport env is not a client default. + void client.defaults.env + expectTypeOf(await client.run('report', { args: { id: 'p1' } })).toEqualTypeOf< + Run.Result<{ title: string }, Commands> + >() + expectTypeOf(await client.run('logs', { args: { service: 'api' } })).toEqualTypeOf< + Run.StreamResponse<{ line: string }, unknown, Commands> + >() +}) diff --git a/src/client/MemoryClient.test.ts b/src/client/MemoryClient.test.ts new file mode 100644 index 0000000..d10539f --- /dev/null +++ b/src/client/MemoryClient.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import * as MemoryClient from './MemoryClient.js' + +describe('MemoryClient.create', () => { + test('creates a memory client, strips transport options from defaults, and executes in process', async () => { + const cli = Cli.create('app', { + env: z.object({ TOKEN: z.string() }), + }).command('status', { + env: z.object({ TOKEN: z.string() }), + run(c) { + return { token: c.env.TOKEN } + }, + }) + cli.fetch = async () => { + throw new Error('fetch should not be called') + } + + const client = MemoryClient.create(cli, { + env: { TOKEN: 'secret' }, + outputFormat: 'json', + outputTokenCount: true, + }) + + expect(client).toMatchObject({ + defaults: { + outputFormat: 'json', + outputTokenCount: true, + }, + transport: { + key: 'memory', + name: 'Memory', + type: 'memory', + }, + type: 'client', + }) + expect(client.defaults).not.toHaveProperty('env') + await expect(client.run('status')).resolves.toMatchObject({ + data: { token: 'secret' }, + ok: true, + }) + }) + + test('exposes memory-only local methods alongside shared resource methods', () => { + const client = MemoryClient.create(Cli.create('app')) + + expect(typeof client.run).toBe('function') + expect(typeof client.llms).toBe('function') + expect(typeof client.llmsFull).toBe('function') + expect(typeof client.schema).toBe('function') + expect(typeof client.help).toBe('function') + expect(typeof client.openapi).toBe('function') + expect(typeof client.skills.index).toBe('function') + expect(typeof client.skills.get).toBe('function') + expect(typeof client.skills.add).toBe('function') + expect(typeof client.skills.list).toBe('function') + expect(typeof client.mcp.tools).toBe('function') + expect(typeof client.mcp.add).toBe('function') + }) + + test('works without options', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const client = MemoryClient.create(cli) + + expect(client.defaults).toEqual({}) + await expect(client.run('status')).resolves.toMatchObject({ + data: { ok: true }, + ok: true, + }) + }) +}) diff --git a/src/client/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/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 index a240afa..70d466a 100644 --- a/src/client/Run.ts +++ b/src/client/Run.ts @@ -62,7 +62,26 @@ export type InputParameters< /** Rejects keys outside an expected input shape. */ export type StrictInput = input extends undefined ? undefined - : input & { [key in Exclude]: never } + : 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 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 } }) +}) From 1fd5dfda959eaf091e0e758f49c6e70ddaa94e9f Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:22:13 +0200 Subject: [PATCH 18/21] test: use real client paths in client tests --- src/client/Client.test.ts | 109 +++----- src/client/HttpClient.test.ts | 1 - src/client/actions/ResourcesActions.test.ts | 143 ++++++---- src/client/actions/RunActions.test.ts | 286 ++++++++++---------- 4 files changed, 279 insertions(+), 260 deletions(-) diff --git a/src/client/Client.test.ts b/src/client/Client.test.ts index ab961e8..aa7fd5a 100644 --- a/src/client/Client.test.ts +++ b/src/client/Client.test.ts @@ -3,57 +3,40 @@ 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 type * as Local from './Local.js' import * as MemoryClient from './MemoryClient.js' -import type { - Request as RpcRequest, - Response as RpcResponse, - StreamResponse as RpcStreamResponse, -} from './Rpc.js' import * as HttpTransport from './transports/HttpTransport.js' -import type * as MemoryTransport from './transports/MemoryTransport.js' - -function mockTransport(): HttpTransport.HttpTransport { - return () => ({ - config: { key: 'mock', name: 'Mock', type: 'http' as const }, - baseUrl: new URL('https://example.com'), - discover: vi.fn(), - request: vi.fn( - async (_request: RpcRequest): Promise => ({ - ok: true, - data: { ok: true }, - meta: { command: 'status', duration: '1ms' }, - }), - ), - }) -} describe('Client.create', () => { test('resolves the transport factory exactly once and keeps resolved capabilities', async () => { - const request = vi.fn( - async (_request: RpcRequest): Promise => ({ - ok: true, - data: { ok: true }, - meta: { command: 'status', duration: '1ms' }, - }), - ) - const discover = vi.fn(async () => ({ contentType: 'text/plain', body: 'help' })) - const transport = vi.fn(() => ({ - config: { key: 'mock', name: 'Mock', type: 'http' as const }, - baseUrl: new URL('https://example.com'), - discover, - request, - })) satisfies HttpTransport.HttpTransport + 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) - expect(client.transport.request).toBe(request) - expect(client.transport.discover).toBe(discover) await client.run('status' as never) await client.help() - expect(request).toHaveBeenCalledTimes(1) - expect(discover).toHaveBeenCalledTimes(1) + 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', () => { @@ -64,18 +47,22 @@ describe('Client.create', () => { expect(() => Client.create({ transport })).toThrow('cannot connect') }) - test('resolves transport, assigns uid, preserves defaults, and binds actions', async () => { - const client = Client.create({ + 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', - transport: mockTransport(), }) expect(client).toMatchObject({ defaults: { outputFormat: 'toon' }, - transport: { key: 'mock', name: 'Mock', type: 'http' }, + transport: { key: 'memory', name: 'Memory', type: 'memory' }, type: 'client', }) - await expect(client.run('status' as never)).resolves.toMatchObject({ + await expect(client.run('status')).resolves.toMatchObject({ ok: true, data: { ok: true }, }) @@ -119,29 +106,21 @@ describe('Client.create', () => { }) test('memory clients merge resource and local methods in shared namespaces', async () => { - const local: Local.Methods = { - skills: { - add: vi.fn(async () => ({ agents: [], paths: [], skills: [] })), - list: vi.fn(async () => ({ skills: [] })), - }, - mcp: { - add: vi.fn(async () => ({ agents: [], command: 'app --mcp' })), + const cli = Cli.create('app').command('status', { + description: 'Show status', + run() { + return { ok: true } }, - } - const transport = (() => ({ - config: { key: 'memory', name: 'Memory', type: 'memory' as const }, - discover: vi.fn(async () => ({ contentType: 'application/json', data: { skills: [] } })), - local, - request: vi.fn(), - })) satisfies MemoryTransport.MemoryTransport - - const client = Client.create({ transport }) + }) + const client = MemoryClient.create(cli) - await expect(client.skills.index()).resolves.toEqual({ skills: [] }) - await expect(client.skills.list()).resolves.toEqual({ skills: [] }) - await expect(client.skills.add()).resolves.toEqual({ agents: [], paths: [], skills: [] }) - await expect(client.mcp.add()).resolves.toEqual({ agents: [], command: 'app --mcp' }) + 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', () => { diff --git a/src/client/HttpClient.test.ts b/src/client/HttpClient.test.ts index 5af51f9..7f61e73 100644 --- a/src/client/HttpClient.test.ts +++ b/src/client/HttpClient.test.ts @@ -64,7 +64,6 @@ describe('HttpClient.create', () => { test('does not expose memory-only local methods', () => { const client = HttpClient.create({ baseUrl: 'https://example.com', - fetch: vi.fn() as unknown as typeof globalThis.fetch, }) expect('add' in client.skills).toBe(false) diff --git a/src/client/actions/ResourcesActions.test.ts b/src/client/actions/ResourcesActions.test.ts index e4bc609..982d6ce 100644 --- a/src/client/actions/ResourcesActions.test.ts +++ b/src/client/actions/ResourcesActions.test.ts @@ -1,78 +1,107 @@ 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 type * as HttpTransport from '../transports/HttpTransport.js' +import * as HttpTransport from '../transports/HttpTransport.js' -function clientWith(discover: (request: Resources.Request) => Promise) { - const transport = (() => ({ - config: { key: 'mock', name: 'Mock', type: 'http' as const }, - baseUrl: new URL('https://example.com'), - discover(request: Resources.Request): Promise { - return discover(request) +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 } }, - request: vi.fn(), - })) satisfies HttpTransport.HttpTransport - return Client.create({ transport }) + }) +} + +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 and preserves structured/text returns', async () => { - const discover = vi.fn(async (request) => { - if (request.resource === 'help') return { contentType: 'text/plain', body: 'help' } - if (request.resource === 'skill') return { contentType: 'text/markdown', body: '# Skill' } - if ( - (request.resource === 'llms' || request.resource === 'llmsFull') && - request.format === 'md' - ) - return { contentType: 'text/markdown', body: '# Manifest' } - if ( - (request.resource === 'llms' || request.resource === 'llmsFull') && - request.format === 'json' - ) - return { contentType: 'application/json', data: { resource: request.resource } } - if ( - (request.resource === 'llms' || request.resource === 'llmsFull') && - request.format === 'jsonl' - ) - return { contentType: 'text/plain', body: JSON.stringify({ resource: request.resource }) } - return { contentType: 'application/json', data: { resource: request.resource } } - }) - const client = clientWith(discover) + test('routes every resources action through HTTP and preserves structured/text returns', async () => { + const { client, requests } = httpClient(createCli()) - await expect(client.llms()).resolves.toEqual({ resource: 'llms' }) - await expect(client.llms({ command: 'project' as never, format: 'md' })).resolves.toBe( - '# Manifest', + 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: 'project' as never, format: 'jsonl' })).resolves.toBe( - '{"resource":"llms"}', + await expect(client.llms({ command: 'status' as never, format: 'jsonl' })).resolves.toContain( + '"name":"status"', ) - await expect(client.llmsFull({ command: 'project' as never })).resolves.toEqual({ - resource: 'llmsFull', + 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' })], }) - await expect(client.schema('project report' as never)).resolves.toEqual({ resource: 'schema' }) - await expect(client.help('project report' as never)).resolves.toBe('help') - await expect(client.openapi()).resolves.toEqual({ resource: 'openapi' }) - await expect(client.skills.index()).resolves.toEqual({ resource: 'skillsIndex' }) - await expect(client.skills.get('deploy')).resolves.toBe('# Skill') - await expect(client.mcp.tools()).resolves.toEqual({ resource: 'mcpTools' }) - expect(discover.mock.calls.map(([request]) => request)).toEqual([ - { resource: 'llms', format: 'json' }, - { resource: 'llms', command: 'project', format: 'md' }, - { resource: 'llms', command: 'project', format: 'jsonl' }, - { resource: 'llmsFull', command: 'project', format: 'json' }, - { resource: 'schema', command: 'project report' }, - { resource: 'help', command: 'project report' }, - { resource: 'openapi' }, - { resource: 'skillsIndex' }, - { resource: 'skill', name: 'deploy' }, - { resource: 'mcpTools' }, + 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 = clientWith( + const client = clientWithDiscover( vi.fn(async () => { throw Object.assign(new Error('Unknown command'), { code: 'COMMAND_NOT_FOUND', diff --git a/src/client/actions/RunActions.test.ts b/src/client/actions/RunActions.test.ts index 389c97a..106f08e 100644 --- a/src/client/actions/RunActions.test.ts +++ b/src/client/actions/RunActions.test.ts @@ -1,7 +1,10 @@ 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, @@ -10,18 +13,67 @@ import type { } from '../Rpc.js' import type * as HttpTransport from '../transports/HttpTransport.js' -function clientWith(request: (request: RpcRequest) => Promise) { - type Commands = { - deploy: { args: {}; options: {}; output: {} } - list: { args: {}; options: {}; output: { page: number } } - report: { args: {}; options: {}; output: {} } - status: { args: {}; options: {}; output: { ok: boolean } } - unblock: { - args: { taskId: string } - options: { dryRun?: boolean | undefined } - output: { unblocked: boolean } - } - } +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'), @@ -30,17 +82,37 @@ function clientWith(request: (request: RpcRequest) => Promise({ - outputFormat: 'toon', - selection: ['items[0]'], - transport, + 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 streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { - type Commands = { - logs: { args: {}; options: {}; output: unknown; stream: true } - } +function mockStreamClient(records: RpcStreamRecord[]) { const transport = (() => ({ config: { key: 'mock', name: 'Mock', type: 'http' as const }, baseUrl: new URL('https://example.com'), @@ -50,30 +122,19 @@ function streamClient(records: RpcStreamRecord[], onReturn = vi.fn()) { stream: true as const, async *records() { const terminal = records.at(-1)! - try { - for (const record of records) yield record - return terminal - } finally { - onReturn() - } + for (const record of records) yield record + return terminal }, } }, })) satisfies HttpTransport.HttpTransport - return Client.create({ transport }) + return Client.create({ transport }) } describe('run action', () => { test('merges defaults with per-call output controls and clears selection with undefined', async () => { - const request = vi.fn( - async (_request: RpcRequest): Promise => ({ - ok: true, - data: { ok: true }, - output: { text: 'ok' }, - meta: { command: 'status', duration: '1ms' }, - }), - ) - const client = clientWith(request) + const client = testClient() + const request = vi.spyOn(client.transport, 'request') await client.run('status', { outputFormat: 'md', @@ -81,7 +142,16 @@ describe('run action', () => { outputTokenLimit: 24, }) - expect(request).toHaveBeenCalledWith({ + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'status', + args: {}, + options: {}, + outputFormat: 'md', + outputTokenLimit: 24, + }), + ) + expect(request.mock.calls[0]?.[0]).toEqual({ command: 'status', args: {}, options: {}, @@ -112,7 +182,7 @@ describe('run action', () => { status: 401, }), ) - const client = clientWith(request) + const client = mockClient(request) await expect(client.run('deploy')).rejects.toMatchObject({ code: 'NOT_AUTHENTICATED', @@ -127,31 +197,22 @@ describe('run action', () => { } catch (error) { expect(error).toBeInstanceOf(ClientError) if (!(error instanceof ClientError)) throw error - expect(error.error).toMatchObject({ code: 'NOT_AUTHENTICATED', message: 'Login required.' }) + 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 request = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - data: { page: 1 }, - output: { text: 'one', nextOffset: 5, tokenCount: 10, tokenLimit: 5, tokenOffset: 0 }, - meta: { command: 'list', duration: '1ms' }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { page: 2 }, - output: { text: 'two', tokenCount: 10, tokenLimit: 5, tokenOffset: 5 }, - meta: { command: 'list', duration: '1ms' }, - }) - const client = clientWith(request) - const result = await client.run('list', { outputTokenLimit: 5 }) + const client = testClient() + const request = vi.spyOn(client.transport, 'request') + const result = await client.run('list', { selection: undefined, outputTokenLimit: 5 }) - expect(result.output).toMatchObject({ text: 'one', tokenCount: 10, tokenLimit: 5 }) - await expect(result.output?.next?.()).resolves.toMatchObject({ data: { page: 2 } }) + 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 }), ) @@ -166,7 +227,7 @@ describe('run action', () => { meta: { command: 'status', duration: '1ms' }, }), ) - const client = clientWith(request) + const client = mockClient(request) await expect(client.run('status')).rejects.toThrow(ClientError) await expect(client.run('status')).rejects.toMatchObject({ @@ -175,32 +236,8 @@ describe('run action', () => { }) test('normalizes CTA metadata and cta.run inherits client defaults only', async () => { - const request = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - data: {}, - meta: { - command: 'report', - duration: '1ms', - cta: { - commands: [ - { - command: 'unblock', - args: { taskId: 't1' }, - options: { dryRun: true }, - description: 'Unblock task', - }, - ], - }, - }, - }) - .mockResolvedValueOnce({ - ok: true, - data: { unblocked: true }, - meta: { command: 'unblock', duration: '1ms' }, - }) - const client = clientWith(request) + 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] @@ -210,31 +247,26 @@ describe('run action', () => { raw: expect.any(Object), }) if (!cta) throw new Error('expected CTA') - await expect(cta.run()).resolves.toMatchObject({ data: { unblocked: true } }) + await expect(cta.run()).resolves.toMatchObject({ ok: true }) expect(request).toHaveBeenLastCalledWith( - expect.objectContaining({ command: 'unblock', outputFormat: 'toon' }), + 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 request = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - data: {}, - meta: { - command: 'report', - duration: '1ms', - cta: { commands: ['missing'] }, - }, - }) - .mockResolvedValueOnce({ - ok: false, - error: { code: 'COMMAND_NOT_FOUND', message: 'Missing command.' }, - meta: { command: 'missing', duration: '1ms' }, - }) - const client = clientWith(request) - const result = await client.run('report') + 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' }) @@ -243,17 +275,7 @@ describe('run action', () => { describe('stream responses', () => { test('default async iteration yields chunks and final resolves terminal metadata', async () => { - const client = streamClient([ - { type: 'chunk', data: { line: 1 } }, - { type: 'chunk', data: { line: 2 } }, - { - type: 'done', - ok: true, - data: { lines: 2 }, - output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, - meta: { command: 'logs', duration: '2ms' }, - }, - ]) + const client = streamClient() const stream = await client.run('logs') const chunks: unknown[] = [] for await (const chunk of stream as AsyncIterable) chunks.push(chunk) @@ -261,45 +283,35 @@ describe('run action', () => { expect(chunks).toEqual([{ line: 1 }, { line: 2 }]) await expect(stream.final).resolves.toMatchObject({ data: { lines: 2 }, - output: { text: 'lines: 2', format: 'toon', tokenCount: 2 }, + output: { format: 'toon' }, meta: { command: 'logs' }, }) }) test('records yields terminal errors without throwing, while iteration and final throw', async () => { - const terminal = { - type: 'error' as const, - ok: false as const, - error: { code: 'DISCONNECTED', message: 'Disconnected.' }, - meta: { command: 'logs', duration: '2ms' }, - } - const recordsStream = await streamClient([{ type: 'chunk', data: 1 }, terminal]).run('logs') + 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 streamClient([{ type: 'chunk', data: 1 }, terminal]).run('logs') - await expect(async () => { - for await (const _ of iterStream as AsyncIterable) { - } - }).rejects.toThrow(ClientError) + const iterStream = await failingStreamClient().run('logs') + await expect( + (async () => { + for await (const _ of iterStream as AsyncIterable) { + } + })(), + ).rejects.toThrow(ClientError) - const finalStream = await streamClient([terminal]).run('logs') + 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( - [ - { type: 'chunk', data: 1 }, - { type: 'done', ok: true, data: undefined, meta: { command: 'logs', duration: '1ms' } }, - ], - onReturn, - ).run('logs') + const stream = await streamClient(onReturn).run('logs') const iterator = stream[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: 1 }) + await expect(iterator.next()).resolves.toMatchObject({ value: { line: 1 } }) expect(() => stream.records()).toThrow(ClientError) await iterator.return?.() expect(onReturn).toHaveBeenCalled() From 513d3f5050fd86b7d0544c5600f12e5aaafa7445 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:32:55 +0200 Subject: [PATCH 19/21] refactor: remove useless AnyCli abstraction --- src/client/MemoryClient.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/client/MemoryClient.ts b/src/client/MemoryClient.ts index 0e13e4e..25311a7 100644 --- a/src/client/MemoryClient.ts +++ b/src/client/MemoryClient.ts @@ -2,8 +2,6 @@ import type * as Cli from '../Cli.js' import * as Client from './Client.js' import * as MemoryTransport from './transports/MemoryTransport.js' -type AnyCli = Cli.Cli - /** Memory client instance. */ export type MemoryClient< commands = Client.Commands, @@ -23,11 +21,11 @@ export function create< const commands = Client.Commands, const defaults extends Client.Defaults = {}, >( - cli: AnyCli, + cli: Cli.Cli, options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined, ): MemoryClient export function create( - cli: AnyCli, + cli: Cli.Cli, options: MemoryTransport.Options & Client.Defaults = {}, ): MemoryClient { const { env, ...defaults } = options From b441dc71d536f514af11d8b872776cf60ca2625e Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:36:42 +0200 Subject: [PATCH 20/21] refactor: refine MemoryClient overloads --- src/client/MemoryClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/MemoryClient.ts b/src/client/MemoryClient.ts index 25311a7..7177b8f 100644 --- a/src/client/MemoryClient.ts +++ b/src/client/MemoryClient.ts @@ -10,15 +10,15 @@ export type MemoryClient< /** Creates a memory typed client and infers commands from a concrete CLI. */ export function create< - const commands extends Cli.CommandsMap, + const inferredCommands extends Cli.CommandsMap, const defaults extends Client.Defaults = {}, >( - cli: Cli.Cli, + cli: Cli.Cli, options?: (MemoryTransport.Options & defaults & Client.Defaults) | undefined, -): MemoryClient +): MemoryClient /** Creates a memory typed client with an explicit command map. */ export function create< - const commands = Client.Commands, + const commands extends Client.CommandsMap = Client.Commands, const defaults extends Client.Defaults = {}, >( cli: Cli.Cli, From 2cc49f123dbdd32ce29ae54a878e2959ca366ab8 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 15:28:50 +0200 Subject: [PATCH 21/21] docs: add TypeScript client skill --- README.md | 185 ++++++- SKILL.md | 36 +- package.json | 1 + skills/incur-typescript-client/SKILL.md | 701 ++++++++++++++++++++++++ src/bin.ts | 2 +- 5 files changed, 921 insertions(+), 4 deletions(-) create mode 100644 skills/incur-typescript-client/SKILL.md diff --git a/README.md b/README.md index 3f6fac5..97a5324 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

- Features · Quickprompt · Install · Usage · Walkthrough · License + Features · Quickprompt · Install · Usage · TypeScript Client · Walkthrough · License

## Features @@ -411,6 +411,189 @@ POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "user Non-`/mcp` paths continue routing to the command API as usual. +## TypeScript Client + +Use the TypeScript client when another TypeScript program needs to call an incur CLI with typed commands, structured data, streaming, CTAs, and discovery resources. Use the CLI directly for shell workflows, Skills for agent discovery, and MCP when the caller is an MCP-capable agent. + +### Generate Command Types + +Export the CLI instance from your entrypoint: + +```ts +import { Cli, z } from 'incur' + +const cli = Cli.create('acme', { + description: 'Acme operations CLI', +}).command('project status', { + args: z.object({ projectId: z.string() }), + output: z.object({ status: z.enum(['ok', 'blocked']) }), + run(c) { + return { status: 'ok' as const } + }, +}) + +cli.serve() + +export default cli +``` + +Generate the command map: + +```sh +npx incur gen --entry ./src/cli.ts --output ./src/incur.generated.ts +``` + +Import the generated type where you create clients: + +```ts +import { HttpClient, MemoryClient } from 'incur/client' +import type { Commands } from './incur.generated.js' +``` + +`incur gen` also augments `incur` and `incur/client`, so clients can use registered command types without explicit generics after the generated file is included by TypeScript. + +### HTTP Client + +Serve the CLI with `cli.fetch`, then call it with `HttpClient.create()`: + +```ts +import { HttpClient } from 'incur/client' +import type { Commands } from './incur.generated.js' + +const client = HttpClient.create({ + baseUrl: 'https://ops.acme.test', + headers: { authorization: `Bearer ${token}` }, + outputFormat: 'toon', +}) + +const status = await client.run('project status', { + args: { projectId: 'proj_web_2026' }, +}) + +status.data.status +// ^? 'ok' | 'blocked' +``` + +`HttpClient` talks to the served CLI's `/_incur/rpc` endpoint for command runs and `/_incur/*` resource endpoints for discovery. You normally should not call those lower-level endpoints directly. + +### Memory Client + +Use `MemoryClient.create(cli)` for in-process callers, tests, local automation, and tools that need local-only actions: + +```ts +import { MemoryClient } from 'incur/client' +import cli from './cli.js' + +const client = MemoryClient.create(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, +}) + +const result = await client.run('project status', { + args: { projectId: 'proj_web_2026' }, +}) +``` + +Memory clients infer commands directly from a concrete CLI. They also expose filesystem actions that HTTP clients intentionally do not expose: + +```ts +await client.skills.list() +await client.skills.add({ global: true }) +await client.mcp.add({ agents: ['codex'] }) +``` + +### Running Commands + +`client.run(command, input)` mirrors CLI invocation: + +```ts +const report = await client.run('project report', { + args: { projectId: 'proj_web_2026' }, + options: { includeClosed: false }, + selection: ['summary', 'items[0:3]', 'nextCursor'], + outputFormat: 'md', + outputTokenCount: true, + outputTokenLimit: 128, +}) +``` + +The result contains typed structured data, optional rendered output text, and metadata: + +```ts +report.ok +report.data +report.output?.text +report.output?.next +report.meta.cta +``` + +`selection` is equivalent to `--filter-output`. Because it changes the shape of `data`, selected results are typed as `unknown`. Pass `selection: undefined` on a call to clear a client-level default and recover the full output type. + +### Streaming + +Commands implemented with `async *run` return a stream wrapper: + +```ts +const stream = await client.run('logs tail', { + args: { service: 'checkout-api' }, +}) + +for await (const line of stream) { + console.log(line) +} + +const final = await stream.final +``` + +Use `stream.records()` when you need raw chunk, done, and error records. A stream can be consumed once: either chunks, records, or final-only consumption. + +### CTAs and Errors + +CTAs returned by commands are runnable from the client: + +```ts +const cta = report.meta.cta?.commands[0] +if (cta) { + console.log(cta.cliCommand) + const next = await cta.run({ outputFormat: 'toon' }) +} +``` + +Failed command runs throw `Client.ClientError`: + +```ts +import { Client } from 'incur/client' + +try { + await client.run('project deploy', { + args: { projectId: 'proj_web_2026' }, + options: { environment: 'production' }, + }) +} catch (error) { + if (error instanceof Client.ClientError) { + console.error(error.code, error.status, error.retryable) + console.error(error.meta?.cta) + } +} +``` + +### Discovery Resources + +Clients can read the same discovery surfaces agents use: + +```ts +await client.llms() +await client.llms({ command: 'project', format: 'md' }) +await client.llmsFull() +await client.schema('project report') +await client.help('project report') +await client.openapi() +await client.skills.index() +await client.skills.get('deploy') +await client.mcp.tools() +``` + +Use these resource actions for documentation, SDK tooling, agent setup, tests, and UI generation. Use `client.run()` for actual command execution. + ## Walkthrough ### Agent discovery diff --git a/SKILL.md b/SKILL.md index 6bb33bf..ba1d3ec 100644 --- a/SKILL.md +++ b/SKILL.md @@ -965,13 +965,45 @@ async *run({ ok }) { ## Type Generation -Generate type definitions for your CLI's command map to get typed CTAs: +Generate type definitions for your CLI's command map: ```sh incur gen ``` -This creates a `incur.generated.ts` file that registers your commands on the `Cli.Commands` type, enabling autocomplete on CTA command names, args, and options. +The CLI entrypoint must `export default cli` so `incur gen` can import it. The generated file exports `Commands` and augments both `incur` and `incur/client`, enabling typed CTAs while authoring a CLI and typed TypeScript clients when consuming one. + +```ts +import { HttpClient } from 'incur/client' +import type { Commands } from './incur.generated.js' + +const client = HttpClient.create({ baseUrl: 'https://ops.acme.test' }) +``` + +## TypeScript Client + +Use `incur/client` when TypeScript code needs to consume an incur CLI programmatically. Prefer normal CLI commands for shell workflows, Skills for agent usage, and MCP for MCP-capable agents. + +```ts +import { HttpClient, MemoryClient } from 'incur/client' +import cli from './cli.js' +import type { Commands } from './incur.generated.js' + +const http = HttpClient.create({ + baseUrl: 'https://ops.acme.test', + outputFormat: 'toon', +}) + +const memory = MemoryClient.create(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, +}) + +const result = await http.run('project status', { + args: { projectId: 'proj_web_2026' }, +}) +``` + +Use the dedicated `incur-typescript-client` skill for exhaustive client usage: `HttpClient`, `MemoryClient`, lower-level transports, `client.run`, streaming, CTAs, `ClientError`, discovery resources, and memory-only local actions. ## Full Example diff --git a/package.json b/package.json index 8d7c658..84c94ed 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "examples", "dist", "src", + "skills", "SKILL.md" ], "dependencies": { diff --git a/skills/incur-typescript-client/SKILL.md b/skills/incur-typescript-client/SKILL.md new file mode 100644 index 0000000..0321328 --- /dev/null +++ b/skills/incur-typescript-client/SKILL.md @@ -0,0 +1,701 @@ +--- +name: incur-typescript-client +description: Use when consuming an incur CLI from TypeScript with `incur/client`, including generated command types, `HttpClient`, `MemoryClient`, streaming, CTAs, resources, and client errors. +command: incur +--- + +# incur TypeScript Client + +Use this skill when TypeScript code needs to call an incur CLI programmatically. Use the root `incur` skill when building the CLI itself. Use shell commands, generated Skills, or MCP when the caller is an agent or human operating outside TypeScript. + +The public client API lives in `incur/client`: + +```ts +import { + Client, + HttpClient, + HttpTransport, + Local, + MemoryClient, + MemoryTransport, + Resources, + Run, +} from 'incur/client' +``` + +## Setup + +The client is typed from a command map. Generate it from the CLI entrypoint: + +```ts +// src/cli.ts +import { Cli, z } from 'incur' + +const cli = Cli.create('acme', { + description: 'Acme operations CLI', +}) + .command('project status', { + args: z.object({ projectId: z.string() }), + output: z.object({ status: z.enum(['ok', 'blocked']) }), + run() { + return { status: 'ok' as const } + }, + }) + .command('logs tail', { + args: z.object({ service: z.string() }), + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'ready' } + }, + }) + +cli.serve() + +export default cli +``` + +Run type generation: + +```sh +npx incur gen --entry ./src/cli.ts --output ./src/incur.generated.ts +``` + +The generated file exports `Commands` and augments both `incur` and `incur/client`: + +```ts +import type { Commands } from './incur.generated.js' +``` + +Command IDs are full command paths such as `'project status'` or `'logs tail'`. Command map entries have this shape: + +```ts +type Commands = { + 'project status': { + args: { projectId: string } + options: {} + output: { status: 'ok' | 'blocked' } + } + 'logs tail': { + args: { service: string } + options: {} + output: { line: string } + stream: true + } +} +``` + +## Creating Clients + +Use `HttpClient` for remote or served CLIs. The CLI must be exposed with `cli.fetch` in Bun, Deno, Cloudflare Workers, Hono, Next.js, or another Fetch-compatible runtime. + +```ts +import { HttpClient } from 'incur/client' +import type { Commands } from './incur.generated.js' + +const client = HttpClient.create({ + baseUrl: 'https://ops.acme.test', + // Optional; defaults to globalThis.fetch. + fetch, + // Optional; merged into every request. + headers: { authorization: `Bearer ${token}` }, + // Defaults for every client.run(). Per-call input overrides these. + outputFormat: 'toon', +}) +``` + +Use `MemoryClient` for in-process calls, tests, local automation, and local setup actions: + +```ts +import { MemoryClient } from 'incur/client' +import cli from './cli.js' + +const memoryClient = MemoryClient.create(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, + outputFormat: 'toon', +}) +``` + +`MemoryClient.create(cli)` infers commands from a concrete CLI. You can still provide an explicit command map when needed: + +```ts +const memoryClient = MemoryClient.create(cli) +``` + +Use `Client.create()` and transports only when composing lower-level client infrastructure: + +```ts +const httpViaTransport = Client.create({ + transport: HttpTransport.create({ + baseUrl: 'https://ops.acme.test', + headers: { authorization: `Bearer ${token}` }, + }), + outputFormat: 'toon', +}) + +const memoryViaTransport = Client.create({ + transport: MemoryTransport.create(cli, { + env: { ACME_TOKEN: 'dev_secret_123' }, + }), +}) +``` + +## Running Commands + +`client.run(command, input)` mirrors a CLI invocation. `args` are positional arguments, `options` are named flags, and output controls mirror global CLI flags. + +```ts +const report = await client.run('project report', { + args: { projectId: 'proj_web_2026' }, + options: { includeClosed: false }, + + // Equivalent to --filter-output. This changes result.data, so data is typed unknown. + selection: ['summary', 'items[0:3]', 'nextCursor'], + + // These affect rendered result.output.text, not the server's original full output. + outputFormat: 'md', + outputTokenCount: true, + outputTokenLimit: 128, +}) +``` + +The returned value for non-streaming commands is `Run.Result`: + +```ts +console.log(report) +/// Run.Result +// { +// ok: true, +// data: { +// summary: 'Website refresh is on track', +// items: [ +// { id: 'task_1', title: 'Finalize copy', status: 'done' }, +// { id: 'task_2', title: 'QA checkout flow', status: 'blocked' }, +// { id: 'task_3', title: 'Publish launch checklist', status: 'open' }, +// ], +// nextCursor: 'task_4', +// }, +// output: { +// text: '## Website refresh is on track\n\n- done: Finalize copy\n- blocked: QA checkout flow', +// format: 'md', +// tokenCount: 37, +// tokenLimit: 128, +// tokenOffset: 0, +// next: [Function], +// }, +// meta: { +// command: 'project report', +// duration: '18ms', +// cta: { +// commands: [ +// { +// command: 'project unblock', +// cliCommand: 'project unblock task_2', +// description: 'Unblock the blocked checkout QA task.', +// args: { taskId: 'task_2' }, +// options: {}, +// raw: { command: 'project unblock', args: { taskId: 'task_2' } }, +// run: [Function], +// }, +// ], +// }, +// }, +// } +``` + +Because `selection` changes the shape of `data`, selected results are typed as `unknown`. + +If `output.next` exists, fetch the next rendered output page for the same command: + +```ts +const nextPage = await report.output?.next?.() + +console.log(nextPage) +/// Run.Result | undefined +// { +// ok: true, +// data: { ... }, +// output: { +// text: '- open: Publish launch checklist', +// format: 'md', +// tokenCount: 37, +// tokenLimit: 128, +// tokenOffset: 128, +// }, +// meta: { command: 'project report', duration: '12ms' }, +// } +``` + +Input is strict. Required `args` and `options` make the input object required; unknown commands and extra keys are rejected by TypeScript when the command map is known. + +```ts +await client.run('project status', { + args: { projectId: 'proj_web_2026' }, +}) + +// Type error: unknown command. +await client.run('project missing') + +// Type error: missing required args. +await client.run('project status') +``` + +If the client has a default `selection`, result data is conservative `unknown`. Clear it for a call with `selection: undefined` to recover the full output type: + +```ts +const selectedClient = HttpClient.create({ + baseUrl: 'https://ops.acme.test', + selection: ['summary'], +}) + +const selected = await selectedClient.run('project report', { + args: { projectId: 'proj_web_2026' }, +}) +// selected.data is unknown + +const full = await selectedClient.run('project report', { + args: { projectId: 'proj_web_2026' }, + selection: undefined, +}) + +console.log(full) +/// Run.Result +// { +// ok: true, +// data: { +// summary: 'Website refresh is on track', +// items: [ +// { id: 'task_1', title: 'Finalize copy', status: 'done' }, +// { id: 'task_2', title: 'QA checkout flow', status: 'blocked' }, +// { id: 'task_3', title: 'Publish launch checklist', status: 'open' }, +// ], +// nextCursor: 'task_4', +// }, +// output: { +// text: 'summary: Website refresh is on track\nitems[3]{id,title,status}: ...', +// format: 'toon', +// }, +// meta: { command: 'project report', duration: '18ms' }, +// } +``` + +## CTAs + +Commands can return CTAs in `meta.cta`. Client CTAs are runnable: + +```ts +const cta = report.meta.cta?.commands[0] + +console.log(cta) +/// Run.Cta | undefined +// { +// command: 'project unblock', +// cliCommand: 'project unblock task_2', +// description: 'Unblock the blocked checkout QA task.', +// args: { taskId: 'task_2' }, +// options: {}, +// raw: { +// command: 'project unblock', +// args: { taskId: 'task_2' }, +// options: {}, +// description: 'Unblock the blocked checkout QA task.', +// }, +// run: [Function], +// } + +if (cta) { + const result = await cta.run({ + outputFormat: 'toon', + }) + + console.log(result) + /// Run.Result + // { + // ok: true, + // data: { unblocked: true, taskId: 'task_2' }, + // output: { + // text: 'unblocked: true\ntaskId: task_2', + // format: 'toon', + // }, + // meta: { command: 'project unblock', duration: '14ms' }, + // } +} +``` + +CTA `run()` does not inherit output controls from the original command result. Pass the controls you want for the CTA run. + +CTA objects have `command`, `cliCommand`, optional `description`, `args`, `options`, `raw`, and `run()`. Do not check for a `runnable` property. + +## Errors + +Failed command runs and malformed client responses throw `Client.ClientError`: + +```ts +import { Client } from 'incur/client' + +try { + await client.run('project deploy', { + args: { projectId: 'proj_web_2026' }, + options: { environment: 'production' }, + }) +} catch (error) { + if (error instanceof Client.ClientError) { + console.log(error) + /// Client.ClientError + // Incur.ClientError: Login required before deploying. + // { + // message: 'Login required before deploying.', + // code: 'NOT_AUTHENTICATED', + // status: 401, + // retryable: false, + // fieldErrors: undefined, + // meta: { + // command: 'project deploy', + // duration: '4ms', + // cta: { + // description: 'Authenticate before deploying.', + // commands: [ + // { + // command: 'auth login', + // cliCommand: 'auth login', + // description: 'Log in to Acme.', + // args: {}, + // options: {}, + // raw: { command: 'auth login', description: 'Log in to Acme.' }, + // run: [Function], + // }, + // ], + // }, + // }, + // error: { + // code: 'NOT_AUTHENTICATED', + // message: 'Login required before deploying.', + // retryable: false, + // }, + // data: { + // ok: false, + // error: { + // code: 'NOT_AUTHENTICATED', + // message: 'Login required before deploying.', + // retryable: false, + // }, + // meta: { + // command: 'project deploy', + // duration: '4ms', + // cta: { ... }, + // }, + // }, + // } + } +} +``` + +## Streaming + +Commands implemented with `async *run` return `Run.StreamResponse`. + +```ts +const stream = await client.run('logs tail', { + args: { service: 'checkout-api' }, +}) + +for await (const chunk of stream) { + console.log(chunk) + /// LogLine + // { + // timestamp: '2026-05-24T10:15:00Z', + // level: 'info', + // message: 'request completed', + // } +} + +const final = await stream.final + +console.log(final) +/// Run.StreamFinal +// { +// ok: true, +// data: { lines: 124 }, +// output: { +// text: 'lines: 124', +// format: 'toon', +// }, +// meta: { +// command: 'logs tail', +// duration: '30s', +// }, +// } +``` + +Use `records()` when you need every stream record, including terminal error records: + +```ts +const rawStream = await client.run('logs tail', { + args: { service: 'checkout-api' }, +}) + +for await (const record of rawStream.records()) { + if (record.type === 'chunk') { + console.log(record) + /// Extract, { type: 'chunk' }> + // { + // type: 'chunk', + // data: { + // timestamp: '2026-05-24T10:15:00Z', + // level: 'info', + // message: 'request completed', + // }, + // output: { + // text: 'timestamp: 2026-05-24T10:15:00Z\nlevel: info\nmessage: request completed', + // format: 'toon', + // }, + // } + } + + if (record.type === 'done') { + console.log(record) + /// Extract, { type: 'done' }> + // { + // type: 'done', + // ok: true, + // data: { lines: 124 }, + // output: { text: 'lines: 124', format: 'toon' }, + // meta: { command: 'logs tail', duration: '30s' }, + // } + } + + if (record.type === 'error') { + console.log(record) + /// Extract, { type: 'error' }> + // { + // type: 'error', + // ok: false, + // error: { + // code: 'LOG_STREAM_DISCONNECTED', + // message: 'Log stream disconnected.', + // retryable: true, + // }, + // meta: { command: 'logs tail', duration: '30s' }, + // } + } +} +``` + +A stream can only be consumed once: use async iteration, `.records()`, or `.final` as the consumption mode. Streaming commands allow `selection` and `outputFormat`, but reject token pagination controls such as `outputTokenLimit`. + +## Discovery Resources + +Resource actions are read-only and available on both HTTP and memory clients: + +```ts +const llms = await client.llms() +const llmsMd = await client.llms({ command: 'project', format: 'md' }) +const full = await client.llmsFull() +const schema = await client.schema('project report') +const help = await client.help('project report') +const openapi = await client.openapi() +const skills = await client.skills.index() +const deploySkill = await client.skills.get('deploy') +const tools = await client.mcp.tools() + +console.log(llms) +/// Resources.LlmsManifest +// { +// version: 'incur.v1', +// commands: [ +// { +// name: 'project report', +// description: 'Summarize project progress.', +// }, +// { +// name: 'project status', +// description: 'Show project status.', +// }, +// ], +// } + +console.log(llmsMd) +/// string +// '# acme project\n\n| Command | Description |\n|---------|-------------|\n| `acme project report ` | Summarize project progress. |' + +console.log(full) +/// Resources.LlmsFullManifest +// { +// version: 'incur.v1', +// commands: [ +// { +// name: 'project report', +// description: 'Summarize project progress.', +// schema: { +// args: { +// type: 'object', +// required: ['projectId'], +// properties: { projectId: { type: 'string' } }, +// }, +// options: { +// type: 'object', +// properties: { includeClosed: { type: 'boolean' } }, +// }, +// output: { +// type: 'object', +// properties: { summary: { type: 'string' } }, +// }, +// }, +// }, +// ], +// } + +console.log(schema) +/// Resources.CommandSchema +// { +// args: { +// type: 'object', +// required: ['projectId'], +// properties: { projectId: { type: 'string' } }, +// }, +// options: { +// type: 'object', +// properties: { includeClosed: { type: 'boolean' } }, +// }, +// output: { +// type: 'object', +// properties: { summary: { type: 'string' } }, +// }, +// } + +console.log(help) +/// string +// 'Usage: acme project report [--include-closed]\n\nSummarize project progress.' + +console.log(openapi) +/// Resources.OpenApiDocument +// { +// openapi: '3.1.0', +// info: { title: 'acme', version: '1.0.0' }, +// paths: { ... }, +// } + +console.log(skills) +/// Resources.SkillsIndex +// { +// skills: [ +// { +// name: 'acme-project', +// description: 'Project commands. Run `acme project --help` for usage details.', +// files: ['SKILL.md'], +// }, +// ], +// } + +console.log(deploySkill) +/// string +// '---\nname: acme-deploy\ndescription: Deploy safely. Run `acme deploy --help` for usage details.\n---\n\n# acme deploy\n\nDeploy safely.' + +console.log(tools) +/// Resources.McpToolsResponse +// { +// tools: [ +// { +// name: 'project_report', +// description: 'Summarize project progress.', +// inputSchema: { +// type: 'object', +// properties: { +// projectId: { type: 'string' }, +// includeClosed: { type: 'boolean' }, +// }, +// required: ['projectId'], +// }, +// outputSchema: { +// type: 'object', +// properties: { summary: { type: 'string' } }, +// }, +// }, +// ], +// } +``` + +`llms()` and `llmsFull()` return structured data by default. Passing a non-JSON `format` returns a string. + +Use command-group scopes where accepted: + +```ts +await client.llmsFull({ command: 'project' }) +await client.schema('project') +await client.help('project report') +``` + +Use discovery resources for docs, SDK tooling, UI generation, tests, and agent setup. Use `client.run()` for command execution. + +## Memory-Only Local Actions + +Memory clients expose local setup actions that HTTP clients do not expose: + +```ts +const localSkills = await memoryClient.skills.list() + +const syncedSkills = await memoryClient.skills.add({ + depth: 1, + global: true, +}) + +const mcpRegistration = await memoryClient.mcp.add({ + agents: ['codex'], +}) + +console.log(localSkills) +/// Local.SkillsList +// { +// skills: [ +// { +// name: 'acme-project', +// description: 'Project commands. Run `acme project --help` for usage details.', +// installed: false, +// }, +// ], +// } + +console.log(syncedSkills) +/// Local.SyncedSkills +// { +// skills: [ +// { +// name: 'acme-project', +// description: 'Project commands. Run `acme project --help` for usage details.', +// }, +// ], +// paths: ['/Users/alice/.config/agents/skills/acme-project'], +// agents: [ +// { +// agent: 'Codex', +// path: '/Users/alice/.codex/skills/acme-project', +// }, +// ], +// } + +console.log(mcpRegistration) +/// Local.McpRegistration +// { +// command: 'acme --mcp', +// agents: [ +// { +// agent: 'Codex', +// path: '/Users/alice/.codex/config.toml', +// }, +// ], +// } +``` + +These actions modify local agent configuration or local skill files. They are intentionally unavailable over HTTP, RPC, and MCP. + +```ts +// Type error: HTTP clients do not expose local actions. +client.skills.add() +``` + +## Lower-Level Notes + +Most code should use `HttpClient.create`, `MemoryClient.create`, and `client.run`. Reach for `Client.create` and transport factories when building reusable infrastructure around transports. + +HTTP clients call `/_incur/rpc` for command execution and `/_incur/*` discovery endpoints for resources. Memory clients call the CLI in-process. + +Fetch gateway commands mounted with `.command('api', { fetch })` are not part of the structured generated command map and cannot be called through typed structured RPC as ordinary commands. Call the served Fetch API routes directly for gateway routes. diff --git a/src/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', {