From 85d238a363baf6d6b3024c527f43e1f997c2f5e9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Fri, 22 May 2026 15:39:08 +0200 Subject: [PATCH 01/55] fix: normalize object validation errors --- .changeset/quiet-walls-share.md | 5 +++ src/Cli.test.ts | 70 +++++++++++++++++++++++++++++++++ src/Parser.ts | 2 +- src/internal/command.ts | 6 +-- 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-walls-share.md diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md new file mode 100644 index 0000000..7d5598c --- /dev/null +++ b/.changeset/quiet-walls-share.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index bd55fc6..86cd794 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import * as Command from './internal/command.js' + const originalIsTTY = process.stdout.isTTY beforeAll(() => { ;(process.stdout as any).isTTY = false @@ -4051,6 +4053,74 @@ describe('--filter-output', () => { }) }) +describe('Command.execute', () => { + test.each([ + { + name: 'split', + command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { name: 123 }, + path: 'name', + parseMode: 'split' as const, + }, + { + name: 'flat', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { id: 123 }, + path: 'id', + parseMode: 'flat' as const, + }, + ])('$name mode returns validation fieldErrors for invalid command input', async (c) => { + const result = await Command.execute(c.command, { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: c.inputOptions, + name: 'test', + parseMode: c.parseMode, + path: 'users', + version: undefined, + }) + + expect(result).toMatchObject({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + missing: false, + path: c.path, + }, + ], + }, + }) + }) + + test('does not normalize handler-thrown Zod errors as command input', async () => { + const result = await Command.execute( + { + run() { + z.object({ name: z.string() }).parse({ name: 123 }) + }, + }, + { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: {}, + name: 'test', + path: 'users', + version: undefined, + }, + ) + + expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } }) + expect(result).not.toHaveProperty('error.fieldErrors') + }) +}) + async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() diff --git a/src/Parser.ts b/src/Parser.ts index ea21a75..3a601ad 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -257,7 +257,7 @@ function setOption( } /** Wraps zod schema.parse(), converting ZodError to ValidationError. */ -function zodParse(schema: z.ZodObject, data: Record) { +export function zodParse(schema: z.ZodObject, data: Record) { try { return schema.parse(data) } catch (err: any) { diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..c1103b9 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -81,12 +81,12 @@ export async function execute(command: any, options: execute.Options): Promise Date: Tue, 26 May 2026 13:35:27 +0200 Subject: [PATCH 02/55] fix(cli): preserve stream terminal records --- src/Cli.test.ts | 437 +++++++++++++++++++++++++++++++++++++--- src/Cli.ts | 71 +++++-- src/e2e.test.ts | 3 + src/internal/command.ts | 2 +- 4 files changed, 471 insertions(+), 42 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 86cd794..81fe966 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3656,6 +3656,57 @@ test('streaming: generator throws in buffered mode', async () => { expect(output).toContain('generator exploded') }) +test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => { + const cli = Cli.create('test') + cli.command('limited', { + async *run() { + yield { step: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + + const jsonl = await serve(cli, ['limited', '--format', 'jsonl']) + const jsonlLines = jsonl.output + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(jsonl.exitCode).toBe(1) + expect(jsonlLines[1]).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "ok": false, + "type": "error", + } + `) + + const json = await serve(cli, ['limited', '--full-output', '--format', 'json']) + const body = JSON.parse(json.output) + body.meta.duration = '' + expect(json.exitCode).toBe(1) + expect(body).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "limited", + "duration": "", + }, + "ok": false, + } + `) +}) + test('streaming: generator returns error in buffered mode', async () => { const cli = Cli.create('test') cli.command('fail', { @@ -4128,6 +4179,20 @@ async function fetchJson(cli: Cli.Cli, req: Request) { return { status: res.status, body } } +async function fetchNdjson(cli: Cli.Cli, req: Request) { + const res = await cli.fetch(req) + const lines = (await res.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + for (const line of lines) + if (line.meta?.duration) { + expect(line.meta.duration).toMatch(/^\d+ms$/) + line.meta.duration = '' + } + return { status: res.status, contentType: res.headers.get('content-type'), lines } +} + describe('fetch', () => { test('GET /health → 200', async () => { const cli = Cli.create('test') @@ -4362,36 +4427,356 @@ describe('fetch', () => { return { done: true } }, }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "data": { + "progress": 2, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response preserves returned ok CTA through middleware', async () => { + const cli = Cli.create('test') + cli.use(async (_c, next) => { + await next() + }) + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "cta": { + "commands": [ + { + "command": "test next", + }, + ], + "description": "Next steps:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response handles terminal-only sentinel returns through middleware', async () => { + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (c, next) => { + order.push(`before:${c.command}`) + await next() + order.push(`after:${c.command}`) + }) + const sub = Cli.create('ops') + sub.command('ok', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.ok( + { ignored: true }, + { cta: { commands: [{ command: 'next', description: 'Continue' }] } }, + ) + }, + }) + sub.command('fail', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.error({ + code: 'EMPTY_FAIL', + cta: { commands: ['retry'], description: 'Recover with:' }, + message: 'failed before chunks', + retryable: true, + }) + }, + }) + cli.command(sub) + + const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok')) + expect(ok).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "meta": { + "command": "ops ok", + "cta": { + "commands": [ + { + "command": "test next", + "description": "Continue", + }, + ], + "description": "Suggested command:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + expect(ok.lines[0]).not.toHaveProperty('data') + + expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "error": { + "code": "EMPTY_FAIL", + "message": "failed before chunks", + "retryable": true, + }, + "meta": { + "command": "ops fail", + "cta": { + "commands": [ + { + "command": "test retry", + }, + ], + "description": "Recover with:", + }, + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail']) + }) + + test('streaming response represents returned error as terminal error', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed late", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response represents yielded error as terminal error', async () => { + let closed = false + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + try { + yield { progress: 1 } + yield c.error({ code: 'STREAM_FAIL', message: 'failed now' }) + yield { progress: 2 } + } finally { + closed = true + } + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed now", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(closed).toBe(true) + }) + + test('streaming response cancellation unwinds generator and middleware', async () => { + let resolveAfter = () => {} + const after = new Promise((resolve) => { + resolveAfter = resolve + }) + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (_c, next) => { + order.push('mw:before') + await next() + order.push('mw:after') + resolveAfter() + }) + cli.command('stream', { + async *run() { + try { + order.push('stream:yield') + yield { progress: 1 } + while (true) yield { progress: 2 } + } finally { + order.push('stream:finally') + } + }, + }) const res = await cli.fetch(new Request('http://localhost/stream')) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/x-ndjson') - const text = await res.text() - const lines = text - .trim() - .split('\n') - .map((l) => JSON.parse(l)) - expect(lines).toMatchInlineSnapshot(` - [ - { - "data": { - "progress": 1, + const reader = res.body!.getReader() + await reader.read() + await reader.cancel() + await after + expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after']) + }) + + test('streaming response thrown error includes terminal duration metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Error('boom') + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "type": "chunk", - }, - { - "data": { - "progress": 2, + { + "error": { + "code": "UNKNOWN", + "message": "boom", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", }, - "type": "chunk", - }, - { - "meta": { - "command": "stream", + ], + "status": 200, + } + `) + }) + + test('streaming response thrown IncurError preserves code and retryable metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "ok": true, - "type": "done", - }, - ] + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } `) }) diff --git a/src/Cli.ts b/src/Cli.ts index d8efef9..ec314ac 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1854,24 +1854,61 @@ async function executeCommand( // Streaming path — async generator → NDJSON response if ('stream' in result) { + const iterator = result.stream + const encoder = new TextEncoder() + const meta = (cta?: FormattedCtaBlock | undefined) => ({ + command: path, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + }) + const errorRecord = (err: ErrorResult) => ({ + type: 'error', + ok: false, + error: { + code: err.code, + message: err.message, + ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined), + }, + meta: meta(formatCtaBlock(options.name ?? path, err.cta)), + }) const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() + async cancel() { + await iterator.return(undefined) + }, + async pull(controller) { try { - for await (const value of result.stream) { + const { value, done } = await iterator.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + controller.close() + return + } + const cta = + isSentinel(value) && value[sentinel] === 'ok' + ? formatCtaBlock(options.name ?? path, value.cta) + : undefined controller.enqueue( - encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'), + encoder.encode( + JSON.stringify({ + type: 'done', + ok: true, + meta: meta(cta), + }) + '\n', + ), ) + controller.close() + return } - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'done', - ok: true, - meta: { command: path }, - }) + '\n', - ), - ) + + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + await iterator.return(undefined) + controller.close() + return + } + + controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n')) } catch (error) { controller.enqueue( encoder.encode( @@ -1879,14 +1916,16 @@ async function executeCommand( type: 'error', ok: false, error: { - code: 'UNKNOWN', + code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, + meta: meta(), }) + '\n', ), ) + controller.close() } - controller.close() }, }) return new Response(stream, { @@ -2719,6 +2758,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, }), ) @@ -2802,6 +2842,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, meta: { command: ctx.path, diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 61bbb4d..4cd126c 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -2833,6 +2833,8 @@ describe('fetch api', () => { .trim() .split('\n') .map((l) => JSON.parse(l)) + expect(lines[2].meta.duration).toMatch(/^\d+ms$/) + lines[2].meta.duration = '' expect(lines).toMatchInlineSnapshot(` [ { @@ -2850,6 +2852,7 @@ describe('fetch api', () => { { "meta": { "command": "stream", + "duration": "", }, "ok": true, "type": "done", diff --git a/src/internal/command.ts b/src/internal/command.ts index c1103b9..c8f7f08 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -128,7 +128,7 @@ export async function execute(command: any, options: execute.Options): Promise + return yield* raw as AsyncGenerator } finally { resolveStreamConsumed!() } From ea85131bbb7cd612e7715c644171aff0f3cbd4d4 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 19:23:27 +0200 Subject: [PATCH 03/55] fix: add stream terminal changeset --- .changeset/sour-dingos-shine.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/sour-dingos-shine.md diff --git a/.changeset/sour-dingos-shine.md b/.changeset/sour-dingos-shine.md new file mode 100644 index 0000000..9fefa90 --- /dev/null +++ b/.changeset/sour-dingos-shine.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed streaming command terminal records so HTTP NDJSON responses preserve returned `c.ok()` CTA metadata, represent returned or yielded `c.error()` values as terminal errors, include terminal duration metadata, and unwind generators on response cancellation. + +Also preserves `IncurError.retryable` metadata in streaming machine-format errors. From 3d8b4ca3b4c2471b75fad7a01302a654b793d256 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 18:46:48 +0200 Subject: [PATCH 04/55] fix: reuse cli skill projection --- src/Cli.ts | 21 ++++++++++- src/Skillgen.test.ts | 22 ++++++++--- src/Skillgen.ts | 41 +++----------------- src/SyncSkills.test.ts | 40 +++++++++++++++++++- src/SyncSkills.ts | 86 ++++-------------------------------------- 5 files changed, 88 insertions(+), 122 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index ec314ac..82a54d0 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -2966,11 +2966,11 @@ function collectCommands( } /** @internal Recursively collects leaf commands as `Skill.CommandInfo` for `--llms --format md`. */ -function collectSkillCommands( +export function collectSkillCommands( commands: Map, prefix: string[], groups: Map, - rootCommand?: CommandDefinition | undefined, + rootCommand?: SkillCommandSource | undefined, ): Skill.CommandInfo[] { const result: Skill.CommandInfo[] = [] if (rootCommand) { @@ -3018,6 +3018,11 @@ function collectSkillCommands( return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) } +type SkillCommandSource = Pick< + CommandDefinition, + 'args' | 'description' | 'env' | 'examples' | 'hint' | 'options' | 'output' +> + /** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */ export function formatExamples( examples: Example[] | undefined, @@ -3034,6 +3039,18 @@ export function formatExamples( }) } +/** @internal Parses YAML frontmatter from generated skill Markdown. */ +export function parseSkillFrontmatter(content: string): { + description?: string | undefined + name?: string | undefined +} { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return {} + const meta = yamlParse(match[1]!) + if (!meta || typeof meta !== 'object') return {} + return meta as { description?: string | undefined; name?: string | undefined } +} + /** @internal Builds separate args, env, and options JSON Schemas. */ function buildInputSchema( args: z.ZodObject | undefined, diff --git a/src/Skillgen.test.ts b/src/Skillgen.test.ts index 49ae419..6e1df8f 100644 --- a/src/Skillgen.test.ts +++ b/src/Skillgen.test.ts @@ -60,13 +60,20 @@ test('collects group descriptions', async () => { test('includes args, options, and examples in output', async () => { const cli = Cli.create('tool', { description: 'A tool', - }).command('greet', { - description: 'Greet someone', - args: z.object({ name: z.string().describe('Name to greet') }), - options: z.object({ loud: z.boolean().default(false).describe('Shout') }), - examples: [{ args: { name: 'world' }, description: 'Greet the world' }], - run: () => ({}), }) + .command('greet', { + description: 'Greet someone', + aliases: ['hi'], + args: z.object({ name: z.string().describe('Name to greet') }), + options: z.object({ loud: z.boolean().default(false).describe('Shout') }), + output: z.object({ message: z.string() }), + examples: [{ args: { name: 'world' }, description: 'Greet the world' }], + run: () => ({ message: 'hi' }), + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) vi.mocked(importCli).mockResolvedValue(cli) const files = await generate('fake-input', tmp, 0) @@ -74,4 +81,7 @@ test('includes args, options, and examples in output', async () => { expect(content).toContain('Name to greet') expect(content).toContain('Shout') expect(content).toContain('Greet the world') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toContain('# tool hi') }) diff --git a/src/Skillgen.ts b/src/Skillgen.ts index 844e52c..3dd2e73 100644 --- a/src/Skillgen.ts +++ b/src/Skillgen.ts @@ -13,7 +13,12 @@ export async function generate(input: string, output: string, depth = 1): Promis const groups = new Map() if (cli.description) groups.set(cli.name, cli.description) - const entries = collectEntries(commands, [], groups) + const entries = Cli.collectSkillCommands( + commands, + [], + groups, + Cli.toRootDefinition.get(cli as unknown as Cli.Root), + ) const files = Skill.split(cli.name, entries, depth, groups) if (depth > 0) await fs.rm(output, { recursive: true, force: true }) @@ -30,37 +35,3 @@ export async function generate(input: string, output: string, depth = 1): Promis return written } - -/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(path.join(' '), entry.description) - result.push(...collectEntries(entry.commands, path, groups)) - } else { - const cmd: Skill.CommandInfo = { name: path.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = Cli.formatExamples(entry.examples) - if (examples) { - const cmdName = path.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 530be61..8f64d23 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -1,4 +1,4 @@ -import { Cli, SyncSkills } from 'incur' +import { Cli, SyncSkills, z } from 'incur' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -288,6 +288,44 @@ test('list includes root command skill', async () => { expect(names).toContain('test-ping') }) +test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => { + const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const cli = Cli.create('tool') + .command('real', { + description: 'Real command', + aliases: ['r'], + options: z.object({ dryRun: z.boolean().default(false) }), + output: z.object({ value: z.string() }), + examples: [{ options: { dryRun: true }, description: 'Preview' }], + run: () => ({ value: 'ok' }), + }) + .command('api', { description: 'Raw API', fetch: () => new Response('{}') }) + + const commands = Cli.toCommands.get(cli)! + const listed = await SyncSkills.list('tool', commands) + const names = listed.map((skill) => skill.name) + expect(names).toContain('tool-api') + expect(names).toContain('tool-real') + expect(names).not.toContain('tool-r') + + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + const synced = await SyncSkills.sync('tool', commands, { + depth: 0, + global: false, + cwd: installDir, + }) + const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8') + expect(content).toContain('Preview') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toMatch(/^# tool r$/m) + + rmSync(tmp, { recursive: true, force: true }) +}) + test('list results are sorted alphabetically', async () => { const cli = Cli.create('test') cli.command('zebra', { description: 'Z command', run: () => ({}) }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 037c350..3317c26 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -2,9 +2,8 @@ import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { parse as yamlParse } from 'yaml' -import { formatExamples } from './Cli.js' +import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js' import * as Agents from './internal/agents.js' import * as Skill from './Skill.js' @@ -19,7 +18,7 @@ export async function sync( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`)) @@ -31,7 +30,7 @@ export async function sync( : path.join(tmpDir, 'SKILL.md') await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${file.content}\n`) - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) skills.push({ name: meta.name ?? (file.dir || name), description: meta.description }) } @@ -42,7 +41,7 @@ export async function sync( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) const dest = path.join(tmpDir, skillName, 'SKILL.md') @@ -68,7 +67,7 @@ export async function sync( } // Write skills hash + names for staleness detection - const hashEntries = collectEntries(commands, [], undefined, options.rootCommand) + const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand) writeMeta( name, Skill.hash(hashEntries), @@ -139,14 +138,14 @@ export async function list( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const skills: list.Skill[] = [] const installed = readInstalledSkills(name, { cwd }) for (const file of files) { - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) const skillName = meta.name ?? (file.dir || name) skills.push({ name: skillName, @@ -162,7 +161,7 @@ export async function list( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) if (!skills.some((s) => s.name === skillName)) { @@ -223,75 +222,6 @@ export declare namespace list { } } -/** Recursively collects leaf commands as `Skill.CommandInfo`. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), - rootCommand?: - | { - description?: string | undefined - args?: any - env?: any - hint?: string | undefined - options?: any - output?: any - examples?: any[] | undefined - } - | undefined, -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - if (rootCommand) { - const cmd: Skill.CommandInfo = {} - if (rootCommand.description) cmd.description = rootCommand.description - if (rootCommand.args) cmd.args = rootCommand.args - if (rootCommand.env) cmd.env = rootCommand.env - if (rootCommand.hint) cmd.hint = rootCommand.hint - if (rootCommand.options) cmd.options = rootCommand.options - if (rootCommand.output) cmd.output = rootCommand.output - const examples = formatExamples(rootCommand.examples) - if (examples) cmd.examples = examples - result.push(cmd) - } - for (const [name, entry] of commands) { - const entryPath = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(entryPath.join(' '), entry.description) - result.push(...collectEntries(entry.commands, entryPath, groups)) - } else { - const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = formatExamples(entry.examples) - if (examples) { - const cmdName = entryPath.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} - -function parseFrontmatter(content: string): { - description?: string | undefined - name?: string | undefined -} { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match) return {} - - const meta = yamlParse(match[1]!) - if (!meta || typeof meta !== 'object') return {} - return meta as { description?: string | undefined; name?: string | undefined } -} - /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */ function resolvePackageRoot(): string { const bin = process.argv[1] From 375c0ebc4c82ee2847f3dc4be1724ffc28599f34 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 19:26:10 +0200 Subject: [PATCH 05/55] chore: add changeset for skill projection fix --- .changeset/tame-pillows-accept.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/tame-pillows-accept.md diff --git a/.changeset/tame-pillows-accept.md b/.changeset/tame-pillows-accept.md new file mode 100644 index 0000000..84bce73 --- /dev/null +++ b/.changeset/tame-pillows-accept.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed generated and synced skills to use the same command projection as CLI skill output. + +`Skillgen` and `SyncSkills` now avoid generating duplicate skills for command aliases, preserve output schemas and examples consistently, and include the fetch gateway skill hint for fetch-based commands. From da28b04e855584649c760f4dbc342d07923042b1 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Mon, 25 May 2026 18:55:18 +0200 Subject: [PATCH 06/55] feat: add typed client runtime foundation --- package.json | 5 + src/Cli.test-d.ts | 22 ++ src/Cli.ts | 6 +- src/Openapi.test.ts | 30 ++ src/Openapi.ts | 51 +++ src/Typegen.test.ts | 98 ++++-- src/Typegen.ts | 45 ++- src/client-routes.test.ts | 81 +++++ src/client/errors.ts | 6 + src/client/index.ts | 15 + src/client/transports/createTransport.ts | 29 ++ src/client/transports/http.test.ts | 148 +++++++++ src/client/transports/http.ts | 216 +++++++++++++ src/client/transports/memory.test.ts | 95 ++++++ src/client/transports/memory.ts | 53 ++++ src/e2e.test.ts | 55 ++-- src/internal/client-discovery.ts | 286 +++++++++++++++++ src/internal/client-local.ts | 85 +++++ src/internal/client-runtime.test.ts | 253 +++++++++++++++ src/internal/client-runtime.ts | 379 +++++++++++++++++++++++ src/internal/command-tree.test.ts | 96 ++++++ src/internal/command-tree.ts | 236 ++++++++++++++ src/internal/command.ts | 13 +- 23 files changed, 2239 insertions(+), 64 deletions(-) create mode 100644 src/client-routes.test.ts create mode 100644 src/client/errors.ts create mode 100644 src/client/index.ts create mode 100644 src/client/transports/createTransport.ts create mode 100644 src/client/transports/http.test.ts create mode 100644 src/client/transports/http.ts create mode 100644 src/client/transports/memory.test.ts create mode 100644 src/client/transports/memory.ts create mode 100644 src/internal/client-discovery.ts create mode 100644 src/internal/client-local.ts create mode 100644 src/internal/client-runtime.test.ts create mode 100644 src/internal/client-runtime.ts create mode 100644 src/internal/command-tree.test.ts create mode 100644 src/internal/command-tree.ts diff --git a/package.json b/package.json index 8f218be..8d7c658 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,11 @@ "types": "./dist/index.d.ts", "src": "./src/index.ts", "default": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "src": "./src/client/index.ts", + "default": "./dist/client/index.js" } } } diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index ee46568..dc44c93 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -159,6 +159,28 @@ test('Cta accepts object form', () => { expectTypeOf<{ command: 'auth login'; description: 'Log in' }>().toMatchTypeOf() }) +test('OpenAPI-mounted operations are included in CLI command map type', () => { + const cli = Cli.create('test').command('api', { + fetch: () => new Response('{}'), + openapi: { + paths: { + '/users': { + get: { + operationId: 'listUsers', + responses: { '200': { description: 'ok' } }, + }, + }, + }, + }, + }) + + expectTypeOf().toMatchTypeOf< + Cli.Cli<{ + 'api listUsers': { args: Record; options: Record } + }> + >() +}) + test('Cta narrows strings and objects to registered commands', () => { type Commands = { get: { args: { id: number }; options: {} } diff --git a/src/Cli.ts b/src/Cli.ts index 82a54d0..1e0d9ef 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -75,17 +75,17 @@ export type Cli< env > /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */ - ( + ( name: name, definition: { basePath?: string | undefined description?: string | undefined fetch: FetchSource - openapi?: Openapi.OpenAPISource | undefined + openapi?: spec | undefined openapiConfig?: Openapi.Config | undefined outputPolicy?: OutputPolicy | undefined }, - ): Cli + ): Cli, vars, env> } /** A short description of the CLI. */ description?: string | undefined diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 9bdf996..6b9a732 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -162,6 +162,36 @@ describe('generateCommands', () => { expect(limitSchema.description).toBe('Max results') }) + test('infers output from JSON response schemas', async () => { + const commands = await Openapi.generateCommands( + { + paths: { + '/users/posts': { + get: { + operationId: 'listPosts', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + () => new Response(JSON.stringify({ ok: true })), + ) + const command = commands.get('listPosts')! + if ('_group' in command) throw new Error('expected listPosts command') + expect(command.output).toBeDefined() + }) + test('generates namespace command groups from paths', async () => { const commands = await Openapi.generateCommands(spec, app.fetch, { config: { mode: 'namespace' }, diff --git a/src/Openapi.ts b/src/Openapi.ts index 0a862d8..78e9dac 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -26,6 +26,42 @@ export type Config = { mode?: Mode | undefined } +/** Inferred command map for operation commands generated from a literal OpenAPI spec. */ +export type Commands = + spec extends OpenAPISpec + ? { + [path in keyof NonNullable & string as OperationCommandName< + name, + NonNullable[path] + >]: { + args: Record + options: Record + output: unknown + } + } + : {} + +type OperationCommandName = item extends object + ? { + [method in keyof item & string]: method extends OperationMethod + ? item[method] extends { operationId: infer id extends string } + ? `${name} ${id}` + : `${name} ${method} ${string}` + : never + }[keyof item & string] + : never + +type OperationMethod = + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'query' + | 'trace' + /** Options for generating an OpenAPI document from an incur CLI. */ export type GenerateOptions = { /** API description. Defaults to the CLI description. */ @@ -96,6 +132,7 @@ type GeneratedCommand = { args?: z.ZodObject | undefined description?: string | undefined options?: z.ZodObject | undefined + output?: z.ZodType | undefined run: (context: any) => any } @@ -360,6 +397,7 @@ export async function generateCommands( const bodySchema = op.requestBody?.content?.['application/json']?.schema const bodyProps = (bodySchema?.properties ?? {}) as Record> const bodyRequired = new Set((bodySchema?.required as string[]) ?? []) + const outputSchema = responseSchema(op.responses) // Build args Zod schema from path params let argsSchema: z.ZodObject | undefined @@ -393,6 +431,7 @@ export async function generateCommands( description: op.summary ?? op.description, args: argsSchema, options: optionsSchema, + ...(outputSchema ? { output: toZod(outputSchema) } : undefined), run: createHandler({ basePath: options.basePath, fetch, @@ -658,3 +697,15 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType { const desc = (schema as any).description ?? (inner as any).description return desc ? coerced.describe(desc) : coerced } + +function responseSchema(responses: Record | undefined) { + if (!responses) return undefined + const entries = Object.entries(responses) + const preferred = + entries.find(([status]) => status === '200') ?? + entries.find(([status]) => /^2\d\d$/.test(status)) + const response = preferred?.[1] as + | { content?: Record | undefined }> | undefined } + | undefined + return response?.content?.['application/json']?.schema +} diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..2f879c3 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -13,12 +13,14 @@ describe('fromCli', () => { }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'get': { args: { id: number }; options: {}; output: {} } + 'list': { args: {}; options: { limit: number }; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'get': { args: { id: number }; options: {} } - 'list': { args: {}; options: { limit: number } } - } + commands: Commands } } " @@ -29,11 +31,13 @@ describe('fromCli', () => { const cli = Cli.create('test').command('ping', { run: () => ({}) }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'ping': { args: {}; options: {}; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'ping': { args: {}; options: {} } - } + commands: Commands } } " @@ -54,12 +58,14 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'pr create': { args: { title: string }; options: {}; output: {} } + 'pr list': { args: {}; options: { state: string }; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'pr create': { args: { title: string }; options: {} } - 'pr list': { args: {}; options: { state: string } } - } + commands: Commands } } " @@ -77,11 +83,13 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'pr review approve': { args: { id: number }; options: {}; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'pr review approve': { args: { id: number }; options: {} } - } + commands: Commands } } " @@ -118,6 +126,46 @@ describe('fromCli', () => { expect(output).toContain('tags: string[]') }) + test('scalar output schema', () => { + const cli = Cli.create('test').command('read', { + output: z.string(), + run: () => 'content', + }) + + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "export type Commands = { + 'read': { args: {}; options: {}; output: string } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + " + `) + }) + + test('array output schema', () => { + const cli = Cli.create('test').command('list', { + output: z.array(z.object({ id: z.string(), active: z.boolean() })), + run: () => [{ id: 'one', active: true }], + }) + + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "export type Commands = { + 'list': { args: {}; options: {}; output: { id: string; active: boolean }[] } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + " + `) + }) + test('commands are sorted alphabetically', () => { const cli = Cli.create('test') .command('zebra', { run: () => ({}) }) @@ -125,7 +173,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(/^ '(\w+)':/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -191,12 +239,14 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'ping': { args: {}; options: {}; output: {} } + 'pr list': { args: {}; options: {}; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'ping': { args: {}; options: {} } - 'pr list': { args: {}; options: {} } - } + commands: Commands } } " diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..335d9aa 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -17,14 +17,23 @@ export function fromCli(cli: Cli.Cli): string { const entries = collectEntries(commands, []) - const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] + const lines: string[] = ['export type Commands = {'] - for (const { name, args, options } of entries) + for (const { name, args, options, output } of entries) lines.push( - ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`, + ` '${name}': { args: ${schemaToObjectType(args)}; options: ${schemaToObjectType(options)}; output: ${schemaToType(output)} }`, ) - lines.push(' }', ' }', '}', '') + lines.push( + '}', + '', + "declare module 'incur' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + ) return lines.join('\n') } @@ -32,18 +41,38 @@ export function fromCli(cli: Cli.Cli): string { function collectEntries( commands: Map, prefix: string[], -): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] { +): { + name: string + args?: z.ZodObject | undefined + options?: z.ZodObject | undefined + output?: z.ZodType | undefined +}[] { const result: ReturnType = [] for (const [name, entry] of commands) { const path = [...prefix, name] + if ('_alias' in entry || '_fetch' in entry) continue if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) - else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) + else + result.push({ + name: path.join(' '), + args: entry.args, + options: entry.options, + output: entry.output, + }) } return result.sort((a, b) => a.name.localeCompare(b.name)) } -/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function schemaToType(schema: z.ZodObject | undefined): string { +/** Converts a Zod output schema to a TypeScript type string. Returns `{}` for undefined schemas. */ +function schemaToType(schema: z.ZodType | undefined): string { + if (!schema) return '{}' + const json = z.toJSONSchema(schema) as Record + const defs = (json.$defs ?? {}) as Record> + return resolveType(json, defs) +} + +/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined or empty schemas. */ +function schemaToObjectType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' const json = z.toJSONSchema(schema) as Record const defs = (json.$defs ?? {}) as Record> diff --git a/src/client-routes.test.ts b/src/client-routes.test.ts new file mode 100644 index 0000000..fb53fe3 --- /dev/null +++ b/src/client-routes.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' + +import * as Cli from './Cli.js' + +async function json(response: Response) { + return response.json() as Promise +} + +describe('client HTTP routes', () => { + test('maps RPC protocol failures to precise HTTP statuses', async () => { + const cli = Cli.create('app').command( + Cli.create('group').command('leaf', { + run() { + return null + }, + }), + ) + cli.command('raw', { fetch: () => new Response('{}') }) + + const invalid = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: '' }), + }), + ) + expect(invalid.status).toBe(400) + expect(await json(invalid)).toMatchObject({ error: { code: 'INVALID_RPC_REQUEST' } }) + + const group = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'group' }), + }), + ) + expect(group.status).toBe(400) + expect(await json(group)).toMatchObject({ error: { code: 'COMMAND_GROUP' } }) + + const raw = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'raw' }), + }), + ) + expect(raw.status).toBe(400) + expect(await json(raw)).toMatchObject({ error: { code: 'FETCH_GATEWAY' } }) + + const missing = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'missing' }), + }), + ) + expect(missing.status).toBe(404) + expect(await json(missing)).toMatchObject({ error: { code: 'COMMAND_NOT_FOUND' } }) + }) + + test('maps discovery failures to precise envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + + const unknownCommand = await cli.fetch( + new Request('http://localhost/_incur/help?command=missing'), + ) + expect(unknownCommand.status).toBe(404) + expect(await json(unknownCommand)).toMatchObject({ + error: { code: 'COMMAND_NOT_FOUND' }, + meta: { resource: 'help' }, + }) + + const unsafeSkill = await cli.fetch(new Request('http://localhost/_incur/skill?name=../x')) + expect(unsafeSkill.status).toBe(400) + expect(await json(unsafeSkill)).toMatchObject({ error: { code: 'INVALID_SKILL_NAME' } }) + + const unknownSkill = await cli.fetch(new Request('http://localhost/_incur/skill?name=missing')) + expect(unknownSkill.status).toBe(404) + expect(await json(unknownSkill)).toMatchObject({ error: { code: 'SKILL_NOT_FOUND' } }) + }) +}) diff --git a/src/client/errors.ts b/src/client/errors.ts new file mode 100644 index 0000000..cf3769e --- /dev/null +++ b/src/client/errors.ts @@ -0,0 +1,6 @@ +import { BaseError } from '../Errors.js' + +/** Error thrown by client transports. */ +export class ClientError extends BaseError { + override name = 'Incur.ClientError' +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..bd36333 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,15 @@ +export { ClientError } from './errors.js' +export { httpTransport } from './transports/http.js' +export { memoryTransport } from './transports/memory.js' +export type { DiscoveryRequest, DiscoveryResponse } from '../internal/client-discovery.js' +export type { + RpcFullEnvelope as ClientRpcEnvelope, + RpcMeta as ClientRpcMeta, + RpcRequest, + RpcResponse, + RpcStreamRecord, + RpcStreamResponse, +} from '../internal/client-runtime.js' +export type { HttpTransport, HttpTransportOptions } from './transports/http.js' +export type { MemoryTransport, MemoryTransportOptions } from './transports/memory.js' +export type { TransportFactory } from './transports/createTransport.js' diff --git a/src/client/transports/createTransport.ts b/src/client/transports/createTransport.ts new file mode 100644 index 0000000..240df0f --- /dev/null +++ b/src/client/transports/createTransport.ts @@ -0,0 +1,29 @@ +/** Transport context supplied when resolving a transport factory. */ +export type TransportContext = { + /** Client uid. */ + uid: string +} + +/** Transport type names. */ +export type TransportType = 'http' | 'memory' + +/** Transport configuration. */ +export type TransportConfig = { + /** Stable transport key. */ + key: string + /** Human-readable transport name. */ + name: string + /** Transport type. */ + type: type +} + +/** Transport value object. */ +export type TransportValue = Record + +/** Transport factory. */ +export type TransportFactory = ( + context: TransportContext, +) => { + config: TransportConfig + value: value +} diff --git a/src/client/transports/http.test.ts b/src/client/transports/http.test.ts new file mode 100644 index 0000000..861eeb7 --- /dev/null +++ b/src/client/transports/http.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test, vi } from 'vitest' + +import { ClientError } from '../errors.js' +import { httpTransport } from './http.js' + +function resolve(fetch: typeof globalThis.fetch) { + return httpTransport({ baseUrl: 'https://example.com/api/', fetch })({ uid: 'u' }).value +} + +function ndjson(lines: string[], options: { cancel?: () => void } = {}) { + const encoder = new TextEncoder() + const source: UnderlyingDefaultSource = { + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line)) + controller.close() + }, + } + if (options.cancel) source.cancel = options.cancel + return new Response(new ReadableStream(source), { + headers: { 'content-type': 'application/x-ndjson; charset=utf-8' }, + }) +} + +describe('httpTransport', () => { + test('normalizes base URL, serializes omitted args/options, and merges headers', async () => { + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe('https://example.com/api/_incur/rpc') + expect(init?.method).toBe('POST') + const headers = new Headers(init?.headers) + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('accept')).toBe('application/json, application/x-ndjson') + expect(headers.get('x-custom')).toBe('yes') + expect(JSON.parse(String(init?.body))).toEqual({ command: 'status', args: {}, options: {} }) + return new Response( + JSON.stringify({ ok: true, data: 1, meta: { command: 'status', duration: '1ms' } }), + { + headers: { 'content-type': 'application/json' }, + }, + ) + }) as typeof globalThis.fetch + const transport = httpTransport({ + baseUrl: 'https://example.com/api', + fetch, + headers: { 'x-custom': 'yes' }, + })({ uid: 'u' }).value + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: 1, + }) + }) + + test('wraps fetch rejection and rejects malformed JSON envelopes', async () => { + const failing = vi.fn(async () => { + throw new Error('offline') + }) as unknown as typeof globalThis.fetch + await expect(resolve(failing).request({ command: 'status' })).rejects.toThrow(ClientError) + + const invalidJson = vi.fn( + async () => new Response('nope', { headers: { 'content-type': 'application/json' } }), + ) as typeof globalThis.fetch + await expect(resolve(invalidJson).request({ command: 'status' })).rejects.toThrow( + 'Invalid RPC JSON', + ) + + const malformed = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof globalThis.fetch + await expect(resolve(malformed).request({ command: 'status' })).rejects.toThrow( + 'Malformed RPC envelope', + ) + }) + + test('parses NDJSON split records, blanks, final line without newline, and truncated streams', async () => { + const fetch = vi.fn(async () => + ndjson([ + '{"type":"chunk","data":{"a":', + '1}}\n\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}', + ]), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { a: 1 } }, + { type: 'done', ok: true, data: null, meta: { command: 'status', duration: '1ms' } }, + ]) + + const truncated = vi.fn(async () => + ndjson(['{"type":"chunk","data":1}\n']), + ) as typeof globalThis.fetch + const truncatedResponse = await resolve(truncated).request({ command: 'status' }) + if (!('stream' in truncatedResponse)) throw new Error('expected stream') + await expect(async () => { + for await (const _ of truncatedResponse.records()) { + } + }).rejects.toThrow('terminal record') + }) + + test('cancels the HTTP reader when the consumer stops early', async () => { + const cancel = vi.fn() + const fetch = vi.fn(async () => + ndjson( + [ + '{"type":"chunk","data":1}\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}\n', + ], + { cancel }, + ), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const iterator = response.records() + await iterator.next() + await iterator.return(undefined as any) + expect(cancel).toHaveBeenCalled() + }) + + test('routes discovery requests', async () => { + const fetch = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe('https://example.com/api/_incur/help?command=status') + return new Response('help', { headers: { 'content-type': 'text/plain' } }) + }) as typeof globalThis.fetch + await expect(resolve(fetch).discover({ resource: 'help', command: 'status' })).resolves.toEqual( + { + contentType: 'text/plain', + body: 'help', + }, + ) + }) + + test('routes OpenAPI discovery to the public OpenAPI route', async () => { + const fetch = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe('https://example.com/api/openapi.json') + return new Response(JSON.stringify({ openapi: '3.2.0' }), { + headers: { 'content-type': 'application/json' }, + }) + }) as typeof globalThis.fetch + await expect(resolve(fetch).discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { openapi: '3.2.0' }, + }) + }) +}) diff --git a/src/client/transports/http.ts b/src/client/transports/http.ts new file mode 100644 index 0000000..a6e83c3 --- /dev/null +++ b/src/client/transports/http.ts @@ -0,0 +1,216 @@ +import type { DiscoveryRequest, DiscoveryResponse } from '../../internal/client-discovery.js' +import type { + RpcRequest, + RpcResponse, + RpcStreamRecord, + RpcStreamResponse, +} from '../../internal/client-runtime.js' +import { ClientError } from '../errors.js' +import type { TransportFactory } from './createTransport.js' + +/** HTTP transport factory. */ +export type HttpTransport = TransportFactory< + 'http', + { + baseUrl: URL + request(request: RpcRequest): Promise + discover(request: DiscoveryRequest): Promise + } +> + +/** HTTP transport options. */ +export type HttpTransportOptions = { + /** Base URL for the served CLI. */ + baseUrl: string | URL + /** Fetch implementation. Defaults to globalThis.fetch. */ + fetch?: typeof globalThis.fetch | undefined + /** Headers merged into every request. */ + headers?: HeadersInit | undefined +} + +/** Creates an HTTP transport. */ +export function httpTransport(options: HttpTransportOptions): HttpTransport { + const fetcher = options.fetch ?? globalThis.fetch + if (!fetcher) throw new ClientError('No fetch implementation is available.') + const baseUrl = new URL(options.baseUrl) + + return () => ({ + config: { key: 'http', name: 'HTTP', type: 'http' }, + value: { + baseUrl, + async request(request) { + const response = await requestFetch(fetcher, url(baseUrl, '_incur/rpc'), { + method: 'POST', + headers: headers(options.headers, { + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }), + body: JSON.stringify({ + ...request, + args: request.args ?? {}, + options: request.options ?? {}, + }), + }) + return parseRpcResponse(response) + }, + async discover(request) { + const response = await requestFetch(fetcher, discoveryUrl(baseUrl, request), { + method: 'GET', + headers: headers(options.headers, { + accept: 'application/json, text/plain, text/markdown', + }), + }) + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) + return { contentType: essence(contentType), data: await parseJson(response) } + return { contentType: essence(contentType), body: await response.text() } + }, + }, + }) +} + +async function requestFetch(fetcher: typeof globalThis.fetch, input: URL, init: RequestInit) { + try { + return await fetcher(input, init) + } catch (error) { + throw new ClientError('RPC request failed', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } +} + +async function parseRpcResponse(response: Response): Promise { + const contentType = essence(response.headers.get('content-type') ?? '') + if (contentType === 'application/x-ndjson') { + if (!response.body) throw new ClientError('Streaming RPC response is missing a body.') + return streamResponse(response.body) + } + if (contentType !== 'application/json') throw new ClientError('RPC response was not JSON.') + const value = await parseJson(response) + if (!isEnvelope(value)) throw new ClientError('Malformed RPC envelope.') + return value +} + +function streamResponse(body: ReadableStream): RpcStreamResponse { + return { + stream: true, + async *records() { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let terminal: RpcStreamRecord | undefined + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + for (const record of drainRecords(buffer)) { + buffer = record.rest + const parsed = parseRecord(record.line) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + } + const rest = buffer.trim() + if (rest) { + const parsed = parseRecord(rest) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + if (!terminal) throw new ClientError('RPC stream ended before a terminal record.') + return terminal + } finally { + await reader.cancel().catch(() => undefined) + } + }, + } +} + +function* drainRecords(buffer: string): Generator<{ line: string; rest: string }> { + let current = buffer + while (true) { + const index = current.indexOf('\n') + if (index === -1) return + const line = current.slice(0, index).trim() + current = current.slice(index + 1) + if (line) yield { line, rest: current } + } +} + +function parseRecord(line: string): RpcStreamRecord { + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + throw new ClientError('Invalid RPC stream JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } + if (!isRecord(value)) throw new ClientError('Malformed RPC stream record.') + return value +} + +async function parseJson(response: Response) { + try { + return JSON.parse(await response.text()) + } catch (error) { + throw new ClientError('Invalid RPC JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } +} + +function discoveryUrl(baseUrl: URL, request: DiscoveryRequest) { + const path = (() => { + if (request.resource === 'llms') return '_incur/llms' + if (request.resource === 'llmsFull') return '_incur/llms-full' + if (request.resource === 'schema') return '_incur/schema' + if (request.resource === 'help') return '_incur/help' + if (request.resource === 'mcpTools') return '_incur/mcp/tools' + if (request.resource === 'skillsIndex') return '_incur/skills' + if (request.resource === 'skill') return '_incur/skill' + return 'openapi.json' + })() + const target = url(baseUrl, path) + if ('command' in request && request.command) target.searchParams.set('command', request.command) + if ('format' in request && request.format) target.searchParams.set('format', request.format) + if (request.resource === 'skill') target.searchParams.set('name', request.name) + return target +} + +function url(baseUrl: URL, path: string) { + const pathname = `${baseUrl.pathname.replace(/\/$/, '')}/${path}` + const target = new URL(baseUrl) + target.pathname = pathname + target.search = '' + return target +} + +function headers(custom: HeadersInit | undefined, required: Record) { + const result = new Headers(required) + if (custom) new Headers(custom).forEach((value, key) => result.set(key, value)) + return result +} + +function essence(value: string) { + return value.split(';', 1)[0]!.trim().toLowerCase() +} + +function isEnvelope(value: unknown): value is RpcResponse { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { ok?: unknown }).ok === 'boolean' && + typeof (value as { meta?: { command?: unknown } }).meta?.command === 'string' + ) +} + +function isRecord(value: unknown): value is RpcStreamRecord { + return ( + typeof value === 'object' && + value !== null && + ((value as { type?: unknown }).type === 'chunk' || + ((value as { type?: unknown }).type === 'done' && isEnvelope(value)) || + ((value as { type?: unknown }).type === 'error' && isEnvelope(value))) + ) +} diff --git a/src/client/transports/memory.test.ts b/src/client/transports/memory.test.ts new file mode 100644 index 0000000..efa46df --- /dev/null +++ b/src/client/transports/memory.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import { memoryTransport } from './memory.js' + +describe('memoryTransport', () => { + test('executes through shared runtime without calling cli.fetch and uses explicit env', async () => { + const cli = Cli.create('app', { + env: z.object({ TOKEN: z.string() }), + }).command('status', { + env: z.object({ TOKEN: z.string() }), + run(c) { + return { token: c.env.TOKEN } + }, + }) + cli.fetch = async () => { + throw new Error('fetch should not be called') + } + + const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })({ uid: 'u' }).value + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { token: 'secret' }, + }) + }) + + test('does not load config defaults for in-process requests', async () => { + const cli = Cli.create('app', { config: {} }).command('status', { + options: z.object({ name: z.string().default('runtime') }), + run(c) { + return c.options + }, + }) + const transport = memoryTransport(cli)({ uid: 'u' }).value + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { name: 'runtime' }, + }) + }) + + test('preserves CLI version for in-process execution and OpenAPI discovery', async () => { + const cli = Cli.create('app', { version: '1.2.3' }).command('status', { + run(c) { + return { version: c.version } + }, + }) + const transport = memoryTransport(cli)({ uid: 'u' }).value + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { version: '1.2.3' }, + }) + await expect(transport.discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { info: { version: '1.2.3' } }, + }) + }) + + test('discovers help, skills, OpenAPI, and MCP tools', async () => { + const cli = Cli.create('app', { description: 'App' }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const transport = memoryTransport(cli)({ uid: 'u' }).value + await expect( + transport.discover({ resource: 'help', command: 'status' }), + ).resolves.toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Show status'), + }) + await expect(transport.discover({ resource: 'skillsIndex' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { skills: expect.any(Array) }, + }) + await expect(transport.discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { openapi: '3.2.0' }, + }) + await expect(transport.discover({ resource: 'mcpTools' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { tools: [expect.objectContaining({ name: 'status' })] }, + }) + }) + + test('exposes memory-only local capability', () => { + const cli = Cli.create('app') + const transport = memoryTransport(cli)({ uid: 'u' }).value + expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) + expect(typeof transport.local.skills.add).toBe('function') + expect(typeof transport.local.skills.list).toBe('function') + expect(typeof transport.local.mcp.add).toBe('function') + }) +}) diff --git a/src/client/transports/memory.ts b/src/client/transports/memory.ts new file mode 100644 index 0000000..7659303 --- /dev/null +++ b/src/client/transports/memory.ts @@ -0,0 +1,53 @@ +import * as Cli from '../../Cli.js' +import { + discoverClientResource, + type DiscoveryRequest, + type DiscoveryResponse, +} from '../../internal/client-discovery.js' +import { createLocalRuntime, type LocalRuntime } from '../../internal/client-local.js' +import { + executeClientCommand, + type RpcRequest, + type RpcResponse, + type RpcStreamResponse, +} from '../../internal/client-runtime.js' +import * as CommandTree from '../../internal/command-tree.js' +import type { TransportFactory } from './createTransport.js' + +/** Memory transport factory. */ +export type MemoryTransport = TransportFactory< + 'memory', + { + request(request: RpcRequest): Promise + discover(request: DiscoveryRequest): Promise + local: LocalRuntime + } +> + +/** Memory transport options. */ +export type MemoryTransportOptions = { + /** Explicit environment source. */ + env?: Record | undefined +} + +/** Creates an in-process memory transport. */ +export function memoryTransport( + cli: Cli.Cli, + options: MemoryTransportOptions = {}, +): MemoryTransport { + return () => { + const ctx = CommandTree.fromCli(cli) + return { + config: { key: 'memory', name: 'Memory', type: 'memory' }, + value: { + request(request) { + return executeClientCommand(ctx, request, { env: options.env }) + }, + discover(request) { + return discoverClientResource(ctx, request) + }, + local: createLocalRuntime(ctx), + }, + } + } +} diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 4cd126c..71d8017 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1601,34 +1601,35 @@ describe('--llms', () => { describe('typegen', () => { test('generates correct .d.ts for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` - "declare module 'incur' { + "export type Commands = { + 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] }; output: {} } + 'auth logout': { args: {}; options: {}; output: {} } + 'auth status': { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } + 'config': { args: { key?: string }; options: {}; output: {} } + 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string }; output: {} } + 'explode': { args: {}; options: {}; output: {} } + 'explode-clac': { args: {}; options: {}; output: {} } + 'noop': { args: {}; options: {}; output: {} } + 'ping': { args: {}; options: {}; output: {} } + 'project create': { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } + 'project delete': { args: { id: string }; options: { force: boolean }; output: {} } + '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: {}; output: {} } + '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: {}; output: {} } + 'stream': { args: {}; options: {}; output: {} } + 'stream-error': { args: {}; options: {}; output: {} } + 'stream-ok': { args: {}; options: {}; output: {} } + 'stream-text': { args: {}; options: {}; output: {} } + 'stream-throw': { args: {}; options: {}; output: {} } + 'validate-fail': { args: { email: string; age: number }; options: {}; output: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'api': { args: {}; options: {} } - 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } - 'auth logout': { args: {}; options: {} } - 'auth status': { args: {}; options: {} } - 'config': { args: { key?: string }; options: {} } - 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string } } - 'explode': { args: {}; options: {} } - 'explode-clac': { args: {}; options: {} } - 'noop': { args: {}; options: {} } - 'ping': { args: {}; options: {} } - 'project create': { args: { name: string }; options: { description: string; private: boolean } } - 'project delete': { args: { id: string }; options: { force: boolean } } - 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } } - 'project deploy rollback': { args: { deployId: string }; options: {} } - 'project deploy status': { args: { deployId: string }; options: {} } - 'project get': { args: { id: string }; options: {} } - 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } } - 'slow': { args: {}; options: {} } - 'stream': { args: {}; options: {} } - 'stream-error': { args: {}; options: {} } - 'stream-ok': { args: {}; options: {} } - 'stream-text': { args: {}; options: {} } - 'stream-throw': { args: {}; options: {} } - 'validate-fail': { args: { email: string; age: number }; options: {} } - } + commands: Commands } } " diff --git a/src/internal/client-discovery.ts b/src/internal/client-discovery.ts new file mode 100644 index 0000000..04ea3d4 --- /dev/null +++ b/src/internal/client-discovery.ts @@ -0,0 +1,286 @@ +import { parse as yamlParse, stringify as yamlStringify } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import * as Formatter from '../Formatter.js' +import * as Help from '../Help.js' +import * as Mcp from '../Mcp.js' +import * as Openapi from '../Openapi.js' +import * as Skill from '../Skill.js' +import * as CommandTree from './command-tree.js' + +/** Discovery request. */ +export type DiscoveryRequest = + | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'schema'; command?: string | undefined } + | { resource: 'help'; command?: string | undefined } + | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined } + | { resource: 'skillsIndex' } + | { resource: 'skill'; name: string } + | { resource: 'mcpTools' } + +/** Discovery response. */ +export type DiscoveryResponse = + | { contentType: string; body: string } + | { contentType: string; data: unknown } + +/** Discovery failure with protocol code and HTTP status metadata. */ +export class DiscoveryError extends Error { + /** Machine-readable error code. */ + code: string + /** HTTP status for discovery routes. */ + status: number + + constructor(code: string, message: string, status: number) { + super(message) + this.code = code + this.status = status + } +} + +const requestSchema = z.discriminatedUnion('resource', [ + z.object({ + resource: z.literal('llms'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ + resource: z.literal('llmsFull'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ resource: z.literal('schema'), command: z.string().optional() }), + z.object({ resource: z.literal('help'), command: z.string().optional() }), + z.object({ resource: z.literal('openapi'), format: z.enum(['json', 'yaml']).optional() }), + z.object({ resource: z.literal('skillsIndex') }), + z.object({ resource: z.literal('skill'), name: z.string() }), + z.object({ resource: z.literal('mcpTools') }), +]) + +/** Builds a client discovery resource from a CLI runtime context. */ +export async function discoverClientResource( + ctx: CommandTree.RuntimeCliContext, + request: unknown, +): Promise { + const parsedRequest = requestSchema.safeParse(request) + if (!parsedRequest.success) + throw new DiscoveryError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + const parsed = parsedRequest.data + if (parsed.resource === 'openapi') { + const spec = openapi(ctx) + if (parsed.format === 'yaml') + return { contentType: 'application/yaml', body: yamlStringify(spec) } + return { contentType: 'application/json', data: spec } + } + if (parsed.resource === 'mcpTools') + return { contentType: 'application/json', data: { tools: Mcp.collectTools(ctx.commands, []) } } + + if (parsed.resource === 'skillsIndex' || parsed.resource === 'skill') { + const { files } = skills(ctx) + if (parsed.resource === 'skillsIndex') { + return { + contentType: 'application/json', + data: { + skills: files.map((file) => { + const meta = parseFrontmatter(file.content) + return { + name: file.dir || ctx.name, + description: meta.description ?? '', + files: ['SKILL.md'], + } + }), + }, + } + } + if (!safeSkillName(parsed.name)) + throw new DiscoveryError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + const file = files.find((value) => (value.dir || ctx.name) === parsed.name) + if (!file) throw new DiscoveryError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + return { contentType: 'text/markdown', body: file.content } + } + + const scoped = scope(ctx, parsed.command) + if (parsed.resource === 'help') { + if (scoped.type === 'command') + return { + contentType: 'text/plain', + body: Help.formatCommand(scoped.id, { + alias: scoped.command.alias, + args: scoped.command.args, + description: scoped.command.description, + env: scoped.command.env, + examples: [], + hint: scoped.command.hint, + options: scoped.command.options, + usage: [], + }), + } + return { + contentType: 'text/plain', + body: Help.formatRoot(scoped.id, { + description: scoped.description, + commands: collect(scoped.commands, [], false).map(({ name, description }) => ({ + name, + ...(description ? { description } : undefined), + })), + }), + } + } + + if (parsed.resource === 'schema') { + if (scoped.type === 'command') { + const schema = CommandTree.buildInputSchema(scoped.command) + return { contentType: 'application/json', data: schema ?? {} } + } + return { contentType: 'application/json', data: manifest(scoped.commands, scoped.prefix, true) } + } + + const full = parsed.resource === 'llmsFull' + const format = parsed.format ?? 'md' + if (format === 'md') { + const groups = new Map() + const entries = skillCommands(scoped.commands, scoped.prefix, groups, scoped.rootCommand) + const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name + const body = full + ? Skill.generate(name, entries, groups) + : Skill.index(name, entries, scoped.description) + return { contentType: 'text/markdown', body } + } + return { + contentType: 'text/plain', + body: Formatter.format(manifest(scoped.commands, scoped.prefix, full), format), + } +} + +function scope(ctx: CommandTree.RuntimeCliContext, command: string | undefined) { + if (!command) + return { + type: 'group' as const, + id: ctx.name, + commands: ctx.commands, + prefix: [] as string[], + rootCommand: ctx.rootCommand, + description: ctx.description, + } + const resolved = CommandTree.resolveCanonical(ctx, command) + if ('error' in resolved) + throw new DiscoveryError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) + if ('gateway' in resolved) + throw new DiscoveryError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) + if ('commands' in resolved) + return { + type: 'group' as const, + id: resolved.id, + commands: resolved.commands, + prefix: resolved.id.split(' '), + rootCommand: undefined, + description: resolved.description, + } + return { + type: 'command' as const, + id: resolved.id, + command: resolved.command, + commands: new Map([[resolved.id.split(' ').at(-1)!, resolved.command]]), + prefix: resolved.id.split(' ').slice(0, -1), + rootCommand: undefined, + description: resolved.command.description, + } +} + +function openapi(ctx: CommandTree.RuntimeCliContext) { + const cli = { name: ctx.name, description: ctx.description } as any + Cli.toCommands.set(cli, ctx.commands as any) + if (ctx.rootCommand) Cli.toRootDefinition.set(cli as Cli.Root, ctx.rootCommand as any) + return Openapi.fromCli(Object.assign(cli, { env: ctx.env, vars: ctx.vars }), { + ...(ctx.version !== undefined ? { version: ctx.version } : undefined), + }) +} + +function skills(ctx: CommandTree.RuntimeCliContext) { + const groups = new Map() + const entries = skillCommands(ctx.commands, [], groups, ctx.rootCommand) + return { files: Skill.split(ctx.name, entries, 1, groups) } +} + +function manifest( + commands: Map, + prefix: string[], + full: boolean, +) { + return { + version: 'incur.v1', + commands: collect(commands, prefix, full).sort((a, b) => a.name.localeCompare(b.name)), + } +} + +function collect(commands: Map, prefix: string[], full: boolean) { + const result: { + name: string + description?: string | undefined + schema?: Record | undefined + }[] = [] + for (const [name, entry] of commands) { + if (CommandTree.isAlias(entry) || CommandTree.isFetchGateway(entry)) continue + const path = [...prefix, name] + if (CommandTree.isGroup(entry)) result.push(...collect(entry.commands, path, full)) + else { + const command: (typeof result)[number] = { name: path.join(' ') } + if (entry.description) command.description = entry.description + if (full) { + const input = CommandTree.buildInputSchema(entry) + if (input || entry.output) { + command.schema = {} + if (input?.args) command.schema.args = input.args + if (input?.env) command.schema.env = input.env + if (input?.options) command.schema.options = input.options + if (entry.output) command.schema.output = z.toJSONSchema(entry.output) + } + } + result.push(command) + } + } + return result +} + +function skillCommands( + commands: Map, + prefix: string[], + groups: Map, + rootCommand?: CommandTree.CommandDefinition | undefined, +): Skill.CommandInfo[] { + const result: Skill.CommandInfo[] = [] + if (rootCommand) result.push(toSkillCommand(rootCommand, undefined)) + for (const [name, entry] of commands) { + if (CommandTree.isAlias(entry) || CommandTree.isFetchGateway(entry)) continue + const path = [...prefix, name] + if (CommandTree.isGroup(entry)) { + if (entry.description) groups.set(path.join(' '), entry.description) + result.push(...skillCommands(entry.commands, path, groups)) + continue + } + result.push(toSkillCommand(entry, path.join(' '))) + } + return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) +} + +function toSkillCommand(command: CommandTree.CommandDefinition, name: string | undefined) { + return { + ...(name ? { name } : undefined), + ...(command.description ? { description: command.description } : undefined), + ...(command.args ? { args: command.args } : undefined), + ...(command.env ? { env: command.env } : undefined), + ...(command.hint ? { hint: command.hint } : undefined), + ...(command.options ? { options: command.options } : undefined), + ...(command.output ? { output: command.output } : undefined), + } satisfies Skill.CommandInfo +} + +function parseFrontmatter(content: string) { + const match = content.match(/^---\n([\s\S]*?)\n---/) + return match ? (yamlParse(match[1]!) as Record) : {} +} + +function safeSkillName(name: string) { + return name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '..' +} diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts new file mode 100644 index 0000000..d572941 --- /dev/null +++ b/src/internal/client-local.ts @@ -0,0 +1,85 @@ +import * as SyncMcp from '../SyncMcp.js' +import * as SyncSkills from '../SyncSkills.js' +import type * as CommandTree from './command-tree.js' + +/** Options for `skills.add()`. */ +export type SkillsAddOptions = { + /** Grouping depth. */ + depth?: number | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Options for `skills.list()`. */ +export type SkillsListOptions = { + /** Grouping depth. */ + depth?: number | undefined +} + +/** Options for `mcp.add()`. */ +export type McpAddOptions = { + /** Target agents. */ + agents?: string[] | undefined + /** Command agents should run. */ + command?: string | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Synced skills result. */ +export type SyncedSkills = SyncSkills.sync.Result + +/** Skills list result. */ +export type SkillsList = SyncSkills.list.Skill[] + +/** MCP registration result. */ +export type McpRegistration = SyncMcp.register.Result + +/** Local memory-only runtime. */ +export type LocalRuntime = { + /** Skill setup actions. */ + skills: { + add(options?: SkillsAddOptions | undefined): Promise + list(options?: SkillsListOptions | undefined): Promise + } + /** MCP setup actions. */ + mcp: { + add(options?: McpAddOptions | undefined): Promise + } +} + +/** Creates local setup/admin wrappers for a memory transport. */ +export function createLocalRuntime(ctx: CommandTree.RuntimeCliContext): LocalRuntime { + return { + skills: { + add(options: SkillsAddOptions = {}) { + return SyncSkills.sync(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + global: options.global ?? true, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + }, + list(options: SkillsListOptions = {}) { + return SyncSkills.list(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + }, + }, + mcp: { + add(options: McpAddOptions = {}) { + return SyncMcp.register(ctx.name, { + agents: options.agents ?? ctx.mcp?.agents, + command: options.command ?? ctx.mcp?.command, + global: options.global ?? true, + }) + }, + }, + } +} diff --git a/src/internal/client-runtime.test.ts b/src/internal/client-runtime.test.ts new file mode 100644 index 0000000..527e62c --- /dev/null +++ b/src/internal/client-runtime.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import { executeClientCommand } from './client-runtime.js' +import * as CommandTree from './command-tree.js' + +function createFixture() { + const order: string[] = [] + const child = Cli.create('child', { + args: z.object({ id: z.string() }), + options: z.object({ loud: z.boolean().default(false) }), + run(c) { + order.push(`child:${c.agent}:${c.args.id}:${c.options.loud}:${c.env.TOKEN}`) + return c.ok({ id: c.args.id, loud: c.options.loud }, { cta: { commands: ['next'] } }) + }, + env: z.object({ TOKEN: z.string() }), + }) + + const router = Cli.create('project') + router.use(async (_, next) => { + order.push('group:before') + await next() + order.push('group:after') + }) + router.command('list', { + args: z.object({ projectId: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ items: z.array(z.object({ id: z.string() })) }), + run(c) { + order.push(`run:${c.args.projectId}:${c.options.limit}:${(c.var as { root: string }).root}`) + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + router.command('stream', { + async *run(c) { + try { + yield { step: 1 } + yield { step: 2 } + return c.ok({ done: true }, { cta: { commands: ['project list'] } }) + } finally { + order.push('stream:return') + } + }, + }) + router.command('fail-stream', { + async *run(c) { + yield { step: 1 } + return c.error({ code: 'STREAM_FAILED', message: 'nope', retryable: true }) + }, + }) + + const cli = Cli.create('root', { + vars: z.object({ root: z.string().default('unset') }), + env: z.object({ API_KEY: z.string() }), + run() { + return { root: true } + }, + }) + cli.use(async (c, next) => { + order.push(`root:before:${c.env.API_KEY}`) + c.set('root', 'set') + await next() + order.push('root:after') + }) + cli.command('alias-target', { + aliases: ['alias'], + run() { + return { ok: true } + }, + }) + cli.command(child) + cli.command(router) + cli.command('raw', { fetch: () => new Response('{}') }) + return { cli, order, ctx: CommandTree.fromCli(cli) } +} + +describe('executeClientCommand', () => { + test('executes root, mounted root, and mounted router commands by canonical ID', async () => { + const { ctx, order } = createFixture() + + await expect( + executeClientCommand( + ctx, + { command: ' root ', args: {}, options: {} }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) + await expect( + executeClientCommand( + ctx, + { command: 'child', args: { id: 'c1' }, options: { loud: true } }, + { env: { API_KEY: 'k', TOKEN: 't' } }, + ), + ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) + await expect( + executeClientCommand( + ctx, + { command: 'project list', args: { projectId: 'p1' }, options: { limit: 1 } }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }, { id: 'b' }] }, + meta: { command: 'project list' }, + }) + + expect(order).toEqual([ + 'root:before:k', + 'root:after', + 'root:before:k', + 'child:true:c1:true:t', + 'root:after', + 'root:before:k', + 'group:before', + 'run:p1:1:set', + 'group:after', + 'root:after', + ]) + }) + + test('rejects invalid RPC shape, unknown commands, groups, aliases, and raw fetch gateways', async () => { + const { ctx } = createFixture() + await expect(executeClientCommand(ctx, { command: '' })).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) + await expect(executeClientCommand(ctx, { command: 'missing' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(executeClientCommand(ctx, { command: 'project' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_GROUP' }, + }) + await expect(executeClientCommand(ctx, { command: 'alias' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(executeClientCommand(ctx, { command: 'raw' })).resolves.toMatchObject({ + ok: false, + error: { code: 'FETCH_GATEWAY' }, + }) + }) + + test('validates structured args, options, CLI env, and command env independently', async () => { + const { ctx } = createFixture() + await expect( + executeClientCommand( + ctx, + { command: 'project list', args: {}, options: { limit: 1 } }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + executeClientCommand( + ctx, + { command: 'project list', args: { projectId: 'p' }, options: { limit: 'bad' } }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + executeClientCommand(ctx, { command: 'project list', args: { projectId: 'p' }, options: {} }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + executeClientCommand( + ctx, + { command: 'child', args: { id: 'c' }, options: {} }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + }) + + test('applies selection, formatting, token metadata, and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await executeClientCommand( + ctx, + { + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 4, + selection: ['items[0,1]'], + }, + { env: { API_KEY: 'k' } }, + ) + expect(response).toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }] }, + meta: { command: 'project list', nextOffset: 4, outputTokenCount: expect.any(Number) }, + output: { truncated: true }, + }) + }) + + test('rejects empty selections and omits token count unless requested', async () => { + const { ctx } = createFixture() + await expect( + executeClientCommand(ctx, { command: 'project list', selection: [] }), + ).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' } }) + await expect( + executeClientCommand( + ctx, + { command: 'project list', args: { projectId: 'p1' }, options: {} }, + { env: { API_KEY: 'k' } }, + ), + ).resolves.not.toMatchObject({ meta: { outputTokenCount: expect.any(Number) } }) + }) + + test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { + const { ctx, order } = createFixture() + const response = await executeClientCommand( + ctx, + { command: 'project stream' }, + { env: { API_KEY: 'k' } }, + ) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toMatchObject([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { type: 'done', ok: true, meta: { command: 'project stream', cta: expect.any(Object) } }, + ]) + + const failed = await executeClientCommand( + ctx, + { command: 'project fail-stream' }, + { env: { API_KEY: 'k' } }, + ) + if (!('stream' in failed)) throw new Error('expected stream') + const failedRecords: unknown[] = [] + for await (const record of failed.records()) failedRecords.push(record) + expect(failedRecords.at(-1)).toMatchObject({ + type: 'error', + ok: false, + error: { code: 'STREAM_FAILED', retryable: true }, + meta: { command: 'project fail-stream' }, + }) + + const cancelled = await executeClientCommand( + ctx, + { command: 'project stream' }, + { env: { API_KEY: 'k' } }, + ) + if (!('stream' in cancelled)) throw new Error('expected stream') + const iterator = cancelled.records() + await iterator.next() + await iterator.return(undefined as any) + expect(order).toContain('stream:return') + }) +}) diff --git a/src/internal/client-runtime.ts b/src/internal/client-runtime.ts new file mode 100644 index 0000000..ca1b7c4 --- /dev/null +++ b/src/internal/client-runtime.ts @@ -0,0 +1,379 @@ +import { estimateTokenCount, sliceByTokens } from 'tokenx' +import { z } from 'zod' + +import type { FieldError } from '../Errors.js' +import * as Filter from '../Filter.js' +import * as Formatter from '../Formatter.js' +import * as CommandTree from './command-tree.js' +import * as Command from './command.js' + +/** RPC request accepted by HTTP and memory transports. */ +export type RpcRequest = { + /** Canonical command ID. */ + command: string + /** Structured positional arguments. */ + args?: Record | undefined + /** Structured named options. */ + options?: Record | undefined + /** Output format for rendered text. */ + outputFormat?: Formatter.Format | undefined + /** Output selection paths. */ + selection?: string[] | undefined + /** Whether token metadata should be included. */ + outputTokenCount?: boolean | undefined + /** Maximum rendered output tokens to return. */ + outputTokenLimit?: number | undefined + /** Rendered output token offset. */ + outputTokenOffset?: number | undefined +} + +/** RPC output payload. */ +export type RpcOutput = { + /** Rendered output text. */ + text: string + /** Whether text was truncated by token controls. */ + truncated?: boolean | undefined +} + +/** RPC metadata. */ +export type RpcMeta = { + /** Canonical command ID. */ + command: string + /** Suggested next commands. */ + cta?: unknown | undefined + /** Wall-clock duration. */ + duration: string + /** Offset to request for the next token window. */ + nextOffset?: number | undefined + /** Rendered token count before truncation. */ + outputTokenCount?: number | undefined +} + +/** Full RPC success/error envelope. */ +export type RpcFullEnvelope = + | { + ok: true + data: unknown + output?: RpcOutput | undefined + meta: RpcMeta + } + | { + ok: false + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + } + meta: RpcMeta + } + +/** Non-streaming RPC response. */ +export type RpcResponse = RpcFullEnvelope + +/** Streaming RPC record. */ +export type RpcStreamRecord = + | { type: 'chunk'; data: unknown } + | ({ type: 'done' } & Extract) + | ({ type: 'error' } & Extract) + +/** Streaming RPC response. */ +export type RpcStreamResponse = { + stream: true + records(): AsyncGenerator +} + +const requestSchema = z.object({ + command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), + args: z.record(z.string(), z.unknown()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + outputFormat: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + selection: z.array(z.string().min(1)).nonempty().optional(), + outputTokenCount: z.boolean().optional(), + outputTokenLimit: z.number().int().nonnegative().optional(), + outputTokenOffset: z.number().int().nonnegative().optional(), +}) +const sentinel = Symbol.for('incur.sentinel') + +/** Executes a canonical client command through the shared runtime. */ +export async function executeClientCommand( + ctx: CommandTree.RuntimeCliContext, + request: unknown, + options: executeClientCommand.Options = {}, +): Promise { + const start = performance.now() + const parsed = requestSchema.safeParse(request) + if (!parsed.success) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'Invalid RPC request.', + fieldErrors: parsed.error.issues.map((issue) => ({ + code: issue.code, + expected: 'valid RPC request', + received: 'invalid', + message: issue.message, + path: issue.path.join('.'), + })), + }) + + const rpc = parsed.data + if (!rpc.command) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'RPC command is required.', + }) + + const resolved = CommandTree.resolveCanonical(ctx, rpc.command) + if ('error' in resolved) + return errorEnvelope(rpc.command, start, { + code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', + message: + resolved.error === 'empty' + ? 'RPC command is required.' + : `'${resolved.token}' is not a command for '${resolved.parent}'.`, + }) + if ('commands' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'COMMAND_GROUP', + message: `'${resolved.id}' is a command group. Specify a subcommand.`, + }) + if ('gateway' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'FETCH_GATEWAY', + message: `'${resolved.id}' is a raw fetch gateway and cannot be called with structured RPC.`, + }) + + const result = await Command.execute(resolved.command, { + agent: true, + argv: [], + env: ctx.env, + envSource: options.env, + format: rpc.outputFormat ?? 'json', + formatExplicit: true, + inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, + middlewares: resolved.middlewares, + name: ctx.name, + parseMode: 'structured', + path: resolved.id, + vars: ctx.vars, + version: ctx.version, + }) + + if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) + if (!result.ok) + return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta), rpc) + return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) +} + +export declare namespace executeClientCommand { + /** Execution options. */ + type Options = { + /** Explicit environment source. */ + env?: Record | undefined + } +} + +function streamResponse( + stream: AsyncGenerator, + command: string, + start: number, + request: RpcRequest, +): RpcStreamResponse { + return { + stream: true, + async *records() { + let terminal: RpcStreamRecord + try { + while (true) { + const { value, done } = await stream.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord( + command, + start, + sentinelError(value), + formatCta('', value.cta), + request, + ) + } else { + const data = isSentinel(value) ? value.data : undefined + terminal = { + type: 'done', + ...successEnvelope( + command, + start, + data, + formatCta('', isSentinel(value) ? value.cta : undefined), + request, + ), + } + } + yield terminal + return terminal + } + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord( + command, + start, + sentinelError(value), + formatCta('', value.cta), + request, + ) + yield terminal + return terminal + } + yield { type: 'chunk', data: value } + } + } catch (error) { + terminal = errorRecord( + command, + start, + { + code: 'UNKNOWN', + message: error instanceof Error ? error.message : String(error), + }, + undefined, + request, + ) + yield terminal + return terminal + } finally { + await stream.return(undefined).catch(() => undefined) + } + }, + } +} + +function successEnvelope( + command: string, + start: number, + data: unknown, + cta?: unknown | undefined, + request: RpcRequest = { command }, +): Extract { + const selected = applySelection(data, request.selection) + const output = renderOutput(selected, request) + return { + ok: true, + data: selected, + ...(output.text + ? { output: { text: output.text, ...(output.truncated ? { truncated: true } : undefined) } } + : undefined), + meta: meta(command, start, cta, output, request), + } +} + +function errorEnvelope( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta?: unknown | undefined, + request: RpcRequest = { command }, +): Extract { + return { + ok: false, + error, + meta: meta(command, start, cta, renderOutput(undefined, request), request), + } +} + +function errorRecord( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta: unknown | undefined, + request: RpcRequest, +): Extract { + return { type: 'error', ...errorEnvelope(command, start, error, cta, request) } +} + +function applySelection(data: unknown, selection: string[] | undefined) { + if (!selection?.length) return data + return Filter.apply( + data, + selection.flatMap((value) => Filter.parse(value)), + ) +} + +function renderOutput(data: unknown, request: RpcRequest) { + const text = Formatter.format(data, request.outputFormat ?? 'json') + const count = estimateTokenCount(text) + const offset = request.outputTokenOffset ?? 0 + if (request.outputTokenLimit === undefined && request.outputTokenOffset === undefined) + return { text, count, truncated: false } + const end = request.outputTokenLimit === undefined ? count : offset + request.outputTokenLimit + const sliced = sliceByTokens(text, offset, end) + return { + text: sliced, + count, + truncated: end < count, + nextOffset: end < count ? end : undefined, + } +} + +function meta( + command: string, + start: number, + cta: unknown | undefined, + output: { count: number; nextOffset?: number | undefined }, + request: RpcRequest, +): RpcMeta { + return { + command, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + ...(request.outputTokenCount ? { outputTokenCount: output.count } : undefined), + ...(output.nextOffset !== undefined ? { nextOffset: output.nextOffset } : undefined), + } +} + +function formatCta(name: string, block: unknown | undefined) { + if (!block || typeof block !== 'object' || !('commands' in block)) return undefined + const commands = (block as { commands: unknown[]; description?: string | undefined }).commands + if (commands.length === 0) return undefined + return { + description: + (block as { description?: string | undefined }).description ?? + (commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'), + commands: commands.map((command) => { + if (typeof command === 'string') return { command: name ? `${name} ${command}` : command } + if (typeof command === 'object' && command !== null && 'command' in command) return command + return { command: String(command) } + }), + } +} + +type SentinelValue = { + [sentinel]: 'ok' | 'error' + code?: string | undefined + cta?: unknown | undefined + data?: unknown | undefined + message?: string | undefined + retryable?: boolean | undefined +} + +function isSentinel(value: unknown): value is SentinelValue { + return typeof value === 'object' && value !== null && sentinel in value +} + +function sentinelError(value: { + code?: string | undefined + message?: string | undefined + retryable?: boolean | undefined +}) { + return { + code: value.code ?? 'UNKNOWN', + message: value.message ?? 'Command failed', + ...(value.retryable !== undefined ? { retryable: value.retryable } : undefined), + } +} diff --git a/src/internal/command-tree.test.ts b/src/internal/command-tree.test.ts new file mode 100644 index 0000000..cd621b5 --- /dev/null +++ b/src/internal/command-tree.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import * as CommandTree from './command-tree.js' + +describe('command-tree', () => { + test('collects canonical client command IDs and excludes aliases/raw gateways', () => { + const root = Cli.create('root', { + run() { + return null + }, + }) + const mounted = Cli.create('mounted', { + run() { + return null + }, + }) + const nested = Cli.create('nested').command('leaf', { + run() { + return null + }, + }) + const router = Cli.create('project').command(nested) + root.command('target', { + aliases: ['alias'], + run() { + return null + }, + }) + root.command('raw', { fetch: () => new Response('{}') }) + root.command(mounted) + root.command(router) + + const ctx = CommandTree.fromCli(root) + expect(CommandTree.collectClientCommands(ctx).map((entry) => entry.id)).toEqual([ + 'mounted', + 'project nested leaf', + 'root', + 'target', + ]) + expect(CommandTree.resolveCanonical(ctx, 'alias')).toMatchObject({ error: 'unknown' }) + expect(CommandTree.resolveCanonical(ctx, 'raw')).toMatchObject({ gateway: expect.any(Object) }) + }) + + test('includes OpenAPI-mounted operations without serving first', () => { + const cli = Cli.create('app').command('api', { + fetch: (req) => + new Response(JSON.stringify({ id: new URL(req.url).pathname.split('/').pop() }), { + headers: { 'content-type': 'application/json' }, + }), + openapi: { + paths: { + '/users/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: { + operationId: 'getUser', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + const command = CommandTree.collectClientCommands(CommandTree.fromCli(cli))[0]! + expect(command.id).toBe('api getUser') + expect(command.command.args?.shape.id).toBeDefined() + expect(command.command.output).toBeDefined() + }) + + test('builds separate input schemas', () => { + const command = { + args: z.object({ id: z.string() }), + env: z.object({ TOKEN: z.string() }), + options: z.object({ limit: z.number().optional() }), + run() {}, + } + expect(CommandTree.buildInputSchema(command)).toMatchObject({ + args: { properties: { id: { type: 'string' } } }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { properties: { limit: { type: 'number' } } }, + }) + }) +}) diff --git a/src/internal/command-tree.ts b/src/internal/command-tree.ts new file mode 100644 index 0000000..c5a05ef --- /dev/null +++ b/src/internal/command-tree.ts @@ -0,0 +1,236 @@ +import type { z } from 'zod' + +import * as Cli from '../Cli.js' +import type { Handler as MiddlewareHandler } from '../middleware.js' +import * as Schema from '../Schema.js' + +/** Runtime metadata needed to execute and discover a CLI command tree. */ +export type RuntimeCliContext = { + /** Command map registered on the CLI. */ + commands: Map + /** CLI description. */ + description?: string | undefined + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Middleware handlers registered on the root CLI. */ + middlewares?: MiddlewareHandler[] | undefined + /** Local MCP setup defaults. */ + mcp?: { agents?: string[] | undefined; command?: string | undefined } | undefined + /** CLI name. */ + name: string + /** Root command definition, when the CLI itself is callable. */ + rootCommand?: CommandDefinition | undefined + /** Local skill sync defaults. */ + sync?: + | { + cwd?: string | undefined + depth?: number | undefined + include?: string[] | undefined + suggestions?: string[] | undefined + } + | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + /** CLI version string. */ + version?: string | undefined +} + +/** Internal command entry shape shared by CLI consumers. */ +export type CommandEntry = CommandDefinition | CommandGroup | FetchGateway | CommandAlias + +/** Internal command definition shape. */ +export type CommandDefinition = { + alias?: Record | undefined + args?: z.ZodObject | undefined + description?: string | undefined + env?: z.ZodObject | undefined + examples?: unknown[] | undefined + hint?: string | undefined + middleware?: MiddlewareHandler[] | undefined + options?: z.ZodObject | undefined + output?: z.ZodType | undefined + outputPolicy?: Cli.OutputPolicy | undefined + run: Function + usage?: unknown[] | undefined +} + +/** Internal command group shape. */ +export type CommandGroup = { + _group: true + commands: Map + description?: string | undefined + middlewares?: MiddlewareHandler[] | undefined + outputPolicy?: Cli.OutputPolicy | undefined +} + +/** Internal raw fetch gateway shape. */ +export type FetchGateway = { + _fetch: true + basePath?: string | undefined + description?: string | undefined + fetch: (req: Request) => Response | Promise + outputPolicy?: Cli.OutputPolicy | undefined +} + +/** Internal alias entry shape. */ +export type CommandAlias = { + _alias: true + target: string +} + +/** Resolved callable command. */ +export type ResolvedCommand = { + command: CommandDefinition + id: string + middlewares: MiddlewareHandler[] +} + +/** Resolved command group. */ +export type ResolvedGroup = { + commands: Map + description?: string | undefined + id: string +} + +/** Resolved raw fetch gateway. */ +export type ResolvedFetchGateway = { + gateway: FetchGateway + id: string + middlewares: MiddlewareHandler[] +} + +/** Returns a runtime context for a CLI instance. */ +export function fromCli(cli: Cli.Cli): RuntimeCliContext { + const commands = Cli.toCommands.get(cli) + if (!commands) throw new Error('No commands registered on this CLI instance') + const version = Cli.toVersion.get(cli) + return { + commands: commands as Map, + ...(cli.description ? { description: cli.description } : undefined), + ...(cli.env ? { env: cli.env } : undefined), + middlewares: Cli.toMiddlewares.get(cli) ?? [], + ...(Cli.toMcpOptions.get(cli) ? { mcp: Cli.toMcpOptions.get(cli) } : undefined), + name: cli.name, + ...(Cli.toRootDefinition.get(cli as unknown as Cli.Root) + ? { rootCommand: Cli.toRootDefinition.get(cli as unknown as Cli.Root) as CommandDefinition } + : undefined), + ...(Cli.toSyncOptions.get(cli) ? { sync: Cli.toSyncOptions.get(cli) } : undefined), + ...(cli.vars ? { vars: cli.vars } : undefined), + ...(version !== undefined ? { version } : undefined), + } +} + +/** Returns true when an entry is an alias. */ +export function isAlias(entry: CommandEntry): entry is CommandAlias { + return '_alias' in entry +} + +/** Returns true when an entry is a command group. */ +export function isGroup(entry: CommandEntry): entry is CommandGroup { + return '_group' in entry +} + +/** Returns true when an entry is a raw fetch gateway. */ +export function isFetchGateway(entry: CommandEntry): entry is FetchGateway { + return '_fetch' in entry +} + +/** Resolves an alias entry within its owning command map. */ +export function resolveAlias( + commands: Map, + entry: CommandEntry, +): Exclude { + if (!isAlias(entry)) return entry + return commands.get(entry.target)! as Exclude +} + +/** Resolves a canonical command ID without accepting aliases. */ +export function resolveCanonical( + ctx: RuntimeCliContext, + command: string, +): + | ResolvedCommand + | ResolvedGroup + | ResolvedFetchGateway + | { error: 'empty' | 'unknown'; token?: string | undefined; parent: string } { + const id = command.trim().replace(/\s+/g, ' ') + if (!id) return { error: 'empty', parent: ctx.name } + if (ctx.rootCommand && id === ctx.name) + return { id, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] } + + let commands = ctx.commands + let entry: CommandEntry | undefined + let parent = ctx.name + const path: string[] = [] + const middlewares = [...(ctx.middlewares ?? [])] + + for (const token of id.split(' ')) { + entry = commands.get(token) + if (!entry || isAlias(entry)) return { error: 'unknown', token, parent } + path.push(token) + if (isGroup(entry)) { + middlewares.push(...(entry.middlewares ?? [])) + commands = entry.commands + parent = path.join(' ') + continue + } + if (path.join(' ') !== id) + return { error: 'unknown', token: id.split(' ')[path.length], parent } + } + + if (!entry) return { error: 'unknown', token: id, parent } + if (isGroup(entry)) return { id, commands: entry.commands, description: entry.description } + if (isFetchGateway(entry)) return { id, gateway: entry, middlewares } + if (isAlias(entry)) return { error: 'unknown', token: id, parent } + return { id, command: entry, middlewares: [...middlewares, ...(entry.middleware ?? [])] } +} + +/** Traverses callable client command entries. Aliases and raw fetch gateways are excluded. */ +export function collectClientCommands(ctx: RuntimeCliContext): ResolvedCommand[] { + const result: ResolvedCommand[] = [] + if (ctx.rootCommand) + result.push({ id: ctx.name, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] }) + collect(ctx.commands, [], ctx.middlewares ?? [], result) + return result.sort((a, b) => a.id.localeCompare(b.id)) +} + +function collect( + commands: Map, + prefix: string[], + middlewares: MiddlewareHandler[], + result: ResolvedCommand[], +) { + for (const [name, entry] of commands) { + if (isAlias(entry) || isFetchGateway(entry)) continue + const path = [...prefix, name] + if (isGroup(entry)) { + collect(entry.commands, path, [...middlewares, ...(entry.middlewares ?? [])], result) + continue + } + result.push({ + id: path.join(' '), + command: entry, + middlewares: [...middlewares, ...(entry.middleware ?? [])], + }) + } +} + +/** Builds the structured input schema used by discovery payloads. */ +export function buildInputSchema(command: CommandDefinition): + | { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } + | undefined { + if (!command.args && !command.env && !command.options) return undefined + const result: { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } = {} + if (command.args) result.args = Schema.toJsonSchema(command.args) + if (command.env) result.env = Schema.toJsonSchema(command.env) + if (command.options) result.options = Schema.toJsonSchema(command.options) + return result +} diff --git a/src/internal/command.ts b/src/internal/command.ts index c8f7f08..795f481 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -82,11 +82,19 @@ export async function execute(command: any, options: execute.Options): Promise + options?: Record + } + args = command.args ? Parser.zodParse(command.args, input.args ?? {}) : {} + parsedOptions = command.options ? Parser.zodParse(command.options, input.options ?? {}) : {} } // Parse env @@ -296,8 +304,9 @@ export declare namespace execute { * - `'argv'` (default): parse both args and options from argv tokens (CLI mode) * - `'split'`: args from argv, options from inputOptions (HTTP mode) * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode) + * - `'structured'`: inputOptions contains separate args/options objects (RPC mode) */ - parseMode?: 'argv' | 'split' | 'flat' | undefined + parseMode?: 'argv' | 'split' | 'flat' | 'structured' | undefined /** The resolved command path. */ path: string /** Vars schema for middleware variables. */ From 6a71ed4db1213f117e9fdc5c14cdc0e0c21560c4 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 10:14:12 +0200 Subject: [PATCH 07/55] fix: keep runtime foundation typegen output unchanged --- src/Typegen.test.ts | 98 +++++++++++---------------------------------- src/Typegen.ts | 45 ++++----------------- src/e2e.test.ts | 55 +++++++++++++------------ 3 files changed, 59 insertions(+), 139 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 2f879c3..e6402c0 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -13,14 +13,12 @@ describe('fromCli', () => { }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'get': { args: { id: number }; options: {}; output: {} } - 'list': { args: {}; options: { limit: number }; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'get': { args: { id: number }; options: {} } + 'list': { args: {}; options: { limit: number } } + } } } " @@ -31,13 +29,11 @@ describe('fromCli', () => { const cli = Cli.create('test').command('ping', { run: () => ({}) }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'ping': { args: {}; options: {}; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'ping': { args: {}; options: {} } + } } } " @@ -58,14 +54,12 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'pr create': { args: { title: string }; options: {}; output: {} } - 'pr list': { args: {}; options: { state: string }; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'pr create': { args: { title: string }; options: {} } + 'pr list': { args: {}; options: { state: string } } + } } } " @@ -83,13 +77,11 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'pr review approve': { args: { id: number }; options: {}; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'pr review approve': { args: { id: number }; options: {} } + } } } " @@ -126,46 +118,6 @@ describe('fromCli', () => { expect(output).toContain('tags: string[]') }) - test('scalar output schema', () => { - const cli = Cli.create('test').command('read', { - output: z.string(), - run: () => 'content', - }) - - expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'read': { args: {}; options: {}; output: string } - } - - declare module 'incur' { - interface Register { - commands: Commands - } - } - " - `) - }) - - test('array output schema', () => { - const cli = Cli.create('test').command('list', { - output: z.array(z.object({ id: z.string(), active: z.boolean() })), - run: () => [{ id: 'one', active: true }], - }) - - expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'list': { args: {}; options: {}; output: { id: string; active: boolean }[] } - } - - declare module 'incur' { - interface Register { - commands: Commands - } - } - " - `) - }) - test('commands are sorted alphabetically', () => { const cli = Cli.create('test') .command('zebra', { run: () => ({}) }) @@ -173,7 +125,7 @@ describe('fromCli', () => { .command('middle', { run: () => ({}) }) const output = Typegen.fromCli(cli) - const commandOrder = [...output.matchAll(/^ '(\w+)':/gm)].map((m) => m[1]) + const commandOrder = [...output.matchAll(/^ {6}'(\w+)':/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -239,14 +191,12 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "export type Commands = { - 'ping': { args: {}; options: {}; output: {} } - 'pr list': { args: {}; options: {}; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'ping': { args: {}; options: {} } + 'pr list': { args: {}; options: {} } + } } } " diff --git a/src/Typegen.ts b/src/Typegen.ts index 335d9aa..2bed6a8 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -17,23 +17,14 @@ export function fromCli(cli: Cli.Cli): string { const entries = collectEntries(commands, []) - const lines: string[] = ['export type Commands = {'] + const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] - for (const { name, args, options, output } of entries) + for (const { name, args, options } of entries) lines.push( - ` '${name}': { args: ${schemaToObjectType(args)}; options: ${schemaToObjectType(options)}; output: ${schemaToType(output)} }`, + ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`, ) - lines.push( - '}', - '', - "declare module 'incur' {", - ' interface Register {', - ' commands: Commands', - ' }', - '}', - '', - ) + lines.push(' }', ' }', '}', '') return lines.join('\n') } @@ -41,38 +32,18 @@ export function fromCli(cli: Cli.Cli): string { function collectEntries( commands: Map, prefix: string[], -): { - name: string - args?: z.ZodObject | undefined - options?: z.ZodObject | undefined - output?: z.ZodType | undefined -}[] { +): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] { const result: ReturnType = [] for (const [name, entry] of commands) { const path = [...prefix, name] - if ('_alias' in entry || '_fetch' in entry) continue if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) - else - result.push({ - name: path.join(' '), - args: entry.args, - options: entry.options, - output: entry.output, - }) + else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) } return result.sort((a, b) => a.name.localeCompare(b.name)) } -/** Converts a Zod output schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function schemaToType(schema: z.ZodType | undefined): string { - if (!schema) return '{}' - const json = z.toJSONSchema(schema) as Record - const defs = (json.$defs ?? {}) as Record> - return resolveType(json, defs) -} - -/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined or empty schemas. */ -function schemaToObjectType(schema: z.ZodObject | undefined): string { +/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ +function schemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' const json = z.toJSONSchema(schema) as Record const defs = (json.$defs ?? {}) as Record> diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 71d8017..4cd126c 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1601,35 +1601,34 @@ describe('--llms', () => { describe('typegen', () => { test('generates correct .d.ts for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` - "export type Commands = { - 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] }; output: {} } - 'auth logout': { args: {}; options: {}; output: {} } - 'auth status': { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } - 'config': { args: { key?: string }; options: {}; output: {} } - 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string }; output: {} } - 'explode': { args: {}; options: {}; output: {} } - 'explode-clac': { args: {}; options: {}; output: {} } - 'noop': { args: {}; options: {}; output: {} } - 'ping': { args: {}; options: {}; output: {} } - 'project create': { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } - 'project delete': { args: { id: string }; options: { force: boolean }; output: {} } - '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: {}; output: {} } - '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: {}; output: {} } - 'stream': { args: {}; options: {}; output: {} } - 'stream-error': { args: {}; options: {}; output: {} } - 'stream-ok': { args: {}; options: {}; output: {} } - 'stream-text': { args: {}; options: {}; output: {} } - 'stream-throw': { args: {}; options: {}; output: {} } - 'validate-fail': { args: { email: string; age: number }; options: {}; output: {} } - } - - declare module 'incur' { + "declare module 'incur' { interface Register { - commands: Commands + commands: { + 'api': { args: {}; options: {} } + 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } + 'auth logout': { args: {}; options: {} } + 'auth status': { args: {}; options: {} } + 'config': { args: { key?: string }; options: {} } + 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string } } + 'explode': { args: {}; options: {} } + 'explode-clac': { args: {}; options: {} } + 'noop': { args: {}; options: {} } + 'ping': { args: {}; options: {} } + 'project create': { args: { name: string }; options: { description: string; private: boolean } } + 'project delete': { args: { id: string }; options: { force: boolean } } + 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } } + 'project deploy rollback': { args: { deployId: string }; options: {} } + 'project deploy status': { args: { deployId: string }; options: {} } + 'project get': { args: { id: string }; options: {} } + 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } } + 'slow': { args: {}; options: {} } + 'stream': { args: {}; options: {} } + 'stream-error': { args: {}; options: {} } + 'stream-ok': { args: {}; options: {} } + 'stream-text': { args: {}; options: {} } + 'stream-throw': { args: {}; options: {} } + 'validate-fail': { args: { email: string; age: number }; options: {} } + } } } " From 0e0fac33b733ce06fe032608221881fad89e70b5 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 10:21:56 +0200 Subject: [PATCH 08/55] test: harden streaming duration snapshots --- src/Cli.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Cli.ts b/src/Cli.ts index 1e0d9ef..618cc30 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -2585,6 +2585,26 @@ export const toConfigEnabled = new WeakMap() /** @internal Maps CLI instances to their output policy. */ const toOutputPolicy = new WeakMap() +/** @internal Maps CLI instances to MCP setup options. */ +export const toMcpOptions = new WeakMap< + Cli, + { agents?: string[] | undefined; command?: string | undefined } +>() + +/** @internal Maps CLI instances to skill sync options. */ +export const toSyncOptions = new WeakMap< + Cli, + { + cwd?: string | undefined + depth?: number | undefined + include?: string[] | undefined + suggestions?: string[] | undefined + } +>() + +/** @internal Maps CLI instances to their version strings. */ +export const toVersion = new WeakMap() + /** @internal Maps root CLI instances to their command aliases. */ const toRootAliases = new WeakMap() From 49722677d752e641f31751db4a124421fdb6e3b1 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 12:23:46 +0200 Subject: [PATCH 09/55] refactor: flatten client transport capabilities --- src/client/transports/createTransport.ts | 14 +++--- src/client/transports/http.test.ts | 4 +- src/client/transports/http.ts | 56 ++++++++++++------------ src/client/transports/memory.test.ts | 10 ++--- src/client/transports/memory.ts | 14 +++--- 5 files changed, 46 insertions(+), 52 deletions(-) diff --git a/src/client/transports/createTransport.ts b/src/client/transports/createTransport.ts index 240df0f..af090ea 100644 --- a/src/client/transports/createTransport.ts +++ b/src/client/transports/createTransport.ts @@ -17,13 +17,11 @@ export type TransportConfig = { type: type } -/** Transport value object. */ -export type TransportValue = Record +/** Transport capabilities exposed by a resolved transport. */ +export type TransportCapabilities = Record /** Transport factory. */ -export type TransportFactory = ( - context: TransportContext, -) => { - config: TransportConfig - value: value -} +export type TransportFactory< + type extends TransportType, + capabilities extends TransportCapabilities, +> = (context: TransportContext) => { config: TransportConfig } & capabilities diff --git a/src/client/transports/http.test.ts b/src/client/transports/http.test.ts index 861eeb7..6719d03 100644 --- a/src/client/transports/http.test.ts +++ b/src/client/transports/http.test.ts @@ -4,7 +4,7 @@ import { ClientError } from '../errors.js' import { httpTransport } from './http.js' function resolve(fetch: typeof globalThis.fetch) { - return httpTransport({ baseUrl: 'https://example.com/api/', fetch })({ uid: 'u' }).value + return httpTransport({ baseUrl: 'https://example.com/api/', fetch })({ uid: 'u' }) } function ndjson(lines: string[], options: { cancel?: () => void } = {}) { @@ -42,7 +42,7 @@ describe('httpTransport', () => { baseUrl: 'https://example.com/api', fetch, headers: { 'x-custom': 'yes' }, - })({ uid: 'u' }).value + })({ uid: 'u' }) await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: 1, diff --git a/src/client/transports/http.ts b/src/client/transports/http.ts index a6e83c3..461189d 100644 --- a/src/client/transports/http.ts +++ b/src/client/transports/http.ts @@ -36,35 +36,33 @@ export function httpTransport(options: HttpTransportOptions): HttpTransport { return () => ({ config: { key: 'http', name: 'HTTP', type: 'http' }, - value: { - baseUrl, - async request(request) { - const response = await requestFetch(fetcher, url(baseUrl, '_incur/rpc'), { - method: 'POST', - headers: headers(options.headers, { - accept: 'application/json, application/x-ndjson', - 'content-type': 'application/json', - }), - body: JSON.stringify({ - ...request, - args: request.args ?? {}, - options: request.options ?? {}, - }), - }) - return parseRpcResponse(response) - }, - async discover(request) { - const response = await requestFetch(fetcher, discoveryUrl(baseUrl, request), { - method: 'GET', - headers: headers(options.headers, { - accept: 'application/json, text/plain, text/markdown', - }), - }) - const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('application/json')) - return { contentType: essence(contentType), data: await parseJson(response) } - return { contentType: essence(contentType), body: await response.text() } - }, + baseUrl, + async request(request) { + const response = await requestFetch(fetcher, url(baseUrl, '_incur/rpc'), { + method: 'POST', + headers: headers(options.headers, { + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }), + body: JSON.stringify({ + ...request, + args: request.args ?? {}, + options: request.options ?? {}, + }), + }) + return parseRpcResponse(response) + }, + async discover(request) { + const response = await requestFetch(fetcher, discoveryUrl(baseUrl, request), { + method: 'GET', + headers: headers(options.headers, { + accept: 'application/json, text/plain, text/markdown', + }), + }) + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) + return { contentType: essence(contentType), data: await parseJson(response) } + return { contentType: essence(contentType), body: await response.text() } }, }) } diff --git a/src/client/transports/memory.test.ts b/src/client/transports/memory.test.ts index efa46df..ab5f291 100644 --- a/src/client/transports/memory.test.ts +++ b/src/client/transports/memory.test.ts @@ -18,7 +18,7 @@ describe('memoryTransport', () => { throw new Error('fetch should not be called') } - const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })({ uid: 'u' }).value + const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })({ uid: 'u' }) await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { token: 'secret' }, @@ -32,7 +32,7 @@ describe('memoryTransport', () => { return c.options }, }) - const transport = memoryTransport(cli)({ uid: 'u' }).value + const transport = memoryTransport(cli)({ uid: 'u' }) await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { name: 'runtime' }, @@ -45,7 +45,7 @@ describe('memoryTransport', () => { return { version: c.version } }, }) - const transport = memoryTransport(cli)({ uid: 'u' }).value + const transport = memoryTransport(cli)({ uid: 'u' }) await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { version: '1.2.3' }, @@ -63,7 +63,7 @@ describe('memoryTransport', () => { return { ok: true } }, }) - const transport = memoryTransport(cli)({ uid: 'u' }).value + const transport = memoryTransport(cli)({ uid: 'u' }) await expect( transport.discover({ resource: 'help', command: 'status' }), ).resolves.toMatchObject({ @@ -86,7 +86,7 @@ describe('memoryTransport', () => { test('exposes memory-only local capability', () => { const cli = Cli.create('app') - const transport = memoryTransport(cli)({ uid: 'u' }).value + const transport = memoryTransport(cli)({ uid: 'u' }) expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) expect(typeof transport.local.skills.add).toBe('function') expect(typeof transport.local.skills.list).toBe('function') diff --git a/src/client/transports/memory.ts b/src/client/transports/memory.ts index 7659303..a51cc70 100644 --- a/src/client/transports/memory.ts +++ b/src/client/transports/memory.ts @@ -39,15 +39,13 @@ export function memoryTransport( const ctx = CommandTree.fromCli(cli) return { config: { key: 'memory', name: 'Memory', type: 'memory' }, - value: { - request(request) { - return executeClientCommand(ctx, request, { env: options.env }) - }, - discover(request) { - return discoverClientResource(ctx, request) - }, - local: createLocalRuntime(ctx), + request(request) { + return executeClientCommand(ctx, request, { env: options.env }) }, + discover(request) { + return discoverClientResource(ctx, request) + }, + local: createLocalRuntime(ctx), } } } From f528bdba39225f7ebc6d797c219160d6f110bc83 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 12:37:49 +0200 Subject: [PATCH 10/55] refactor: remove unused transport context --- src/client/transports/createTransport.ts | 8 +------- src/client/transports/http.test.ts | 4 ++-- src/client/transports/memory.test.ts | 10 +++++----- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/client/transports/createTransport.ts b/src/client/transports/createTransport.ts index af090ea..6202eff 100644 --- a/src/client/transports/createTransport.ts +++ b/src/client/transports/createTransport.ts @@ -1,9 +1,3 @@ -/** Transport context supplied when resolving a transport factory. */ -export type TransportContext = { - /** Client uid. */ - uid: string -} - /** Transport type names. */ export type TransportType = 'http' | 'memory' @@ -24,4 +18,4 @@ export type TransportCapabilities = Record export type TransportFactory< type extends TransportType, capabilities extends TransportCapabilities, -> = (context: TransportContext) => { config: TransportConfig } & capabilities +> = () => { config: TransportConfig } & capabilities diff --git a/src/client/transports/http.test.ts b/src/client/transports/http.test.ts index 6719d03..b3ff4d7 100644 --- a/src/client/transports/http.test.ts +++ b/src/client/transports/http.test.ts @@ -4,7 +4,7 @@ import { ClientError } from '../errors.js' import { httpTransport } from './http.js' function resolve(fetch: typeof globalThis.fetch) { - return httpTransport({ baseUrl: 'https://example.com/api/', fetch })({ uid: 'u' }) + return httpTransport({ baseUrl: 'https://example.com/api/', fetch })() } function ndjson(lines: string[], options: { cancel?: () => void } = {}) { @@ -42,7 +42,7 @@ describe('httpTransport', () => { baseUrl: 'https://example.com/api', fetch, headers: { 'x-custom': 'yes' }, - })({ uid: 'u' }) + })() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: 1, diff --git a/src/client/transports/memory.test.ts b/src/client/transports/memory.test.ts index ab5f291..0af0864 100644 --- a/src/client/transports/memory.test.ts +++ b/src/client/transports/memory.test.ts @@ -18,7 +18,7 @@ describe('memoryTransport', () => { throw new Error('fetch should not be called') } - const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })({ uid: 'u' }) + const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { token: 'secret' }, @@ -32,7 +32,7 @@ describe('memoryTransport', () => { return c.options }, }) - const transport = memoryTransport(cli)({ uid: 'u' }) + const transport = memoryTransport(cli)() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { name: 'runtime' }, @@ -45,7 +45,7 @@ describe('memoryTransport', () => { return { version: c.version } }, }) - const transport = memoryTransport(cli)({ uid: 'u' }) + const transport = memoryTransport(cli)() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { version: '1.2.3' }, @@ -63,7 +63,7 @@ describe('memoryTransport', () => { return { ok: true } }, }) - const transport = memoryTransport(cli)({ uid: 'u' }) + const transport = memoryTransport(cli)() await expect( transport.discover({ resource: 'help', command: 'status' }), ).resolves.toMatchObject({ @@ -86,7 +86,7 @@ describe('memoryTransport', () => { test('exposes memory-only local capability', () => { const cli = Cli.create('app') - const transport = memoryTransport(cli)({ uid: 'u' }) + const transport = memoryTransport(cli)() expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) expect(typeof transport.local.skills.add).toBe('function') expect(typeof transport.local.skills.list).toBe('function') From aca3ab7b8ecdb0939a3eaa0d4d643447f2dea50c Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 13:43:15 +0200 Subject: [PATCH 11/55] test: keep client runtime OpenAPI fixture scoped --- src/internal/command-tree.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/command-tree.test.ts b/src/internal/command-tree.test.ts index cd621b5..d7d0361 100644 --- a/src/internal/command-tree.test.ts +++ b/src/internal/command-tree.test.ts @@ -52,9 +52,9 @@ describe('command-tree', () => { openapi: { paths: { '/users/{id}': { - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], get: { operationId: 'getUser', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { content: { From 5f92ad8989178833272d896097e0de1361d2b6b1 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 20:45:56 +0200 Subject: [PATCH 12/55] refactor(client): namespace transport modules --- src/client/{errors.ts => ClientError.ts} | 0 src/client/index.ts | 10 ++++------ .../{http.test.ts => HttpTransport.test.ts} | 10 +++++----- .../transports/{http.ts => HttpTransport.ts} | 10 +++++----- .../{memory.test.ts => MemoryTransport.test.ts} | 14 +++++++------- .../transports/{memory.ts => MemoryTransport.ts} | 11 ++++------- .../{createTransport.ts => Transport.ts} | 11 +++++------ 7 files changed, 30 insertions(+), 36 deletions(-) rename src/client/{errors.ts => ClientError.ts} (100%) rename src/client/transports/{http.test.ts => HttpTransport.test.ts} (95%) rename src/client/transports/{http.ts => HttpTransport.ts} (96%) rename src/client/transports/{memory.test.ts => MemoryTransport.test.ts} (88%) rename src/client/transports/{memory.ts => MemoryTransport.ts} (81%) rename src/client/transports/{createTransport.ts => Transport.ts} (54%) diff --git a/src/client/errors.ts b/src/client/ClientError.ts similarity index 100% rename from src/client/errors.ts rename to src/client/ClientError.ts diff --git a/src/client/index.ts b/src/client/index.ts index bd36333..0cb45db 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,4 @@ -export { ClientError } from './errors.js' -export { httpTransport } from './transports/http.js' -export { memoryTransport } from './transports/memory.js' +export { ClientError } from './ClientError.js' export type { DiscoveryRequest, DiscoveryResponse } from '../internal/client-discovery.js' export type { RpcFullEnvelope as ClientRpcEnvelope, @@ -10,6 +8,6 @@ export type { RpcStreamRecord, RpcStreamResponse, } from '../internal/client-runtime.js' -export type { HttpTransport, HttpTransportOptions } from './transports/http.js' -export type { MemoryTransport, MemoryTransportOptions } from './transports/memory.js' -export type { TransportFactory } from './transports/createTransport.js' +export * as HttpTransport from './transports/HttpTransport.js' +export * as MemoryTransport from './transports/MemoryTransport.js' +export * as Transport from './transports/Transport.js' diff --git a/src/client/transports/http.test.ts b/src/client/transports/HttpTransport.test.ts similarity index 95% rename from src/client/transports/http.test.ts rename to src/client/transports/HttpTransport.test.ts index b3ff4d7..992f8aa 100644 --- a/src/client/transports/http.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test, vi } from 'vitest' -import { ClientError } from '../errors.js' -import { httpTransport } from './http.js' +import { ClientError } from '../ClientError.js' +import * as HttpTransport from './HttpTransport.js' function resolve(fetch: typeof globalThis.fetch) { - return httpTransport({ baseUrl: 'https://example.com/api/', fetch })() + return HttpTransport.create({ baseUrl: 'https://example.com/api/', fetch })() } function ndjson(lines: string[], options: { cancel?: () => void } = {}) { @@ -21,7 +21,7 @@ function ndjson(lines: string[], options: { cancel?: () => void } = {}) { }) } -describe('httpTransport', () => { +describe('HttpTransport', () => { test('normalizes base URL, serializes omitted args/options, and merges headers', async () => { const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { expect(String(input)).toBe('https://example.com/api/_incur/rpc') @@ -38,7 +38,7 @@ describe('httpTransport', () => { }, ) }) as typeof globalThis.fetch - const transport = httpTransport({ + const transport = HttpTransport.create({ baseUrl: 'https://example.com/api', fetch, headers: { 'x-custom': 'yes' }, diff --git a/src/client/transports/http.ts b/src/client/transports/HttpTransport.ts similarity index 96% rename from src/client/transports/http.ts rename to src/client/transports/HttpTransport.ts index 461189d..1f8f6aa 100644 --- a/src/client/transports/http.ts +++ b/src/client/transports/HttpTransport.ts @@ -5,11 +5,11 @@ import type { RpcStreamRecord, RpcStreamResponse, } from '../../internal/client-runtime.js' -import { ClientError } from '../errors.js' -import type { TransportFactory } from './createTransport.js' +import { ClientError } from '../ClientError.js' +import type * as Transport from './Transport.js' /** HTTP transport factory. */ -export type HttpTransport = TransportFactory< +export type HttpTransport = Transport.Factory< 'http', { baseUrl: URL @@ -19,7 +19,7 @@ export type HttpTransport = TransportFactory< > /** HTTP transport options. */ -export type HttpTransportOptions = { +export type Options = { /** Base URL for the served CLI. */ baseUrl: string | URL /** Fetch implementation. Defaults to globalThis.fetch. */ @@ -29,7 +29,7 @@ export type HttpTransportOptions = { } /** Creates an HTTP transport. */ -export function httpTransport(options: HttpTransportOptions): HttpTransport { +export function create(options: Options): HttpTransport { const fetcher = options.fetch ?? globalThis.fetch if (!fetcher) throw new ClientError('No fetch implementation is available.') const baseUrl = new URL(options.baseUrl) diff --git a/src/client/transports/memory.test.ts b/src/client/transports/MemoryTransport.test.ts similarity index 88% rename from src/client/transports/memory.test.ts rename to src/client/transports/MemoryTransport.test.ts index 0af0864..e7bc55c 100644 --- a/src/client/transports/memory.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' import * as Cli from '../../Cli.js' -import { memoryTransport } from './memory.js' +import * as MemoryTransport from './MemoryTransport.js' -describe('memoryTransport', () => { +describe('MemoryTransport', () => { test('executes through shared runtime without calling cli.fetch and uses explicit env', async () => { const cli = Cli.create('app', { env: z.object({ TOKEN: z.string() }), @@ -18,7 +18,7 @@ describe('memoryTransport', () => { throw new Error('fetch should not be called') } - const transport = memoryTransport(cli, { env: { TOKEN: 'secret' } })() + const transport = MemoryTransport.create(cli, { env: { TOKEN: 'secret' } })() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { token: 'secret' }, @@ -32,7 +32,7 @@ describe('memoryTransport', () => { return c.options }, }) - const transport = memoryTransport(cli)() + const transport = MemoryTransport.create(cli)() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { name: 'runtime' }, @@ -45,7 +45,7 @@ describe('memoryTransport', () => { return { version: c.version } }, }) - const transport = memoryTransport(cli)() + const transport = MemoryTransport.create(cli)() await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, data: { version: '1.2.3' }, @@ -63,7 +63,7 @@ describe('memoryTransport', () => { return { ok: true } }, }) - const transport = memoryTransport(cli)() + const transport = MemoryTransport.create(cli)() await expect( transport.discover({ resource: 'help', command: 'status' }), ).resolves.toMatchObject({ @@ -86,7 +86,7 @@ describe('memoryTransport', () => { test('exposes memory-only local capability', () => { const cli = Cli.create('app') - const transport = memoryTransport(cli)() + const transport = MemoryTransport.create(cli)() expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) expect(typeof transport.local.skills.add).toBe('function') expect(typeof transport.local.skills.list).toBe('function') diff --git a/src/client/transports/memory.ts b/src/client/transports/MemoryTransport.ts similarity index 81% rename from src/client/transports/memory.ts rename to src/client/transports/MemoryTransport.ts index a51cc70..75a7aad 100644 --- a/src/client/transports/memory.ts +++ b/src/client/transports/MemoryTransport.ts @@ -12,10 +12,10 @@ import { type RpcStreamResponse, } from '../../internal/client-runtime.js' import * as CommandTree from '../../internal/command-tree.js' -import type { TransportFactory } from './createTransport.js' +import type * as Transport from './Transport.js' /** Memory transport factory. */ -export type MemoryTransport = TransportFactory< +export type MemoryTransport = Transport.Factory< 'memory', { request(request: RpcRequest): Promise @@ -25,16 +25,13 @@ export type MemoryTransport = TransportFactory< > /** Memory transport options. */ -export type MemoryTransportOptions = { +export type Options = { /** Explicit environment source. */ env?: Record | undefined } /** Creates an in-process memory transport. */ -export function memoryTransport( - cli: Cli.Cli, - options: MemoryTransportOptions = {}, -): MemoryTransport { +export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { return () => { const ctx = CommandTree.fromCli(cli) return { diff --git a/src/client/transports/createTransport.ts b/src/client/transports/Transport.ts similarity index 54% rename from src/client/transports/createTransport.ts rename to src/client/transports/Transport.ts index 6202eff..f91a989 100644 --- a/src/client/transports/createTransport.ts +++ b/src/client/transports/Transport.ts @@ -2,7 +2,7 @@ export type TransportType = 'http' | 'memory' /** Transport configuration. */ -export type TransportConfig = { +export type Config = { /** Stable transport key. */ key: string /** Human-readable transport name. */ @@ -12,10 +12,9 @@ export type TransportConfig = { } /** Transport capabilities exposed by a resolved transport. */ -export type TransportCapabilities = Record +export type Capabilities = Record /** Transport factory. */ -export type TransportFactory< - type extends TransportType, - capabilities extends TransportCapabilities, -> = () => { config: TransportConfig } & capabilities +export type Factory = () => { + config: Config +} & capabilities From a548dc64a04069f8ea14f766a0e241c149dc1d6f Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Tue, 26 May 2026 22:10:46 +0200 Subject: [PATCH 13/55] test(client): expand transport route coverage --- src/Cli.test.ts | 175 ++++++++- src/client-routes.test.ts | 81 ---- src/client/transports/HttpTransport.test.ts | 361 +++++++++++++++--- src/client/transports/HttpTransport.ts | 4 +- src/client/transports/MemoryTransport.test.ts | 248 ++++++++++-- 5 files changed, 714 insertions(+), 155 deletions(-) delete mode 100644 src/client-routes.test.ts diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 81fe966..33c20b8 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4175,7 +4175,7 @@ describe('Command.execute', () => { async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() - body.meta.duration = '' + if (body.meta?.duration) body.meta.duration = '' return { status: res.status, body } } @@ -4243,6 +4243,179 @@ describe('fetch', () => { expect(res.body.error.message).toContain("Did you mean 'health'?") }) + test('RPC route maps protocol failures to HTTP statuses', async () => { + const cli = Cli.create('app').command( + Cli.create('group').command('leaf', { + run() { + return null + }, + }), + ) + cli.command('raw', { fetch: () => new Response('{}') }) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: '' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "INVALID_RPC_REQUEST", + "message": "RPC command is required.", + }, + "meta": { + "command": "", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'group' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_GROUP", + "message": "'group' is a command group. Specify a subcommand.", + }, + "meta": { + "command": "group", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'raw' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "FETCH_GATEWAY", + "message": "'raw' is a raw fetch gateway and cannot be called with structured RPC.", + }, + "meta": { + "command": "raw", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'missing' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "'missing' is not a command for 'app'.", + }, + "meta": { + "command": "missing", + "duration": "", + }, + "ok": false, + }, + "status": 404, + } + `) + }) + + test('discovery routes map failures to envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/help?command=missing'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "Unknown command 'missing'.", + }, + "meta": { + "duration": "", + "resource": "help", + }, + "ok": false, + }, + "status": 404, + } + `) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=../x'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "INVALID_SKILL_NAME", + "message": "Unsafe skill name.", + }, + "meta": { + "duration": "", + "resource": "skill", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=missing'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "SKILL_NOT_FOUND", + "message": "Unknown skill 'missing'.", + }, + "meta": { + "duration": "", + "resource": "skill", + }, + "ok": false, + }, + "status": 404, + } + `) + }) + test('GET / with root command → 200', async () => { const cli = Cli.create('test', { run: () => ({ root: true }) }) expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(` diff --git a/src/client-routes.test.ts b/src/client-routes.test.ts deleted file mode 100644 index fb53fe3..0000000 --- a/src/client-routes.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import * as Cli from './Cli.js' - -async function json(response: Response) { - return response.json() as Promise -} - -describe('client HTTP routes', () => { - test('maps RPC protocol failures to precise HTTP statuses', async () => { - const cli = Cli.create('app').command( - Cli.create('group').command('leaf', { - run() { - return null - }, - }), - ) - cli.command('raw', { fetch: () => new Response('{}') }) - - const invalid = await cli.fetch( - new Request('http://localhost/_incur/rpc', { - method: 'POST', - body: JSON.stringify({ command: '' }), - }), - ) - expect(invalid.status).toBe(400) - expect(await json(invalid)).toMatchObject({ error: { code: 'INVALID_RPC_REQUEST' } }) - - const group = await cli.fetch( - new Request('http://localhost/_incur/rpc', { - method: 'POST', - body: JSON.stringify({ command: 'group' }), - }), - ) - expect(group.status).toBe(400) - expect(await json(group)).toMatchObject({ error: { code: 'COMMAND_GROUP' } }) - - const raw = await cli.fetch( - new Request('http://localhost/_incur/rpc', { - method: 'POST', - body: JSON.stringify({ command: 'raw' }), - }), - ) - expect(raw.status).toBe(400) - expect(await json(raw)).toMatchObject({ error: { code: 'FETCH_GATEWAY' } }) - - const missing = await cli.fetch( - new Request('http://localhost/_incur/rpc', { - method: 'POST', - body: JSON.stringify({ command: 'missing' }), - }), - ) - expect(missing.status).toBe(404) - expect(await json(missing)).toMatchObject({ error: { code: 'COMMAND_NOT_FOUND' } }) - }) - - test('maps discovery failures to precise envelopes', async () => { - const cli = Cli.create('app').command('status', { - run() { - return { ok: true } - }, - }) - - const unknownCommand = await cli.fetch( - new Request('http://localhost/_incur/help?command=missing'), - ) - expect(unknownCommand.status).toBe(404) - expect(await json(unknownCommand)).toMatchObject({ - error: { code: 'COMMAND_NOT_FOUND' }, - meta: { resource: 'help' }, - }) - - const unsafeSkill = await cli.fetch(new Request('http://localhost/_incur/skill?name=../x')) - expect(unsafeSkill.status).toBe(400) - expect(await json(unsafeSkill)).toMatchObject({ error: { code: 'INVALID_SKILL_NAME' } }) - - const unknownSkill = await cli.fetch(new Request('http://localhost/_incur/skill?name=missing')) - expect(unknownSkill.status).toBe(404) - expect(await json(unknownSkill)).toMatchObject({ error: { code: 'SKILL_NOT_FOUND' } }) - }) -}) diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index 992f8aa..1baa4e6 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test, vi } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' +import * as Cli from '../../Cli.js' +import type { DiscoveryRequest } from '../../internal/client-discovery.js' import { ClientError } from '../ClientError.js' import * as HttpTransport from './HttpTransport.js' @@ -7,6 +11,22 @@ function resolve(fetch: typeof globalThis.fetch) { return HttpTransport.create({ baseUrl: 'https://example.com/api/', fetch })() } +function connect(cli: Cli.Cli, options: Partial = {}) { + const requests: { input: RequestInfo | URL; init: RequestInit | undefined }[] = [] + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ input, init }) + return cli.fetch(new Request(input, init)) + } + return { + requests, + transport: HttpTransport.create({ + baseUrl: 'https://example.com/', + ...options, + fetch, + })(), + } +} + function ndjson(lines: string[], options: { cancel?: () => void } = {}) { const encoder = new TextEncoder() const source: UnderlyingDefaultSource = { @@ -22,30 +42,52 @@ function ndjson(lines: string[], options: { cancel?: () => void } = {}) { } describe('HttpTransport', () => { - test('normalizes base URL, serializes omitted args/options, and merges headers', async () => { - const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - expect(String(input)).toBe('https://example.com/api/_incur/rpc') - expect(init?.method).toBe('POST') - const headers = new Headers(init?.headers) - expect(headers.get('content-type')).toBe('application/json') - expect(headers.get('accept')).toBe('application/json, application/x-ndjson') - expect(headers.get('x-custom')).toBe('yes') - expect(JSON.parse(String(init?.body))).toEqual({ command: 'status', args: {}, options: {} }) - return new Response( - JSON.stringify({ ok: true, data: 1, meta: { command: 'status', duration: '1ms' } }), - { - headers: { 'content-type': 'application/json' }, - }, - ) - }) as typeof globalThis.fetch - const transport = HttpTransport.create({ - baseUrl: 'https://example.com/api', - fetch, - headers: { 'x-custom': 'yes' }, - })() + test('requests commands through the CLI HTTP route', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { requests, transport } = connect(cli, { headers: { 'x-custom': 'yes' } }) + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ ok: true, - data: 1, + data: { ok: true }, + }) + + const request = requests[0]! + expect(String(request.input)).toBe('https://example.com/_incur/rpc') + expect(request.init?.method).toBe('POST') + const headers = new Headers(request.init?.headers) + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('accept')).toBe('application/json, application/x-ndjson') + expect(headers.get('x-custom')).toBe('yes') + expect(JSON.parse(String(request.init?.body))).toEqual({ + command: 'status', + args: {}, + options: {}, + }) + }) + + test('sends args and options to the CLI HTTP route', async () => { + const cli = Cli.create('app').command('sum', { + args: z.object({ left: z.number(), right: z.number() }), + options: z.object({ label: z.string() }), + run(c) { + return { label: c.options.label, total: c.args.left + c.args.right } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'sum', + args: { left: 2, right: 3 }, + options: { label: 'result' }, + }), + ).resolves.toMatchObject({ + ok: true, + data: { label: 'result', total: 5 }, }) }) @@ -73,7 +115,32 @@ describe('HttpTransport', () => { ) }) - test('parses NDJSON split records, blanks, final line without newline, and truncated streams', async () => { + test('streams records from the CLI HTTP route', async () => { + const cli = Cli.create('app').command('stream', { + async *run() { + yield { step: 1 } + yield { step: 2 } + }, + }) + const { transport } = connect(cli) + + const response = await transport.request({ command: 'stream' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { + type: 'done', + ok: true, + data: undefined, + meta: expect.objectContaining({ command: 'stream' }), + }, + ]) + }) + + test('parses split NDJSON records and rejects truncated streams', async () => { const fetch = vi.fn(async () => ndjson([ '{"type":"chunk","data":{"a":', @@ -120,29 +187,235 @@ describe('HttpTransport', () => { expect(cancel).toHaveBeenCalled() }) - test('routes discovery requests', async () => { - const fetch = vi.fn(async (input: RequestInfo | URL) => { - expect(String(input)).toBe('https://example.com/api/_incur/help?command=status') - return new Response('help', { headers: { 'content-type': 'text/plain' } }) - }) as typeof globalThis.fetch - await expect(resolve(fetch).discover({ resource: 'help', command: 'status' })).resolves.toEqual( + test('discovers every resource through the CLI HTTP route', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, + }) + const { requests, transport } = connect(cli) + + const cases: { + request: DiscoveryRequest + url: string + assert(response: Awaited>): void + }[] = [ { - contentType: 'text/plain', - body: 'help', + request: { resource: 'llms' }, + url: 'https://example.com/_incur/llms', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, }, - ) - }) + { + request: { resource: 'llms', command: 'status' }, + url: 'https://example.com/_incur/llms?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + url: 'https://example.com/_incur/llms?format=yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + url: 'https://example.com/_incur/llms-full', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + url: 'https://example.com/_incur/llms-full?command=status&format=json', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + url: 'https://example.com/_incur/schema', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + url: 'https://example.com/_incur/schema?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + url: 'https://example.com/_incur/help', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + url: 'https://example.com/_incur/help?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + url: 'https://example.com/openapi.json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + url: 'https://example.com/openapi.yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + url: 'https://example.com/_incur/skills', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + url: 'https://example.com/_incur/skill?name=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + url: 'https://example.com/_incur/mcp/tools', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] - test('routes OpenAPI discovery to the public OpenAPI route', async () => { - const fetch = vi.fn(async (input: RequestInfo | URL) => { - expect(String(input)).toBe('https://example.com/api/openapi.json') - return new Response(JSON.stringify({ openapi: '3.2.0' }), { - headers: { 'content-type': 'application/json' }, - }) - }) as typeof globalThis.fetch - await expect(resolve(fetch).discover({ resource: 'openapi' })).resolves.toMatchObject({ - contentType: 'application/json', - data: { openapi: '3.2.0' }, - }) + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } + + expect(requests.map((request) => String(request.input))).toEqual(cases.map((item) => item.url)) }) }) diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts index 1f8f6aa..d2c35a7 100644 --- a/src/client/transports/HttpTransport.ts +++ b/src/client/transports/HttpTransport.ts @@ -167,11 +167,13 @@ function discoveryUrl(baseUrl: URL, request: DiscoveryRequest) { if (request.resource === 'mcpTools') return '_incur/mcp/tools' if (request.resource === 'skillsIndex') return '_incur/skills' if (request.resource === 'skill') return '_incur/skill' + if (request.resource === 'openapi' && request.format === 'yaml') return 'openapi.yaml' return 'openapi.json' })() const target = url(baseUrl, path) if ('command' in request && request.command) target.searchParams.set('command', request.command) - if ('format' in request && request.format) target.searchParams.set('format', request.format) + if ('format' in request && request.format && request.resource !== 'openapi') + target.searchParams.set('format', request.format) if (request.resource === 'skill') target.searchParams.set('name', request.name) return target } diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index e7bc55c..c2baa25 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' import { z } from 'zod' import * as Cli from '../../Cli.js' +import type { DiscoveryRequest } from '../../internal/client-discovery.js' import * as MemoryTransport from './MemoryTransport.js' describe('MemoryTransport', () => { @@ -39,7 +41,7 @@ describe('MemoryTransport', () => { }) }) - test('preserves CLI version for in-process execution and OpenAPI discovery', async () => { + test('preserves CLI version for in-process execution', async () => { const cli = Cli.create('app', { version: '1.2.3' }).command('status', { run(c) { return { version: c.version } @@ -50,13 +52,223 @@ describe('MemoryTransport', () => { ok: true, data: { version: '1.2.3' }, }) - await expect(transport.discover({ resource: 'openapi' })).resolves.toMatchObject({ - contentType: 'application/json', - data: { info: { version: '1.2.3' } }, + }) + + test('discovers every resource in process', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, }) + const transport = MemoryTransport.create(cli)() + const cases: { + request: DiscoveryRequest + assert(response: Awaited>): void + }[] = [ + { + request: { resource: 'llms' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] + + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } }) - test('discovers help, skills, OpenAPI, and MCP tools', async () => { + test('exposes memory-only local capability', async () => { const cli = Cli.create('app', { description: 'App' }).command('status', { description: 'Show status', run() { @@ -64,32 +276,12 @@ describe('MemoryTransport', () => { }, }) const transport = MemoryTransport.create(cli)() - await expect( - transport.discover({ resource: 'help', command: 'status' }), - ).resolves.toMatchObject({ - contentType: 'text/plain', - body: expect.stringContaining('Show status'), - }) - await expect(transport.discover({ resource: 'skillsIndex' })).resolves.toMatchObject({ - contentType: 'application/json', - data: { skills: expect.any(Array) }, - }) - await expect(transport.discover({ resource: 'openapi' })).resolves.toMatchObject({ - contentType: 'application/json', - data: { openapi: '3.2.0' }, - }) - await expect(transport.discover({ resource: 'mcpTools' })).resolves.toMatchObject({ - contentType: 'application/json', - data: { tools: [expect.objectContaining({ name: 'status' })] }, - }) - }) - - test('exposes memory-only local capability', () => { - const cli = Cli.create('app') - const transport = MemoryTransport.create(cli)() expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) expect(typeof transport.local.skills.add).toBe('function') expect(typeof transport.local.skills.list).toBe('function') expect(typeof transport.local.mcp.add).toBe('function') + await expect(transport.local.skills.list()).resolves.toEqual([ + expect.objectContaining({ installed: false, name: 'app-status' }), + ]) }) }) From b51681e69121250bff7745f046cd8c36fdb56f34 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 12:52:19 +0200 Subject: [PATCH 14/55] refactor(client): split request discover local runtimes --- src/Cli.ts | 109 ++++++++++++++ src/client/ClientError.ts | 46 ++++++ src/client/Discover.ts | 17 +++ src/client/Local.ts | 51 +++++++ src/client/Request.ts | 78 ++++++++++ src/client/index.ts | 12 +- src/client/transports/HttpTransport.test.ts | 23 ++- src/client/transports/HttpTransport.ts | 63 +++++--- src/client/transports/MemoryTransport.test.ts | 25 +++- src/client/transports/MemoryTransport.ts | 78 +++++++--- ...client-discovery.ts => client-discover.ts} | 47 +++--- src/internal/client-local.ts | 115 ++++++--------- ...runtime.test.ts => client-request.test.ts} | 73 ++++------ .../{client-runtime.ts => client-request.ts} | 136 +++++------------- 14 files changed, 584 insertions(+), 289 deletions(-) create mode 100644 src/client/Discover.ts create mode 100644 src/client/Local.ts create mode 100644 src/client/Request.ts rename src/internal/{client-discovery.ts => client-discover.ts} (86%) rename src/internal/{client-runtime.test.ts => client-request.test.ts} (79%) rename src/internal/{client-runtime.ts => client-request.ts} (75%) diff --git a/src/Cli.ts b/src/Cli.ts index 618cc30..4136dd5 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -12,6 +12,9 @@ import * as Fetch from './Fetch.js' import * as Filter from './Filter.js' import * as Formatter from './Formatter.js' import * as Help from './Help.js' +import { createClientDiscover, DiscoverError } from './internal/client-discover.js' +import { createClientRequest } from './internal/client-request.js' +import * as CommandTree from './internal/command-tree.js' import { builtinCommands, type CommandMeta, @@ -1673,6 +1676,112 @@ async function fetchImpl( const url = new URL(req.url) const segments = url.pathname.split('/').filter(Boolean) + if (segments[0] === '_incur') { + const ctx: CommandTree.RuntimeCliContext = { + commands: commands as Map, + ...(options.description ? { description: options.description } : undefined), + ...(options.envSchema ? { env: options.envSchema } : undefined), + middlewares: options.middlewares ?? [], + name, + ...(options.rootCommand ? { rootCommand: options.rootCommand as any } : undefined), + ...(options.vars ? { vars: options.vars } : undefined), + ...(options.version ? { version: options.version } : undefined), + } + + if (segments[1] === 'rpc' && segments.length === 2 && req.method === 'POST') { + const client = createClientRequest(ctx) + let body: unknown + try { + body = await req.json() + } catch { + const response = await client.request({}) + return new Response(JSON.stringify(response), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + } + const response = await client.request(body) + if ('stream' in response) { + const records = response.records() + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + try { + for await (const record of records) + controller.enqueue(encoder.encode(`${JSON.stringify(record)}\n`)) + } finally { + controller.close() + } + }, + async cancel() { + await records.return(undefined as any) + }, + }) + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }) + } + return new Response(JSON.stringify(response), { + status: response.ok ? 200 : rpcStatus(response.error.code), + headers: { 'content-type': 'application/json' }, + }) + } + + if (req.method === 'GET') { + const resource = (() => { + if (segments[1] === 'llms') return 'llms' + if (segments[1] === 'llms-full') return 'llmsFull' + if (segments[1] === 'schema') return 'schema' + if (segments[1] === 'help') return 'help' + if (segments[1] === 'openapi') return 'openapi' + if (segments[1] === 'skills') return 'skillsIndex' + if (segments[1] === 'skill') return 'skill' + if (segments[1] === 'mcp' && segments[2] === 'tools') return 'mcpTools' + return undefined + })() + if (resource) { + try { + const client = createClientDiscover(ctx) + const discovery = await client.discover({ + resource, + ...(url.searchParams.get('command') + ? { command: url.searchParams.get('command')! } + : undefined), + ...(url.searchParams.get('format') + ? { format: url.searchParams.get('format')! } + : undefined), + ...(url.searchParams.get('name') ? { name: url.searchParams.get('name')! } : undefined), + }) + return new Response( + 'body' in discovery ? discovery.body : JSON.stringify(discovery.data), + { + status: 200, + headers: { 'content-type': discovery.contentType }, + }, + ) + } catch (error) { + const status = error instanceof DiscoverError ? error.status : 500 + const code = error instanceof DiscoverError ? error.code : 'DISCOVERY_ERROR' + return new Response( + JSON.stringify({ + ok: false, + error: { + code, + message: error instanceof Error ? error.message : String(error), + }, + meta: { + resource, + duration: `${Math.round(performance.now() - start)}ms`, + }, + }), + { status, headers: { 'content-type': 'application/json' } }, + ) + } + } + } + } + // OpenAPI discovery: route /openapi.json, /openapi.yml, /openapi.yaml, and /.well-known/openapi.json if (req.method === 'GET' && isOpenapiRoute(segments)) { const spec = generatedOpenapi(name, commands, options) diff --git a/src/client/ClientError.ts b/src/client/ClientError.ts index cf3769e..4b1d210 100644 --- a/src/client/ClientError.ts +++ b/src/client/ClientError.ts @@ -1,6 +1,52 @@ import { BaseError } from '../Errors.js' +import type * as Request from './Request.js' /** Error thrown by client transports. */ export class ClientError extends BaseError { override name = 'Incur.ClientError' + /** Machine-readable error code. */ + code: string | undefined + /** Full error envelope or diagnostic payload. */ + data: unknown | undefined + /** Request error object. */ + error: Extract['error'] | undefined + /** Field validation errors. */ + fieldErrors: Extract['error']['fieldErrors'] | undefined + /** Response metadata. */ + meta: Request.Meta | undefined + /** Whether the operation can be retried. */ + retryable: boolean | undefined + /** HTTP status when available. */ + status: number | undefined + + constructor(message: string, options: ClientError.Options = {}) { + super(message, options.cause ? { cause: options.cause } : undefined) + this.code = options.code + this.data = options.data + this.error = options.error + this.fieldErrors = options.fieldErrors + this.meta = options.meta + this.retryable = options.retryable + this.status = options.status + } +} + +export declare namespace ClientError { + /** Client error constructor options. */ + type Options = BaseError.Options & { + /** Machine-readable error code. */ + code?: string | undefined + /** Full error envelope or diagnostic payload. */ + data?: unknown | undefined + /** Request error object. */ + error?: Extract['error'] | undefined + /** Field validation errors. */ + fieldErrors?: Extract['error']['fieldErrors'] | undefined + /** Response metadata. */ + meta?: Request.Meta | undefined + /** Whether the operation can be retried. */ + retryable?: boolean | undefined + /** HTTP status when available. */ + status?: number | undefined + } } diff --git a/src/client/Discover.ts b/src/client/Discover.ts new file mode 100644 index 0000000..39a1c87 --- /dev/null +++ b/src/client/Discover.ts @@ -0,0 +1,17 @@ +import type * as Formatter from '../Formatter.js' + +/** Request accepted by `transport.discover()`. */ +export type Request = + | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'schema'; command?: string | undefined } + | { resource: 'help'; command?: string | undefined } + | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined } + | { resource: 'skillsIndex' } + | { resource: 'skill'; name: string } + | { resource: 'mcpTools' } + +/** Response returned by `transport.discover()`. */ +export type Response = + | { contentType: string; body: string } + | { contentType: string; data: unknown } diff --git a/src/client/Local.ts b/src/client/Local.ts new file mode 100644 index 0000000..dfbbd95 --- /dev/null +++ b/src/client/Local.ts @@ -0,0 +1,51 @@ +import type * as SyncMcp from '../SyncMcp.js' +import type * as SyncSkills from '../SyncSkills.js' + +/** Options for `local.skills.add()`. */ +export type SkillsAddOptions = { + /** Grouping depth. */ + depth?: number | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Options for `local.skills.list()`. */ +export type SkillsListOptions = { + /** Grouping depth. */ + depth?: number | undefined +} + +/** Options for `local.mcp.add()`. */ +export type McpAddOptions = { + /** Target agents. */ + agents?: string[] | undefined + /** Command agents should run. */ + command?: string | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Synced skills result. */ +export type SyncedSkills = SyncSkills.sync.Result + +/** Skills list result. */ +export type SkillsList = SyncSkills.list.Skill[] + +/** MCP registration result. */ +export type McpRegistration = SyncMcp.register.Result + +/** Memory-only local runtime exposed by the memory transport. */ +export type Runtime = { + /** Skill setup actions. */ + skills: { + /** Sync generated skill files. */ + add(options?: SkillsAddOptions | undefined): Promise + /** List generated skill files without writing them. */ + list(options?: SkillsListOptions | undefined): Promise + } + /** MCP setup actions. */ + mcp: { + /** Register the CLI as an MCP server. */ + add(options?: McpAddOptions | undefined): Promise + } +} diff --git a/src/client/Request.ts b/src/client/Request.ts new file mode 100644 index 0000000..113eb8f --- /dev/null +++ b/src/client/Request.ts @@ -0,0 +1,78 @@ +import type { FieldError } from '../Errors.js' +import type * as Formatter from '../Formatter.js' + +/** Request accepted by `transport.request()`. */ +export type Request = { + /** Canonical command ID. */ + command: string + /** Structured positional arguments. */ + args?: Record | undefined + /** Structured named options. */ + options?: Record | undefined + /** Output format for rendered text. */ + outputFormat?: Formatter.Format | undefined + /** Output selection paths. */ + selection?: string[] | undefined + /** Whether token metadata should be included. */ + outputTokenCount?: boolean | undefined + /** Maximum rendered output tokens to return. */ + outputTokenLimit?: number | undefined + /** Rendered output token offset. */ + outputTokenOffset?: number | undefined +} + +/** Rendered output payload. */ +export type Output = { + /** Rendered output text. */ + text: string + /** Whether text was truncated by token controls. */ + truncated?: boolean | undefined +} + +/** Request metadata. */ +export type Meta = { + /** Canonical command ID. */ + command: string + /** Suggested next commands. */ + cta?: unknown | undefined + /** Wall-clock duration. */ + duration: string + /** Offset to request for the next token window. */ + nextOffset?: number | undefined + /** Rendered token count before truncation. */ + outputTokenCount?: number | undefined +} + +/** Full request success/error envelope. */ +export type Envelope = + | { + ok: true + data: unknown + output?: Output | undefined + meta: Meta + } + | { + ok: false + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + } + meta: Meta + } + +/** Non-streaming request response. */ +export type Response = Envelope + +/** Streaming request record. */ +export type StreamRecord = + | { type: 'chunk'; data: unknown } + | ({ type: 'done' } & Extract) + | ({ type: 'error' } & Extract) + +/** Streaming request response. */ +export type StreamResponse = { + stream: true + records(): AsyncGenerator +} diff --git a/src/client/index.ts b/src/client/index.ts index 0cb45db..804cfdc 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,13 +1,7 @@ export { ClientError } from './ClientError.js' -export type { DiscoveryRequest, DiscoveryResponse } from '../internal/client-discovery.js' -export type { - RpcFullEnvelope as ClientRpcEnvelope, - RpcMeta as ClientRpcMeta, - RpcRequest, - RpcResponse, - RpcStreamRecord, - RpcStreamResponse, -} from '../internal/client-runtime.js' +export * as Discover from './Discover.js' export * as HttpTransport from './transports/HttpTransport.js' +export * as Local from './Local.js' export * as MemoryTransport from './transports/MemoryTransport.js' +export * as Request from './Request.js' export * as Transport from './transports/Transport.js' diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index 1baa4e6..4846a53 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -3,8 +3,8 @@ import { parse as yamlParse } from 'yaml' import { z } from 'zod' import * as Cli from '../../Cli.js' -import type { DiscoveryRequest } from '../../internal/client-discovery.js' import { ClientError } from '../ClientError.js' +import type * as Discover from '../Discover.js' import * as HttpTransport from './HttpTransport.js' function resolve(fetch: typeof globalThis.fetch) { @@ -115,6 +115,25 @@ describe('HttpTransport', () => { ) }) + test('wraps discovery route errors with response metadata', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + code: 'SKILL_NOT_FOUND', + data: { + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, + ok: false, + }, + message: expect.stringContaining("Unknown skill 'missing'."), + status: 404, + }) + }) + test('streams records from the CLI HTTP route', async () => { const cli = Cli.create('app').command('stream', { async *run() { @@ -199,7 +218,7 @@ describe('HttpTransport', () => { const { requests, transport } = connect(cli) const cases: { - request: DiscoveryRequest + request: Discover.Request url: string assert(response: Awaited>): void }[] = [ diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts index d2c35a7..3176a8a 100644 --- a/src/client/transports/HttpTransport.ts +++ b/src/client/transports/HttpTransport.ts @@ -1,11 +1,6 @@ -import type { DiscoveryRequest, DiscoveryResponse } from '../../internal/client-discovery.js' -import type { - RpcRequest, - RpcResponse, - RpcStreamRecord, - RpcStreamResponse, -} from '../../internal/client-runtime.js' import { ClientError } from '../ClientError.js' +import type * as Discover from '../Discover.js' +import type * as ClientRequest from '../Request.js' import type * as Transport from './Transport.js' /** HTTP transport factory. */ @@ -13,8 +8,10 @@ export type HttpTransport = Transport.Factory< 'http', { baseUrl: URL - request(request: RpcRequest): Promise - discover(request: DiscoveryRequest): Promise + request( + request: ClientRequest.Request, + ): Promise + discover(request: Discover.Request): Promise } > @@ -59,10 +56,7 @@ export function create(options: Options): HttpTransport { accept: 'application/json, text/plain, text/markdown', }), }) - const contentType = response.headers.get('content-type') ?? '' - if (contentType.includes('application/json')) - return { contentType: essence(contentType), data: await parseJson(response) } - return { contentType: essence(contentType), body: await response.text() } + return parseDiscoverResponse(response) }, }) } @@ -77,7 +71,9 @@ async function requestFetch(fetcher: typeof globalThis.fetch, input: URL, init: } } -async function parseRpcResponse(response: Response): Promise { +async function parseRpcResponse( + response: Response, +): Promise { const contentType = essence(response.headers.get('content-type') ?? '') if (contentType === 'application/x-ndjson') { if (!response.body) throw new ClientError('Streaming RPC response is missing a body.') @@ -89,14 +85,14 @@ async function parseRpcResponse(response: Response): Promise): RpcStreamResponse { +function streamResponse(body: ReadableStream): ClientRequest.StreamResponse { return { stream: true, async *records() { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' - let terminal: RpcStreamRecord | undefined + let terminal: ClientRequest.StreamRecord | undefined try { while (true) { const { value, done } = await reader.read() @@ -135,7 +131,7 @@ function* drainRecords(buffer: string): Generator<{ line: string; rest: string } } } -function parseRecord(line: string): RpcStreamRecord { +function parseRecord(line: string): ClientRequest.StreamRecord { let value: unknown try { value = JSON.parse(line) @@ -158,7 +154,25 @@ async function parseJson(response: Response) { } } -function discoveryUrl(baseUrl: URL, request: DiscoveryRequest) { +async function parseDiscoverResponse(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!response.ok) { + const data = contentType.includes('application/json') + ? await parseJson(response).catch(() => undefined) + : await response.text().catch(() => undefined) + const error = isErrorPayload(data) ? data.error : undefined + throw new ClientError(error?.message ?? 'Discover request failed.', { + code: error?.code, + data, + status: response.status, + }) + } + if (contentType.includes('application/json')) + return { contentType: essence(contentType), data: await parseJson(response) } + return { contentType: essence(contentType), body: await response.text() } +} + +function discoveryUrl(baseUrl: URL, request: Discover.Request) { const path = (() => { if (request.resource === 'llms') return '_incur/llms' if (request.resource === 'llmsFull') return '_incur/llms-full' @@ -196,7 +210,7 @@ function essence(value: string) { return value.split(';', 1)[0]!.trim().toLowerCase() } -function isEnvelope(value: unknown): value is RpcResponse { +function isEnvelope(value: unknown): value is ClientRequest.Response { return ( typeof value === 'object' && value !== null && @@ -205,7 +219,7 @@ function isEnvelope(value: unknown): value is RpcResponse { ) } -function isRecord(value: unknown): value is RpcStreamRecord { +function isRecord(value: unknown): value is ClientRequest.StreamRecord { return ( typeof value === 'object' && value !== null && @@ -214,3 +228,12 @@ function isRecord(value: unknown): value is RpcStreamRecord { ((value as { type?: unknown }).type === 'error' && isEnvelope(value))) ) } + +function isErrorPayload(value: unknown): value is { error: { code?: string; message?: string } } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { error?: unknown }).error === 'object' && + (value as { error?: unknown }).error !== null + ) +} diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index c2baa25..48a30e1 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -3,7 +3,9 @@ import { parse as yamlParse } from 'yaml' import { z } from 'zod' import * as Cli from '../../Cli.js' -import type { DiscoveryRequest } from '../../internal/client-discovery.js' +import { DiscoverError } from '../../internal/client-discover.js' +import { ClientError } from '../ClientError.js' +import type * as Discover from '../Discover.js' import * as MemoryTransport from './MemoryTransport.js' describe('MemoryTransport', () => { @@ -65,7 +67,7 @@ describe('MemoryTransport', () => { }) const transport = MemoryTransport.create(cli)() const cases: { - request: DiscoveryRequest + request: Discover.Request assert(response: Awaited>): void }[] = [ { @@ -268,6 +270,25 @@ describe('MemoryTransport', () => { } }) + test('wraps discovery failures as client errors with internal cause', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + cause: expect.any(DiscoverError), + code: 'SKILL_NOT_FOUND', + message: expect.stringContaining('Discover request failed.'), + status: 404, + }) + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toThrow( + ClientError, + ) + }) + test('exposes memory-only local capability', async () => { const cli = Cli.create('app', { description: 'App' }).command('status', { description: 'Show status', diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index 75a7aad..1ba5682 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -1,26 +1,23 @@ import * as Cli from '../../Cli.js' -import { - discoverClientResource, - type DiscoveryRequest, - type DiscoveryResponse, -} from '../../internal/client-discovery.js' -import { createLocalRuntime, type LocalRuntime } from '../../internal/client-local.js' -import { - executeClientCommand, - type RpcRequest, - type RpcResponse, - type RpcStreamResponse, -} from '../../internal/client-runtime.js' +import { createClientDiscover } from '../../internal/client-discover.js' +import { createClientLocal } from '../../internal/client-local.js' +import { createClientRequest } from '../../internal/client-request.js' import * as CommandTree from '../../internal/command-tree.js' +import { ClientError } from '../ClientError.js' +import type * as Discover from '../Discover.js' +import type * as Local from '../Local.js' +import type * as ClientRequest from '../Request.js' import type * as Transport from './Transport.js' /** Memory transport factory. */ export type MemoryTransport = Transport.Factory< 'memory', { - request(request: RpcRequest): Promise - discover(request: DiscoveryRequest): Promise - local: LocalRuntime + request( + request: ClientRequest.Request, + ): Promise + discover(request: Discover.Request): Promise + local: Local.Runtime } > @@ -34,15 +31,56 @@ export type Options = { export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { return () => { const ctx = CommandTree.fromCli(cli) + const { request } = createClientRequest(ctx, { env: options.env }) + const { discover } = createClientDiscover(ctx) + const local = createClientLocal(ctx) return { config: { key: 'memory', name: 'Memory', type: 'memory' }, - request(request) { - return executeClientCommand(ctx, request, { env: options.env }) + request, + async discover(request) { + try { + return await discover(request) + } catch (error) { + throw toClientError('Discover request failed.', error) + } }, - discover(request) { - return discoverClientResource(ctx, request) + local: { + skills: { + async add(options) { + try { + return await local.skills.add(options) + } catch (error) { + throw toClientError('Local skills sync failed.', error) + } + }, + async list(options) { + try { + return await local.skills.list(options) + } catch (error) { + throw toClientError('Local skills list failed.', error) + } + }, + }, + mcp: { + async add(options) { + try { + return await local.mcp.add(options) + } catch (error) { + throw toClientError('Local MCP registration failed.', error) + } + }, + }, }, - local: createLocalRuntime(ctx), } } } + +function toClientError(message: string, error: unknown) { + if (error instanceof ClientError) return error + const cause = error instanceof Error ? error : new Error(String(error)) + return new ClientError(message, { + cause, + code: 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined, + status: 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined, + }) +} diff --git a/src/internal/client-discovery.ts b/src/internal/client-discover.ts similarity index 86% rename from src/internal/client-discovery.ts rename to src/internal/client-discover.ts index 04ea3d4..6f7b507 100644 --- a/src/internal/client-discovery.ts +++ b/src/internal/client-discover.ts @@ -2,6 +2,8 @@ import { parse as yamlParse, stringify as yamlStringify } from 'yaml' import { z } from 'zod' import * as Cli from '../Cli.js' +import type * as ClientDiscover from '../client/Discover.js' +import { BaseError } from '../Errors.js' import * as Formatter from '../Formatter.js' import * as Help from '../Help.js' import * as Mcp from '../Mcp.js' @@ -9,24 +11,9 @@ import * as Openapi from '../Openapi.js' import * as Skill from '../Skill.js' import * as CommandTree from './command-tree.js' -/** Discovery request. */ -export type DiscoveryRequest = - | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } - | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } - | { resource: 'schema'; command?: string | undefined } - | { resource: 'help'; command?: string | undefined } - | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined } - | { resource: 'skillsIndex' } - | { resource: 'skill'; name: string } - | { resource: 'mcpTools' } - -/** Discovery response. */ -export type DiscoveryResponse = - | { contentType: string; body: string } - | { contentType: string; data: unknown } - -/** Discovery failure with protocol code and HTTP status metadata. */ -export class DiscoveryError extends Error { +/** Discover failure with protocol code and HTTP status metadata. */ +export class DiscoverError extends BaseError { + override name = 'Incur.DiscoverError' /** Machine-readable error code. */ code: string /** HTTP status for discovery routes. */ @@ -58,14 +45,22 @@ const requestSchema = z.discriminatedUnion('resource', [ z.object({ resource: z.literal('mcpTools') }), ]) -/** Builds a client discovery resource from a CLI runtime context. */ -export async function discoverClientResource( +/** Creates the shared client discovery executor. */ +export function createClientDiscover(ctx: CommandTree.RuntimeCliContext) { + return { + discover(request: unknown) { + return discover(ctx, request) + }, + } +} + +async function discover( ctx: CommandTree.RuntimeCliContext, request: unknown, -): Promise { +): Promise { const parsedRequest = requestSchema.safeParse(request) if (!parsedRequest.success) - throw new DiscoveryError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + throw new DiscoverError('VALIDATION_ERROR', 'Invalid discovery request.', 400) const parsed = parsedRequest.data if (parsed.resource === 'openapi') { const spec = openapi(ctx) @@ -94,9 +89,9 @@ export async function discoverClientResource( } } if (!safeSkillName(parsed.name)) - throw new DiscoveryError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + throw new DiscoverError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) const file = files.find((value) => (value.dir || ctx.name) === parsed.name) - if (!file) throw new DiscoveryError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + if (!file) throw new DiscoverError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) return { contentType: 'text/markdown', body: file.content } } @@ -165,9 +160,9 @@ function scope(ctx: CommandTree.RuntimeCliContext, command: string | undefined) } const resolved = CommandTree.resolveCanonical(ctx, command) if ('error' in resolved) - throw new DiscoveryError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) + throw new DiscoverError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) if ('gateway' in resolved) - throw new DiscoveryError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) + throw new DiscoverError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) if ('commands' in resolved) return { type: 'group' as const, diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts index d572941..80ca6de 100644 --- a/src/internal/client-local.ts +++ b/src/internal/client-local.ts @@ -1,84 +1,63 @@ +import type * as Local from '../client/Local.js' +import { BaseError } from '../Errors.js' import * as SyncMcp from '../SyncMcp.js' import * as SyncSkills from '../SyncSkills.js' import type * as CommandTree from './command-tree.js' -/** Options for `skills.add()`. */ -export type SkillsAddOptions = { - /** Grouping depth. */ - depth?: number | undefined - /** Install globally instead of project-local. */ - global?: boolean | undefined -} - -/** Options for `skills.list()`. */ -export type SkillsListOptions = { - /** Grouping depth. */ - depth?: number | undefined -} - -/** Options for `mcp.add()`. */ -export type McpAddOptions = { - /** Target agents. */ - agents?: string[] | undefined - /** Command agents should run. */ - command?: string | undefined - /** Install globally instead of project-local. */ - global?: boolean | undefined -} - -/** Synced skills result. */ -export type SyncedSkills = SyncSkills.sync.Result - -/** Skills list result. */ -export type SkillsList = SyncSkills.list.Skill[] - -/** MCP registration result. */ -export type McpRegistration = SyncMcp.register.Result - -/** Local memory-only runtime. */ -export type LocalRuntime = { - /** Skill setup actions. */ - skills: { - add(options?: SkillsAddOptions | undefined): Promise - list(options?: SkillsListOptions | undefined): Promise - } - /** MCP setup actions. */ - mcp: { - add(options?: McpAddOptions | undefined): Promise - } +/** Local setup/admin failure. */ +export class LocalError extends BaseError { + override name = 'Incur.LocalError' } /** Creates local setup/admin wrappers for a memory transport. */ -export function createLocalRuntime(ctx: CommandTree.RuntimeCliContext): LocalRuntime { +export function createClientLocal(ctx: CommandTree.RuntimeCliContext): Local.Runtime { return { skills: { - add(options: SkillsAddOptions = {}) { - return SyncSkills.sync(ctx.name, ctx.commands, { - cwd: ctx.sync?.cwd, - depth: options.depth ?? ctx.sync?.depth ?? 1, - description: ctx.description, - global: options.global ?? true, - include: ctx.sync?.include, - rootCommand: ctx.rootCommand, - }) + async add(options: Local.SkillsAddOptions = {}) { + try { + return await SyncSkills.sync(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + global: options.global ?? true, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to sync local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } }, - list(options: SkillsListOptions = {}) { - return SyncSkills.list(ctx.name, ctx.commands, { - cwd: ctx.sync?.cwd, - depth: options.depth ?? ctx.sync?.depth ?? 1, - description: ctx.description, - include: ctx.sync?.include, - rootCommand: ctx.rootCommand, - }) + async list(options: Local.SkillsListOptions = {}) { + try { + return await SyncSkills.list(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to list local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } }, }, mcp: { - add(options: McpAddOptions = {}) { - return SyncMcp.register(ctx.name, { - agents: options.agents ?? ctx.mcp?.agents, - command: options.command ?? ctx.mcp?.command, - global: options.global ?? true, - }) + async add(options: Local.McpAddOptions = {}) { + try { + return await SyncMcp.register(ctx.name, { + agents: options.agents ?? ctx.mcp?.agents, + command: options.command ?? ctx.mcp?.command, + global: options.global ?? true, + }) + } catch (error) { + throw new LocalError('Failed to register local MCP server.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } }, }, } diff --git a/src/internal/client-runtime.test.ts b/src/internal/client-request.test.ts similarity index 79% rename from src/internal/client-runtime.test.ts rename to src/internal/client-request.test.ts index 527e62c..d420b0a 100644 --- a/src/internal/client-runtime.test.ts +++ b/src/internal/client-request.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' import * as Cli from '../Cli.js' -import { executeClientCommand } from './client-runtime.js' +import { createClientRequest } from './client-request.js' import * as CommandTree from './command-tree.js' function createFixture() { @@ -75,26 +75,30 @@ function createFixture() { return { cli, order, ctx: CommandTree.fromCli(cli) } } -describe('executeClientCommand', () => { +function request( + ctx: CommandTree.RuntimeCliContext, + body: unknown, + options: createClientRequest.Options = {}, +) { + return createClientRequest(ctx, options).request(body) +} + +describe('createClientRequest', () => { test('executes root, mounted root, and mounted router commands by canonical ID', async () => { const { ctx, order } = createFixture() await expect( - executeClientCommand( - ctx, - { command: ' root ', args: {}, options: {} }, - { env: { API_KEY: 'k' } }, - ), + request(ctx, { command: ' root ', args: {}, options: {} }, { env: { API_KEY: 'k' } }), ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) await expect( - executeClientCommand( + request( ctx, { command: 'child', args: { id: 'c1' }, options: { loud: true } }, { env: { API_KEY: 'k', TOKEN: 't' } }, ), ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) await expect( - executeClientCommand( + request( ctx, { command: 'project list', args: { projectId: 'p1' }, options: { limit: 1 } }, { env: { API_KEY: 'k' } }, @@ -121,23 +125,23 @@ describe('executeClientCommand', () => { test('rejects invalid RPC shape, unknown commands, groups, aliases, and raw fetch gateways', async () => { const { ctx } = createFixture() - await expect(executeClientCommand(ctx, { command: '' })).resolves.toMatchObject({ + await expect(request(ctx, { command: '' })).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' }, }) - await expect(executeClientCommand(ctx, { command: 'missing' })).resolves.toMatchObject({ + await expect(request(ctx, { command: 'missing' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_NOT_FOUND' }, }) - await expect(executeClientCommand(ctx, { command: 'project' })).resolves.toMatchObject({ + await expect(request(ctx, { command: 'project' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_GROUP' }, }) - await expect(executeClientCommand(ctx, { command: 'alias' })).resolves.toMatchObject({ + await expect(request(ctx, { command: 'alias' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_NOT_FOUND' }, }) - await expect(executeClientCommand(ctx, { command: 'raw' })).resolves.toMatchObject({ + await expect(request(ctx, { command: 'raw' })).resolves.toMatchObject({ ok: false, error: { code: 'FETCH_GATEWAY' }, }) @@ -146,34 +150,30 @@ describe('executeClientCommand', () => { test('validates structured args, options, CLI env, and command env independently', async () => { const { ctx } = createFixture() await expect( - executeClientCommand( + request( ctx, { command: 'project list', args: {}, options: { limit: 1 } }, { env: { API_KEY: 'k' } }, ), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - executeClientCommand( + request( ctx, { command: 'project list', args: { projectId: 'p' }, options: { limit: 'bad' } }, { env: { API_KEY: 'k' } }, ), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - executeClientCommand(ctx, { command: 'project list', args: { projectId: 'p' }, options: {} }), + request(ctx, { command: 'project list', args: { projectId: 'p' }, options: {} }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - executeClientCommand( - ctx, - { command: 'child', args: { id: 'c' }, options: {} }, - { env: { API_KEY: 'k' } }, - ), + request(ctx, { command: 'child', args: { id: 'c' }, options: {} }, { env: { API_KEY: 'k' } }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) }) test('applies selection, formatting, token metadata, and CTA metadata', async () => { const { ctx } = createFixture() - const response = await executeClientCommand( + const response = await request( ctx, { command: 'project list', @@ -196,11 +196,12 @@ describe('executeClientCommand', () => { test('rejects empty selections and omits token count unless requested', async () => { const { ctx } = createFixture() + await expect(request(ctx, { command: 'project list', selection: [] })).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) await expect( - executeClientCommand(ctx, { command: 'project list', selection: [] }), - ).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' } }) - await expect( - executeClientCommand( + request( ctx, { command: 'project list', args: { projectId: 'p1' }, options: {} }, { env: { API_KEY: 'k' } }, @@ -210,11 +211,7 @@ describe('executeClientCommand', () => { test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { const { ctx, order } = createFixture() - const response = await executeClientCommand( - ctx, - { command: 'project stream' }, - { env: { API_KEY: 'k' } }, - ) + const response = await request(ctx, { command: 'project stream' }, { env: { API_KEY: 'k' } }) if (!('stream' in response)) throw new Error('expected stream') const records: unknown[] = [] for await (const record of response.records()) records.push(record) @@ -224,11 +221,7 @@ describe('executeClientCommand', () => { { type: 'done', ok: true, meta: { command: 'project stream', cta: expect.any(Object) } }, ]) - const failed = await executeClientCommand( - ctx, - { command: 'project fail-stream' }, - { env: { API_KEY: 'k' } }, - ) + const failed = await request(ctx, { command: 'project fail-stream' }, { env: { API_KEY: 'k' } }) if (!('stream' in failed)) throw new Error('expected stream') const failedRecords: unknown[] = [] for await (const record of failed.records()) failedRecords.push(record) @@ -239,11 +232,7 @@ describe('executeClientCommand', () => { meta: { command: 'project fail-stream' }, }) - const cancelled = await executeClientCommand( - ctx, - { command: 'project stream' }, - { env: { API_KEY: 'k' } }, - ) + const cancelled = await request(ctx, { command: 'project stream' }, { env: { API_KEY: 'k' } }) if (!('stream' in cancelled)) throw new Error('expected stream') const iterator = cancelled.records() await iterator.next() diff --git a/src/internal/client-runtime.ts b/src/internal/client-request.ts similarity index 75% rename from src/internal/client-runtime.ts rename to src/internal/client-request.ts index ca1b7c4..46dc376 100644 --- a/src/internal/client-runtime.ts +++ b/src/internal/client-request.ts @@ -1,88 +1,13 @@ import { estimateTokenCount, sliceByTokens } from 'tokenx' import { z } from 'zod' +import type * as ClientRequest from '../client/Request.js' import type { FieldError } from '../Errors.js' import * as Filter from '../Filter.js' import * as Formatter from '../Formatter.js' import * as CommandTree from './command-tree.js' import * as Command from './command.js' -/** RPC request accepted by HTTP and memory transports. */ -export type RpcRequest = { - /** Canonical command ID. */ - command: string - /** Structured positional arguments. */ - args?: Record | undefined - /** Structured named options. */ - options?: Record | undefined - /** Output format for rendered text. */ - outputFormat?: Formatter.Format | undefined - /** Output selection paths. */ - selection?: string[] | undefined - /** Whether token metadata should be included. */ - outputTokenCount?: boolean | undefined - /** Maximum rendered output tokens to return. */ - outputTokenLimit?: number | undefined - /** Rendered output token offset. */ - outputTokenOffset?: number | undefined -} - -/** RPC output payload. */ -export type RpcOutput = { - /** Rendered output text. */ - text: string - /** Whether text was truncated by token controls. */ - truncated?: boolean | undefined -} - -/** RPC metadata. */ -export type RpcMeta = { - /** Canonical command ID. */ - command: string - /** Suggested next commands. */ - cta?: unknown | undefined - /** Wall-clock duration. */ - duration: string - /** Offset to request for the next token window. */ - nextOffset?: number | undefined - /** Rendered token count before truncation. */ - outputTokenCount?: number | undefined -} - -/** Full RPC success/error envelope. */ -export type RpcFullEnvelope = - | { - ok: true - data: unknown - output?: RpcOutput | undefined - meta: RpcMeta - } - | { - ok: false - error: { - code: string - fieldErrors?: FieldError[] | undefined - message: string - retryable?: boolean | undefined - } - meta: RpcMeta - } - -/** Non-streaming RPC response. */ -export type RpcResponse = RpcFullEnvelope - -/** Streaming RPC record. */ -export type RpcStreamRecord = - | { type: 'chunk'; data: unknown } - | ({ type: 'done' } & Extract) - | ({ type: 'error' } & Extract) - -/** Streaming RPC response. */ -export type RpcStreamResponse = { - stream: true - records(): AsyncGenerator -} - const requestSchema = z.object({ command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), args: z.record(z.string(), z.unknown()).optional(), @@ -95,12 +20,31 @@ const requestSchema = z.object({ }) const sentinel = Symbol.for('incur.sentinel') -/** Executes a canonical client command through the shared runtime. */ -export async function executeClientCommand( +/** Creates the shared client request executor. */ +export function createClientRequest( + ctx: CommandTree.RuntimeCliContext, + options: createClientRequest.Options = {}, +) { + return { + request(request: unknown) { + return execute(ctx, request, options) + }, + } +} + +export declare namespace createClientRequest { + /** Execution options. */ + type Options = { + /** Explicit environment source. */ + env?: Record | undefined + } +} + +async function execute( ctx: CommandTree.RuntimeCliContext, request: unknown, - options: executeClientCommand.Options = {}, -): Promise { + options: createClientRequest.Options, +): Promise { const start = performance.now() const parsed = requestSchema.safeParse(request) if (!parsed.success) @@ -165,24 +109,16 @@ export async function executeClientCommand( return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) } -export declare namespace executeClientCommand { - /** Execution options. */ - type Options = { - /** Explicit environment source. */ - env?: Record | undefined - } -} - function streamResponse( stream: AsyncGenerator, command: string, start: number, - request: RpcRequest, -): RpcStreamResponse { + request: ClientRequest.Request, +): ClientRequest.StreamResponse { return { stream: true, async *records() { - let terminal: RpcStreamRecord + let terminal: ClientRequest.StreamRecord try { while (true) { const { value, done } = await stream.next() @@ -249,8 +185,8 @@ function successEnvelope( start: number, data: unknown, cta?: unknown | undefined, - request: RpcRequest = { command }, -): Extract { + request: ClientRequest.Request = { command }, +): Extract { const selected = applySelection(data, request.selection) const output = renderOutput(selected, request) return { @@ -273,8 +209,8 @@ function errorEnvelope( retryable?: boolean | undefined }, cta?: unknown | undefined, - request: RpcRequest = { command }, -): Extract { + request: ClientRequest.Request = { command }, +): Extract { return { ok: false, error, @@ -292,8 +228,8 @@ function errorRecord( retryable?: boolean | undefined }, cta: unknown | undefined, - request: RpcRequest, -): Extract { + request: ClientRequest.Request, +): Extract { return { type: 'error', ...errorEnvelope(command, start, error, cta, request) } } @@ -305,7 +241,7 @@ function applySelection(data: unknown, selection: string[] | undefined) { ) } -function renderOutput(data: unknown, request: RpcRequest) { +function renderOutput(data: unknown, request: ClientRequest.Request) { const text = Formatter.format(data, request.outputFormat ?? 'json') const count = estimateTokenCount(text) const offset = request.outputTokenOffset ?? 0 @@ -326,8 +262,8 @@ function meta( start: number, cta: unknown | undefined, output: { count: number; nextOffset?: number | undefined }, - request: RpcRequest, -): RpcMeta { + request: ClientRequest.Request, +): ClientRequest.Meta { return { command, duration: `${Math.round(performance.now() - start)}ms`, From 2499cfab49bda30f41dd36301d2b22b438126843 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 13:02:09 +0200 Subject: [PATCH 15/55] refactor(client): wrap local runtime capability --- src/client/transports/MemoryTransport.ts | 2 +- src/internal/client-local.ts | 94 ++++++++++++------------ 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index 1ba5682..73aa3d4 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -33,7 +33,7 @@ export function create(cli: Cli.Cli, options: Options = {}): Memo const ctx = CommandTree.fromCli(cli) const { request } = createClientRequest(ctx, { env: options.env }) const { discover } = createClientDiscover(ctx) - const local = createClientLocal(ctx) + const { local } = createClientLocal(ctx) return { config: { key: 'memory', name: 'Memory', type: 'memory' }, request, diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts index 80ca6de..b411aae 100644 --- a/src/internal/client-local.ts +++ b/src/internal/client-local.ts @@ -10,54 +10,56 @@ export class LocalError extends BaseError { } /** Creates local setup/admin wrappers for a memory transport. */ -export function createClientLocal(ctx: CommandTree.RuntimeCliContext): Local.Runtime { +export function createClientLocal(ctx: CommandTree.RuntimeCliContext) { return { - skills: { - async add(options: Local.SkillsAddOptions = {}) { - try { - return await SyncSkills.sync(ctx.name, ctx.commands, { - cwd: ctx.sync?.cwd, - depth: options.depth ?? ctx.sync?.depth ?? 1, - description: ctx.description, - global: options.global ?? true, - include: ctx.sync?.include, - rootCommand: ctx.rootCommand, - }) - } catch (error) { - throw new LocalError('Failed to sync local skills.', { - cause: error instanceof Error ? error : new Error(String(error)), - }) - } + local: { + skills: { + async add(options: Local.SkillsAddOptions = {}) { + try { + return await SyncSkills.sync(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + global: options.global ?? true, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to sync local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + async list(options: Local.SkillsListOptions = {}) { + try { + return await SyncSkills.list(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to list local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, }, - async list(options: Local.SkillsListOptions = {}) { - try { - return await SyncSkills.list(ctx.name, ctx.commands, { - cwd: ctx.sync?.cwd, - depth: options.depth ?? ctx.sync?.depth ?? 1, - description: ctx.description, - include: ctx.sync?.include, - rootCommand: ctx.rootCommand, - }) - } catch (error) { - throw new LocalError('Failed to list local skills.', { - cause: error instanceof Error ? error : new Error(String(error)), - }) - } - }, - }, - mcp: { - async add(options: Local.McpAddOptions = {}) { - try { - return await SyncMcp.register(ctx.name, { - agents: options.agents ?? ctx.mcp?.agents, - command: options.command ?? ctx.mcp?.command, - global: options.global ?? true, - }) - } catch (error) { - throw new LocalError('Failed to register local MCP server.', { - cause: error instanceof Error ? error : new Error(String(error)), - }) - } + mcp: { + async add(options: Local.McpAddOptions = {}) { + try { + return await SyncMcp.register(ctx.name, { + agents: options.agents ?? ctx.mcp?.agents, + command: options.command ?? ctx.mcp?.command, + global: options.global ?? true, + }) + } catch (error) { + throw new LocalError('Failed to register local MCP server.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, }, }, } From e2e6687c512e0887334ec96d9e1c5f2fc7364294 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 13:10:33 +0200 Subject: [PATCH 16/55] refactor(client): inline runtime methods --- src/internal/client-discover.ts | 178 ++++++++++++++++---------------- src/internal/client-request.ts | 136 ++++++++++++------------ 2 files changed, 154 insertions(+), 160 deletions(-) diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 6f7b507..053c221 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -48,103 +48,103 @@ const requestSchema = z.discriminatedUnion('resource', [ /** Creates the shared client discovery executor. */ export function createClientDiscover(ctx: CommandTree.RuntimeCliContext) { return { - discover(request: unknown) { - return discover(ctx, request) - }, - } -} + async discover(request: unknown): Promise { + const parsedRequest = requestSchema.safeParse(request) + if (!parsedRequest.success) + throw new DiscoverError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + const parsed = parsedRequest.data + if (parsed.resource === 'openapi') { + const spec = openapi(ctx) + if (parsed.format === 'yaml') + return { contentType: 'application/yaml', body: yamlStringify(spec) } + return { contentType: 'application/json', data: spec } + } + if (parsed.resource === 'mcpTools') + return { + contentType: 'application/json', + data: { tools: Mcp.collectTools(ctx.commands, []) }, + } -async function discover( - ctx: CommandTree.RuntimeCliContext, - request: unknown, -): Promise { - const parsedRequest = requestSchema.safeParse(request) - if (!parsedRequest.success) - throw new DiscoverError('VALIDATION_ERROR', 'Invalid discovery request.', 400) - const parsed = parsedRequest.data - if (parsed.resource === 'openapi') { - const spec = openapi(ctx) - if (parsed.format === 'yaml') - return { contentType: 'application/yaml', body: yamlStringify(spec) } - return { contentType: 'application/json', data: spec } - } - if (parsed.resource === 'mcpTools') - return { contentType: 'application/json', data: { tools: Mcp.collectTools(ctx.commands, []) } } + if (parsed.resource === 'skillsIndex' || parsed.resource === 'skill') { + const { files } = skills(ctx) + if (parsed.resource === 'skillsIndex') { + return { + contentType: 'application/json', + data: { + skills: files.map((file) => { + const meta = parseFrontmatter(file.content) + return { + name: file.dir || ctx.name, + description: meta.description ?? '', + files: ['SKILL.md'], + } + }), + }, + } + } + if (!safeSkillName(parsed.name)) + throw new DiscoverError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + const file = files.find((value) => (value.dir || ctx.name) === parsed.name) + if (!file) + throw new DiscoverError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + return { contentType: 'text/markdown', body: file.content } + } - if (parsed.resource === 'skillsIndex' || parsed.resource === 'skill') { - const { files } = skills(ctx) - if (parsed.resource === 'skillsIndex') { - return { - contentType: 'application/json', - data: { - skills: files.map((file) => { - const meta = parseFrontmatter(file.content) - return { - name: file.dir || ctx.name, - description: meta.description ?? '', - files: ['SKILL.md'], - } + const scoped = scope(ctx, parsed.command) + if (parsed.resource === 'help') { + if (scoped.type === 'command') + return { + contentType: 'text/plain', + body: Help.formatCommand(scoped.id, { + alias: scoped.command.alias, + args: scoped.command.args, + description: scoped.command.description, + env: scoped.command.env, + examples: [], + hint: scoped.command.hint, + options: scoped.command.options, + usage: [], + }), + } + return { + contentType: 'text/plain', + body: Help.formatRoot(scoped.id, { + description: scoped.description, + commands: collect(scoped.commands, [], false).map(({ name, description }) => ({ + name, + ...(description ? { description } : undefined), + })), }), - }, + } } - } - if (!safeSkillName(parsed.name)) - throw new DiscoverError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) - const file = files.find((value) => (value.dir || ctx.name) === parsed.name) - if (!file) throw new DiscoverError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) - return { contentType: 'text/markdown', body: file.content } - } - const scoped = scope(ctx, parsed.command) - if (parsed.resource === 'help') { - if (scoped.type === 'command') + if (parsed.resource === 'schema') { + if (scoped.type === 'command') { + const schema = CommandTree.buildInputSchema(scoped.command) + return { contentType: 'application/json', data: schema ?? {} } + } + return { + contentType: 'application/json', + data: manifest(scoped.commands, scoped.prefix, true), + } + } + + const full = parsed.resource === 'llmsFull' + const format = parsed.format ?? 'md' + if (format === 'md') { + const groups = new Map() + const entries = skillCommands(scoped.commands, scoped.prefix, groups, scoped.rootCommand) + const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name + const body = full + ? Skill.generate(name, entries, groups) + : Skill.index(name, entries, scoped.description) + return { contentType: 'text/markdown', body } + } return { contentType: 'text/plain', - body: Help.formatCommand(scoped.id, { - alias: scoped.command.alias, - args: scoped.command.args, - description: scoped.command.description, - env: scoped.command.env, - examples: [], - hint: scoped.command.hint, - options: scoped.command.options, - usage: [], - }), + body: Formatter.format(manifest(scoped.commands, scoped.prefix, full), format), } - return { - contentType: 'text/plain', - body: Help.formatRoot(scoped.id, { - description: scoped.description, - commands: collect(scoped.commands, [], false).map(({ name, description }) => ({ - name, - ...(description ? { description } : undefined), - })), - }), - } - } - - if (parsed.resource === 'schema') { - if (scoped.type === 'command') { - const schema = CommandTree.buildInputSchema(scoped.command) - return { contentType: 'application/json', data: schema ?? {} } - } - return { contentType: 'application/json', data: manifest(scoped.commands, scoped.prefix, true) } - } - - const full = parsed.resource === 'llmsFull' - const format = parsed.format ?? 'md' - if (format === 'md') { - const groups = new Map() - const entries = skillCommands(scoped.commands, scoped.prefix, groups, scoped.rootCommand) - const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name - const body = full - ? Skill.generate(name, entries, groups) - : Skill.index(name, entries, scoped.description) - return { contentType: 'text/markdown', body } - } - return { - contentType: 'text/plain', - body: Formatter.format(manifest(scoped.commands, scoped.prefix, full), format), + }, } } diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 46dc376..05deaf5 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -26,8 +26,71 @@ export function createClientRequest( options: createClientRequest.Options = {}, ) { return { - request(request: unknown) { - return execute(ctx, request, options) + async request( + request: unknown, + ): Promise { + const start = performance.now() + const parsed = requestSchema.safeParse(request) + if (!parsed.success) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'Invalid RPC request.', + fieldErrors: parsed.error.issues.map((issue) => ({ + code: issue.code, + expected: 'valid RPC request', + received: 'invalid', + message: issue.message, + path: issue.path.join('.'), + })), + }) + + const rpc = parsed.data + if (!rpc.command) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'RPC command is required.', + }) + + const resolved = CommandTree.resolveCanonical(ctx, rpc.command) + if ('error' in resolved) + return errorEnvelope(rpc.command, start, { + code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', + message: + resolved.error === 'empty' + ? 'RPC command is required.' + : `'${resolved.token}' is not a command for '${resolved.parent}'.`, + }) + if ('commands' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'COMMAND_GROUP', + message: `'${resolved.id}' is a command group. Specify a subcommand.`, + }) + if ('gateway' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'FETCH_GATEWAY', + message: `'${resolved.id}' is a raw fetch gateway and cannot be called with structured RPC.`, + }) + + const result = await Command.execute(resolved.command, { + agent: true, + argv: [], + env: ctx.env, + envSource: options.env, + format: rpc.outputFormat ?? 'json', + formatExplicit: true, + inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, + middlewares: resolved.middlewares, + name: ctx.name, + parseMode: 'structured', + path: resolved.id, + vars: ctx.vars, + version: ctx.version, + }) + + if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) + if (!result.ok) + return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta), rpc) + return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) }, } } @@ -40,75 +103,6 @@ export declare namespace createClientRequest { } } -async function execute( - ctx: CommandTree.RuntimeCliContext, - request: unknown, - options: createClientRequest.Options, -): Promise { - const start = performance.now() - const parsed = requestSchema.safeParse(request) - if (!parsed.success) - return errorEnvelope('', start, { - code: 'INVALID_RPC_REQUEST', - message: 'Invalid RPC request.', - fieldErrors: parsed.error.issues.map((issue) => ({ - code: issue.code, - expected: 'valid RPC request', - received: 'invalid', - message: issue.message, - path: issue.path.join('.'), - })), - }) - - const rpc = parsed.data - if (!rpc.command) - return errorEnvelope('', start, { - code: 'INVALID_RPC_REQUEST', - message: 'RPC command is required.', - }) - - const resolved = CommandTree.resolveCanonical(ctx, rpc.command) - if ('error' in resolved) - return errorEnvelope(rpc.command, start, { - code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', - message: - resolved.error === 'empty' - ? 'RPC command is required.' - : `'${resolved.token}' is not a command for '${resolved.parent}'.`, - }) - if ('commands' in resolved) - return errorEnvelope(rpc.command, start, { - code: 'COMMAND_GROUP', - message: `'${resolved.id}' is a command group. Specify a subcommand.`, - }) - if ('gateway' in resolved) - return errorEnvelope(rpc.command, start, { - code: 'FETCH_GATEWAY', - message: `'${resolved.id}' is a raw fetch gateway and cannot be called with structured RPC.`, - }) - - const result = await Command.execute(resolved.command, { - agent: true, - argv: [], - env: ctx.env, - envSource: options.env, - format: rpc.outputFormat ?? 'json', - formatExplicit: true, - inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, - middlewares: resolved.middlewares, - name: ctx.name, - parseMode: 'structured', - path: resolved.id, - vars: ctx.vars, - version: ctx.version, - }) - - if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) - if (!result.ok) - return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta), rpc) - return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) -} - function streamResponse( stream: AsyncGenerator, command: string, From 93af9113c503a7cea58a2a376af6633c916d8c54 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 13:40:44 +0200 Subject: [PATCH 17/55] refactor(client): reuse cli command tree internals --- src/Cli.ts | 24 +++--- src/internal/client-discover.ts | 17 ++-- src/internal/client-request.test.ts | 120 ++++++++++++++-------------- src/internal/command-tree.ts | 86 +++++++------------- 4 files changed, 111 insertions(+), 136 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 4136dd5..e0126bc 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1678,7 +1678,7 @@ async function fetchImpl( if (segments[0] === '_incur') { const ctx: CommandTree.RuntimeCliContext = { - commands: commands as Map, + commands, ...(options.description ? { description: options.description } : undefined), ...(options.envSchema ? { env: options.envSchema } : undefined), middlewares: options.middlewares ?? [], @@ -2595,7 +2595,7 @@ export type CommandsMap = Record< > /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ -type CommandEntry = +export type CommandEntry = | CommandDefinition | InternalGroup | InternalFetchGateway @@ -2611,7 +2611,7 @@ export type FetchHandler = Fetch.Handler export type FetchSource = Fetch.Source /** @internal A command group's internal storage. */ -type InternalGroup = { +export type InternalGroup = { _group: true description?: string | undefined middlewares?: MiddlewareHandler[] | undefined @@ -2620,7 +2620,7 @@ type InternalGroup = { } /** @internal A fetch gateway entry. */ -type InternalFetchGateway = { +export type InternalFetchGateway = { _fetch: true basePath?: string | undefined description?: string | undefined @@ -2646,29 +2646,29 @@ function fetchBaseUrl(source: FetchSource) { } /** @internal Type guard for command groups. */ -function isGroup(entry: CommandEntry): entry is InternalGroup { +export function isGroup(entry: CommandEntry): entry is InternalGroup { return '_group' in entry } /** @internal Type guard for fetch gateways. */ -function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { +export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { return '_fetch' in entry } /** @internal An alias entry that points to another command by name. */ -type InternalAlias = { +export type InternalAlias = { _alias: true /** The canonical command name this alias resolves to. */ target: string } /** @internal Type guard for alias entries. */ -function isAlias(entry: CommandEntry): entry is InternalAlias { +export function isAlias(entry: CommandEntry): entry is InternalAlias { return '_alias' in entry } /** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */ -function resolveAlias( +export function resolveAlias( commands: Map, entry: CommandEntry, ): Exclude { @@ -3205,7 +3205,7 @@ function buildInputSchema( } /** @internal A usage example for a command, typed against its args and options schemas. */ -type Example< +export type Example< args extends z.ZodObject | undefined, options extends z.ZodObject | undefined, > = { @@ -3218,7 +3218,7 @@ type Example< } /** @internal A usage pattern shown in help output. */ -type Usage< +export type Usage< args extends z.ZodObject | undefined, options extends z.ZodObject | undefined, > = { @@ -3294,7 +3294,7 @@ declare namespace Output { } /** @internal Defines a command's schema, handler, and metadata. */ -type CommandDefinition< +export type CommandDefinition< args extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, options extends z.ZodObject | undefined = undefined, diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 053c221..7646a36 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -2,6 +2,7 @@ import { parse as yamlParse, stringify as yamlStringify } from 'yaml' import { z } from 'zod' import * as Cli from '../Cli.js' +import type { CommandDefinition as CliCommandDefinition, CommandEntry } from '../Cli.js' import type * as ClientDiscover from '../client/Discover.js' import { BaseError } from '../Errors.js' import * as Formatter from '../Formatter.js' @@ -11,6 +12,8 @@ import * as Openapi from '../Openapi.js' import * as Skill from '../Skill.js' import * as CommandTree from './command-tree.js' +type CommandDefinition = CliCommandDefinition + /** Discover failure with protocol code and HTTP status metadata. */ export class DiscoverError extends BaseError { override name = 'Incur.DiscoverError' @@ -198,18 +201,14 @@ function skills(ctx: CommandTree.RuntimeCliContext) { return { files: Skill.split(ctx.name, entries, 1, groups) } } -function manifest( - commands: Map, - prefix: string[], - full: boolean, -) { +function manifest(commands: Map, prefix: string[], full: boolean) { return { version: 'incur.v1', commands: collect(commands, prefix, full).sort((a, b) => a.name.localeCompare(b.name)), } } -function collect(commands: Map, prefix: string[], full: boolean) { +function collect(commands: Map, prefix: string[], full: boolean) { const result: { name: string description?: string | undefined @@ -239,10 +238,10 @@ function collect(commands: Map, prefix: string } function skillCommands( - commands: Map, + commands: Map, prefix: string[], groups: Map, - rootCommand?: CommandTree.CommandDefinition | undefined, + rootCommand?: CommandDefinition | undefined, ): Skill.CommandInfo[] { const result: Skill.CommandInfo[] = [] if (rootCommand) result.push(toSkillCommand(rootCommand, undefined)) @@ -259,7 +258,7 @@ function skillCommands( return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) } -function toSkillCommand(command: CommandTree.CommandDefinition, name: string | undefined) { +function toSkillCommand(command: CommandDefinition, name: string | undefined) { return { ...(name ? { name } : undefined), ...(command.description ? { description: command.description } : undefined), diff --git a/src/internal/client-request.test.ts b/src/internal/client-request.test.ts index d420b0a..1ab3146 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/client-request.test.ts @@ -75,34 +75,30 @@ function createFixture() { return { cli, order, ctx: CommandTree.fromCli(cli) } } -function request( - ctx: CommandTree.RuntimeCliContext, - body: unknown, - options: createClientRequest.Options = {}, -) { - return createClientRequest(ctx, options).request(body) -} - describe('createClientRequest', () => { test('executes root, mounted root, and mounted router commands by canonical ID', async () => { const { ctx, order } = createFixture() await expect( - request(ctx, { command: ' root ', args: {}, options: {} }, { env: { API_KEY: 'k' } }), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: ' root ', + args: {}, + options: {}, + }), ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) await expect( - request( - ctx, - { command: 'child', args: { id: 'c1' }, options: { loud: true } }, - { env: { API_KEY: 'k', TOKEN: 't' } }, - ), + createClientRequest(ctx, { env: { API_KEY: 'k', TOKEN: 't' } }).request({ + command: 'child', + args: { id: 'c1' }, + options: { loud: true }, + }), ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) await expect( - request( - ctx, - { command: 'project list', args: { projectId: 'p1' }, options: { limit: 1 } }, - { env: { API_KEY: 'k' } }, - ), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: { limit: 1 }, + }), ).resolves.toMatchObject({ ok: true, data: { items: [{ id: 'a' }, { id: 'b' }] }, @@ -125,23 +121,24 @@ describe('createClientRequest', () => { test('rejects invalid RPC shape, unknown commands, groups, aliases, and raw fetch gateways', async () => { const { ctx } = createFixture() - await expect(request(ctx, { command: '' })).resolves.toMatchObject({ + const { request } = createClientRequest(ctx) + await expect(request({ command: '' })).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' }, }) - await expect(request(ctx, { command: 'missing' })).resolves.toMatchObject({ + await expect(request({ command: 'missing' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_NOT_FOUND' }, }) - await expect(request(ctx, { command: 'project' })).resolves.toMatchObject({ + await expect(request({ command: 'project' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_GROUP' }, }) - await expect(request(ctx, { command: 'alias' })).resolves.toMatchObject({ + await expect(request({ command: 'alias' })).resolves.toMatchObject({ ok: false, error: { code: 'COMMAND_NOT_FOUND' }, }) - await expect(request(ctx, { command: 'raw' })).resolves.toMatchObject({ + await expect(request({ command: 'raw' })).resolves.toMatchObject({ ok: false, error: { code: 'FETCH_GATEWAY' }, }) @@ -150,42 +147,46 @@ describe('createClientRequest', () => { test('validates structured args, options, CLI env, and command env independently', async () => { const { ctx } = createFixture() await expect( - request( - ctx, - { command: 'project list', args: {}, options: { limit: 1 } }, - { env: { API_KEY: 'k' } }, - ), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: {}, + options: { limit: 1 }, + }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - request( - ctx, - { command: 'project list', args: { projectId: 'p' }, options: { limit: 'bad' } }, - { env: { API_KEY: 'k' } }, - ), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p' }, + options: { limit: 'bad' }, + }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - request(ctx, { command: 'project list', args: { projectId: 'p' }, options: {} }), + createClientRequest(ctx).request({ + command: 'project list', + args: { projectId: 'p' }, + options: {}, + }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - request(ctx, { command: 'child', args: { id: 'c' }, options: {} }, { env: { API_KEY: 'k' } }), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'child', + args: { id: 'c' }, + options: {}, + }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) }) test('applies selection, formatting, token metadata, and CTA metadata', async () => { const { ctx } = createFixture() - const response = await request( - ctx, - { - command: 'project list', - args: { projectId: 'p1' }, - options: {}, - outputFormat: 'json', - outputTokenCount: true, - outputTokenLimit: 4, - selection: ['items[0,1]'], - }, - { env: { API_KEY: 'k' } }, - ) + const response = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 4, + selection: ['items[0,1]'], + }) expect(response).toMatchObject({ ok: true, data: { items: [{ id: 'a' }] }, @@ -196,22 +197,25 @@ describe('createClientRequest', () => { test('rejects empty selections and omits token count unless requested', async () => { const { ctx } = createFixture() - await expect(request(ctx, { command: 'project list', selection: [] })).resolves.toMatchObject({ + await expect( + createClientRequest(ctx).request({ command: 'project list', selection: [] }), + ).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' }, }) await expect( - request( - ctx, - { command: 'project list', args: { projectId: 'p1' }, options: {} }, - { env: { API_KEY: 'k' } }, - ), + createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + }), ).resolves.not.toMatchObject({ meta: { outputTokenCount: expect.any(Number) } }) }) test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { const { ctx, order } = createFixture() - const response = await request(ctx, { command: 'project stream' }, { env: { API_KEY: 'k' } }) + const { request } = createClientRequest(ctx, { env: { API_KEY: 'k' } }) + const response = await request({ command: 'project stream' }) if (!('stream' in response)) throw new Error('expected stream') const records: unknown[] = [] for await (const record of response.records()) records.push(record) @@ -221,7 +225,7 @@ describe('createClientRequest', () => { { type: 'done', ok: true, meta: { command: 'project stream', cta: expect.any(Object) } }, ]) - const failed = await request(ctx, { command: 'project fail-stream' }, { env: { API_KEY: 'k' } }) + const failed = await request({ command: 'project fail-stream' }) if (!('stream' in failed)) throw new Error('expected stream') const failedRecords: unknown[] = [] for await (const record of failed.records()) failedRecords.push(record) @@ -232,7 +236,7 @@ describe('createClientRequest', () => { meta: { command: 'project fail-stream' }, }) - const cancelled = await request(ctx, { command: 'project stream' }, { env: { API_KEY: 'k' } }) + const cancelled = await request({ command: 'project stream' }) if (!('stream' in cancelled)) throw new Error('expected stream') const iterator = cancelled.records() await iterator.next() diff --git a/src/internal/command-tree.ts b/src/internal/command-tree.ts index c5a05ef..b868af8 100644 --- a/src/internal/command-tree.ts +++ b/src/internal/command-tree.ts @@ -1,6 +1,13 @@ import type { z } from 'zod' import * as Cli from '../Cli.js' +import type { + CommandDefinition, + CommandEntry, + InternalAlias, + InternalFetchGateway, + InternalGroup, +} from '../Cli.js' import type { Handler as MiddlewareHandler } from '../middleware.js' import * as Schema from '../Schema.js' @@ -19,7 +26,7 @@ export type RuntimeCliContext = { /** CLI name. */ name: string /** Root command definition, when the CLI itself is callable. */ - rootCommand?: CommandDefinition | undefined + rootCommand?: CommandDefinition | undefined /** Local skill sync defaults. */ sync?: | { @@ -35,52 +42,9 @@ export type RuntimeCliContext = { version?: string | undefined } -/** Internal command entry shape shared by CLI consumers. */ -export type CommandEntry = CommandDefinition | CommandGroup | FetchGateway | CommandAlias - -/** Internal command definition shape. */ -export type CommandDefinition = { - alias?: Record | undefined - args?: z.ZodObject | undefined - description?: string | undefined - env?: z.ZodObject | undefined - examples?: unknown[] | undefined - hint?: string | undefined - middleware?: MiddlewareHandler[] | undefined - options?: z.ZodObject | undefined - output?: z.ZodType | undefined - outputPolicy?: Cli.OutputPolicy | undefined - run: Function - usage?: unknown[] | undefined -} - -/** Internal command group shape. */ -export type CommandGroup = { - _group: true - commands: Map - description?: string | undefined - middlewares?: MiddlewareHandler[] | undefined - outputPolicy?: Cli.OutputPolicy | undefined -} - -/** Internal raw fetch gateway shape. */ -export type FetchGateway = { - _fetch: true - basePath?: string | undefined - description?: string | undefined - fetch: (req: Request) => Response | Promise - outputPolicy?: Cli.OutputPolicy | undefined -} - -/** Internal alias entry shape. */ -export type CommandAlias = { - _alias: true - target: string -} - /** Resolved callable command. */ export type ResolvedCommand = { - command: CommandDefinition + command: CommandDefinition id: string middlewares: MiddlewareHandler[] } @@ -94,7 +58,7 @@ export type ResolvedGroup = { /** Resolved raw fetch gateway. */ export type ResolvedFetchGateway = { - gateway: FetchGateway + gateway: InternalFetchGateway id: string middlewares: MiddlewareHandler[] } @@ -112,7 +76,16 @@ export function fromCli(cli: Cli.Cli): RuntimeCliContext { ...(Cli.toMcpOptions.get(cli) ? { mcp: Cli.toMcpOptions.get(cli) } : undefined), name: cli.name, ...(Cli.toRootDefinition.get(cli as unknown as Cli.Root) - ? { rootCommand: Cli.toRootDefinition.get(cli as unknown as Cli.Root) as CommandDefinition } + ? { + rootCommand: Cli.toRootDefinition.get(cli as unknown as Cli.Root) as CommandDefinition< + any, + any, + any, + any, + any, + any + >, + } : undefined), ...(Cli.toSyncOptions.get(cli) ? { sync: Cli.toSyncOptions.get(cli) } : undefined), ...(cli.vars ? { vars: cli.vars } : undefined), @@ -121,27 +94,26 @@ export function fromCli(cli: Cli.Cli): RuntimeCliContext { } /** Returns true when an entry is an alias. */ -export function isAlias(entry: CommandEntry): entry is CommandAlias { - return '_alias' in entry +export function isAlias(entry: CommandEntry): entry is InternalAlias { + return Cli.isAlias(entry) } /** Returns true when an entry is a command group. */ -export function isGroup(entry: CommandEntry): entry is CommandGroup { - return '_group' in entry +export function isGroup(entry: CommandEntry): entry is InternalGroup { + return Cli.isGroup(entry) } /** Returns true when an entry is a raw fetch gateway. */ -export function isFetchGateway(entry: CommandEntry): entry is FetchGateway { - return '_fetch' in entry +export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { + return Cli.isFetchGateway(entry) } /** Resolves an alias entry within its owning command map. */ export function resolveAlias( commands: Map, entry: CommandEntry, -): Exclude { - if (!isAlias(entry)) return entry - return commands.get(entry.target)! as Exclude +): Exclude { + return Cli.resolveAlias(commands, entry) as Exclude } /** Resolves a canonical command ID without accepting aliases. */ @@ -216,7 +188,7 @@ function collect( } /** Builds the structured input schema used by discovery payloads. */ -export function buildInputSchema(command: CommandDefinition): +export function buildInputSchema(command: CommandDefinition): | { args?: Record | undefined env?: Record | undefined From ba157252eaa6bcf697d4f4a2f3dd28d34b7bf953 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 13:50:30 +0200 Subject: [PATCH 18/55] refactor(client): rename runtime context module --- src/Cli.ts | 4 ++-- src/client/transports/MemoryTransport.ts | 4 ++-- src/internal/client-discover.ts | 24 +++++++++---------- src/internal/client-local.ts | 4 ++-- src/internal/client-request.test.ts | 4 ++-- src/internal/client-request.ts | 6 ++--- ...test.ts => client-runtime-context.test.ts} | 18 +++++++------- ...mand-tree.ts => client-runtime-context.ts} | 0 8 files changed, 33 insertions(+), 31 deletions(-) rename src/internal/{command-tree.test.ts => client-runtime-context.test.ts} (80%) rename src/internal/{command-tree.ts => client-runtime-context.ts} (100%) diff --git a/src/Cli.ts b/src/Cli.ts index e0126bc..d450c8e 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -14,7 +14,7 @@ import * as Formatter from './Formatter.js' import * as Help from './Help.js' import { createClientDiscover, DiscoverError } from './internal/client-discover.js' import { createClientRequest } from './internal/client-request.js' -import * as CommandTree from './internal/command-tree.js' +import * as RuntimeContext from './internal/client-runtime-context.js' import { builtinCommands, type CommandMeta, @@ -1677,7 +1677,7 @@ async function fetchImpl( const segments = url.pathname.split('/').filter(Boolean) if (segments[0] === '_incur') { - const ctx: CommandTree.RuntimeCliContext = { + const ctx: RuntimeContext.RuntimeCliContext = { commands, ...(options.description ? { description: options.description } : undefined), ...(options.envSchema ? { env: options.envSchema } : undefined), diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index 73aa3d4..a922927 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -2,7 +2,7 @@ import * as Cli from '../../Cli.js' import { createClientDiscover } from '../../internal/client-discover.js' import { createClientLocal } from '../../internal/client-local.js' import { createClientRequest } from '../../internal/client-request.js' -import * as CommandTree from '../../internal/command-tree.js' +import * as RuntimeContext from '../../internal/client-runtime-context.js' import { ClientError } from '../ClientError.js' import type * as Discover from '../Discover.js' import type * as Local from '../Local.js' @@ -30,7 +30,7 @@ export type Options = { /** Creates an in-process memory transport. */ export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { return () => { - const ctx = CommandTree.fromCli(cli) + const ctx = RuntimeContext.fromCli(cli) const { request } = createClientRequest(ctx, { env: options.env }) const { discover } = createClientDiscover(ctx) const { local } = createClientLocal(ctx) diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 7646a36..5bf14b9 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -10,7 +10,7 @@ import * as Help from '../Help.js' import * as Mcp from '../Mcp.js' import * as Openapi from '../Openapi.js' import * as Skill from '../Skill.js' -import * as CommandTree from './command-tree.js' +import * as RuntimeContext from './client-runtime-context.js' type CommandDefinition = CliCommandDefinition @@ -49,7 +49,7 @@ const requestSchema = z.discriminatedUnion('resource', [ ]) /** Creates the shared client discovery executor. */ -export function createClientDiscover(ctx: CommandTree.RuntimeCliContext) { +export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { return { async discover(request: unknown): Promise { const parsedRequest = requestSchema.safeParse(request) @@ -123,7 +123,7 @@ export function createClientDiscover(ctx: CommandTree.RuntimeCliContext) { if (parsed.resource === 'schema') { if (scoped.type === 'command') { - const schema = CommandTree.buildInputSchema(scoped.command) + const schema = RuntimeContext.buildInputSchema(scoped.command) return { contentType: 'application/json', data: schema ?? {} } } return { @@ -151,7 +151,7 @@ export function createClientDiscover(ctx: CommandTree.RuntimeCliContext) { } } -function scope(ctx: CommandTree.RuntimeCliContext, command: string | undefined) { +function scope(ctx: RuntimeContext.RuntimeCliContext, command: string | undefined) { if (!command) return { type: 'group' as const, @@ -161,7 +161,7 @@ function scope(ctx: CommandTree.RuntimeCliContext, command: string | undefined) rootCommand: ctx.rootCommand, description: ctx.description, } - const resolved = CommandTree.resolveCanonical(ctx, command) + const resolved = RuntimeContext.resolveCanonical(ctx, command) if ('error' in resolved) throw new DiscoverError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) if ('gateway' in resolved) @@ -186,7 +186,7 @@ function scope(ctx: CommandTree.RuntimeCliContext, command: string | undefined) } } -function openapi(ctx: CommandTree.RuntimeCliContext) { +function openapi(ctx: RuntimeContext.RuntimeCliContext) { const cli = { name: ctx.name, description: ctx.description } as any Cli.toCommands.set(cli, ctx.commands as any) if (ctx.rootCommand) Cli.toRootDefinition.set(cli as Cli.Root, ctx.rootCommand as any) @@ -195,7 +195,7 @@ function openapi(ctx: CommandTree.RuntimeCliContext) { }) } -function skills(ctx: CommandTree.RuntimeCliContext) { +function skills(ctx: RuntimeContext.RuntimeCliContext) { const groups = new Map() const entries = skillCommands(ctx.commands, [], groups, ctx.rootCommand) return { files: Skill.split(ctx.name, entries, 1, groups) } @@ -215,14 +215,14 @@ function collect(commands: Map, prefix: string[], full: bo schema?: Record | undefined }[] = [] for (const [name, entry] of commands) { - if (CommandTree.isAlias(entry) || CommandTree.isFetchGateway(entry)) continue + if (RuntimeContext.isAlias(entry) || RuntimeContext.isFetchGateway(entry)) continue const path = [...prefix, name] - if (CommandTree.isGroup(entry)) result.push(...collect(entry.commands, path, full)) + if (RuntimeContext.isGroup(entry)) result.push(...collect(entry.commands, path, full)) else { const command: (typeof result)[number] = { name: path.join(' ') } if (entry.description) command.description = entry.description if (full) { - const input = CommandTree.buildInputSchema(entry) + const input = RuntimeContext.buildInputSchema(entry) if (input || entry.output) { command.schema = {} if (input?.args) command.schema.args = input.args @@ -246,9 +246,9 @@ function skillCommands( const result: Skill.CommandInfo[] = [] if (rootCommand) result.push(toSkillCommand(rootCommand, undefined)) for (const [name, entry] of commands) { - if (CommandTree.isAlias(entry) || CommandTree.isFetchGateway(entry)) continue + if (RuntimeContext.isAlias(entry) || RuntimeContext.isFetchGateway(entry)) continue const path = [...prefix, name] - if (CommandTree.isGroup(entry)) { + if (RuntimeContext.isGroup(entry)) { if (entry.description) groups.set(path.join(' '), entry.description) result.push(...skillCommands(entry.commands, path, groups)) continue diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts index b411aae..8f76eb5 100644 --- a/src/internal/client-local.ts +++ b/src/internal/client-local.ts @@ -2,7 +2,7 @@ import type * as Local from '../client/Local.js' import { BaseError } from '../Errors.js' import * as SyncMcp from '../SyncMcp.js' import * as SyncSkills from '../SyncSkills.js' -import type * as CommandTree from './command-tree.js' +import type * as RuntimeContext from './client-runtime-context.js' /** Local setup/admin failure. */ export class LocalError extends BaseError { @@ -10,7 +10,7 @@ export class LocalError extends BaseError { } /** Creates local setup/admin wrappers for a memory transport. */ -export function createClientLocal(ctx: CommandTree.RuntimeCliContext) { +export function createClientLocal(ctx: RuntimeContext.RuntimeCliContext) { return { local: { skills: { diff --git a/src/internal/client-request.test.ts b/src/internal/client-request.test.ts index 1ab3146..f99654e 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/client-request.test.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import * as Cli from '../Cli.js' import { createClientRequest } from './client-request.js' -import * as CommandTree from './command-tree.js' +import * as RuntimeContext from './client-runtime-context.js' function createFixture() { const order: string[] = [] @@ -72,7 +72,7 @@ function createFixture() { cli.command(child) cli.command(router) cli.command('raw', { fetch: () => new Response('{}') }) - return { cli, order, ctx: CommandTree.fromCli(cli) } + return { cli, order, ctx: RuntimeContext.fromCli(cli) } } describe('createClientRequest', () => { diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 05deaf5..5afb09d 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -5,7 +5,7 @@ import type * as ClientRequest from '../client/Request.js' import type { FieldError } from '../Errors.js' import * as Filter from '../Filter.js' import * as Formatter from '../Formatter.js' -import * as CommandTree from './command-tree.js' +import * as RuntimeContext from './client-runtime-context.js' import * as Command from './command.js' const requestSchema = z.object({ @@ -22,7 +22,7 @@ const sentinel = Symbol.for('incur.sentinel') /** Creates the shared client request executor. */ export function createClientRequest( - ctx: CommandTree.RuntimeCliContext, + ctx: RuntimeContext.RuntimeCliContext, options: createClientRequest.Options = {}, ) { return { @@ -51,7 +51,7 @@ export function createClientRequest( message: 'RPC command is required.', }) - const resolved = CommandTree.resolveCanonical(ctx, rpc.command) + const resolved = RuntimeContext.resolveCanonical(ctx, rpc.command) if ('error' in resolved) return errorEnvelope(rpc.command, start, { code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', diff --git a/src/internal/command-tree.test.ts b/src/internal/client-runtime-context.test.ts similarity index 80% rename from src/internal/command-tree.test.ts rename to src/internal/client-runtime-context.test.ts index d7d0361..ab0db2b 100644 --- a/src/internal/command-tree.test.ts +++ b/src/internal/client-runtime-context.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' import * as Cli from '../Cli.js' -import * as CommandTree from './command-tree.js' +import * as RuntimeContext from './client-runtime-context.js' -describe('command-tree', () => { +describe('client-runtime-context', () => { test('collects canonical client command IDs and excludes aliases/raw gateways', () => { const root = Cli.create('root', { run() { @@ -32,15 +32,17 @@ describe('command-tree', () => { root.command(mounted) root.command(router) - const ctx = CommandTree.fromCli(root) - expect(CommandTree.collectClientCommands(ctx).map((entry) => entry.id)).toEqual([ + const ctx = RuntimeContext.fromCli(root) + expect(RuntimeContext.collectClientCommands(ctx).map((entry) => entry.id)).toEqual([ 'mounted', 'project nested leaf', 'root', 'target', ]) - expect(CommandTree.resolveCanonical(ctx, 'alias')).toMatchObject({ error: 'unknown' }) - expect(CommandTree.resolveCanonical(ctx, 'raw')).toMatchObject({ gateway: expect.any(Object) }) + expect(RuntimeContext.resolveCanonical(ctx, 'alias')).toMatchObject({ error: 'unknown' }) + expect(RuntimeContext.resolveCanonical(ctx, 'raw')).toMatchObject({ + gateway: expect.any(Object), + }) }) test('includes OpenAPI-mounted operations without serving first', () => { @@ -74,7 +76,7 @@ describe('command-tree', () => { }, }) - const command = CommandTree.collectClientCommands(CommandTree.fromCli(cli))[0]! + const command = RuntimeContext.collectClientCommands(RuntimeContext.fromCli(cli))[0]! expect(command.id).toBe('api getUser') expect(command.command.args?.shape.id).toBeDefined() expect(command.command.output).toBeDefined() @@ -87,7 +89,7 @@ describe('command-tree', () => { options: z.object({ limit: z.number().optional() }), run() {}, } - expect(CommandTree.buildInputSchema(command)).toMatchObject({ + expect(RuntimeContext.buildInputSchema(command)).toMatchObject({ args: { properties: { id: { type: 'string' } } }, env: { properties: { TOKEN: { type: 'string' } } }, options: { properties: { limit: { type: 'number' } } }, diff --git a/src/internal/command-tree.ts b/src/internal/client-runtime-context.ts similarity index 100% rename from src/internal/command-tree.ts rename to src/internal/client-runtime-context.ts From 1afb8d5c84f4dda8b7dcffb2715eff5abbc230a3 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 14:19:56 +0200 Subject: [PATCH 19/55] refactor(client): move request status mapping --- src/Cli.test.ts | 3 ++- src/Cli.ts | 4 ++-- src/internal/client-request.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 33c20b8..5dc5d17 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4175,7 +4175,8 @@ describe('Command.execute', () => { async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() - if (body.meta?.duration) body.meta.duration = '' + expect(body.meta.duration).toMatch(/^\d+ms$/) + body.meta.duration = '' return { status: res.status, body } } diff --git a/src/Cli.ts b/src/Cli.ts index d450c8e..c3fb1da 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -13,7 +13,7 @@ import * as Filter from './Filter.js' import * as Formatter from './Formatter.js' import * as Help from './Help.js' import { createClientDiscover, DiscoverError } from './internal/client-discover.js' -import { createClientRequest } from './internal/client-request.js' +import { createClientRequest, getClientRequestStatus } from './internal/client-request.js' import * as RuntimeContext from './internal/client-runtime-context.js' import { builtinCommands, @@ -1723,7 +1723,7 @@ async function fetchImpl( }) } return new Response(JSON.stringify(response), { - status: response.ok ? 200 : rpcStatus(response.error.code), + status: response.ok ? 200 : getClientRequestStatus(response.error.code), headers: { 'content-type': 'application/json' }, }) } diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 5afb09d..5894803 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -20,6 +20,14 @@ const requestSchema = z.object({ }) const sentinel = Symbol.for('incur.sentinel') +/** Returns the HTTP status for a client request error code. */ +export function getClientRequestStatus(code: string) { + if (code === 'COMMAND_NOT_FOUND') return 404 + if (code === 'VALIDATION_ERROR' || code === 'INVALID_RPC_REQUEST') return 400 + if (code === 'COMMAND_GROUP' || code === 'FETCH_GATEWAY') return 400 + return 500 +} + /** Creates the shared client request executor. */ export function createClientRequest( ctx: RuntimeContext.RuntimeCliContext, From d3bd9e300e0d9d7b9dc14c9a25a4873b9852e0b4 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 16:29:24 +0200 Subject: [PATCH 20/55] fix(client): expose rpc output metadata --- src/client/Request.ts | 14 ++- src/client/transports/HttpTransport.test.ts | 29 +++++ src/client/transports/MemoryTransport.test.ts | 29 +++++ src/internal/client-request.test.ts | 110 ++++++++++++++++-- src/internal/client-request.ts | 77 ++++++------ 5 files changed, 209 insertions(+), 50 deletions(-) diff --git a/src/client/Request.ts b/src/client/Request.ts index 113eb8f..0e646a5 100644 --- a/src/client/Request.ts +++ b/src/client/Request.ts @@ -25,6 +25,16 @@ export type Request = { export type Output = { /** Rendered output text. */ text: string + /** Rendered format. */ + format?: Formatter.Format | undefined + /** Offset to request for the next token window. */ + nextOffset?: number | undefined + /** Rendered token count before truncation. */ + tokenCount?: number | undefined + /** Requested token limit. */ + tokenLimit?: number | undefined + /** Requested token offset. */ + tokenOffset?: number | undefined /** Whether text was truncated by token controls. */ truncated?: boolean | undefined } @@ -37,10 +47,6 @@ export type Meta = { cta?: unknown | undefined /** Wall-clock duration. */ duration: string - /** Offset to request for the next token window. */ - nextOffset?: number | undefined - /** Rendered token count before truncation. */ - outputTokenCount?: number | undefined } /** Full request success/error envelope. */ diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index 4846a53..1137111 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -91,6 +91,35 @@ describe('HttpTransport', () => { }) }) + test('preserves rendered output metadata from JSON envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + test('wraps fetch rejection and rejects malformed JSON envelopes', async () => { const failing = vi.fn(async () => { throw new Error('offline') diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index 48a30e1..7db6176 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -56,6 +56,35 @@ describe('MemoryTransport', () => { }) }) + test('preserves rendered output metadata for in-process execution', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + test('discovers every resource in process', async () => { const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { description: 'Show status', diff --git a/src/internal/client-request.test.ts b/src/internal/client-request.test.ts index f99654e..02fa244 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/client-request.test.ts @@ -190,9 +190,20 @@ describe('createClientRequest', () => { expect(response).toMatchObject({ ok: true, data: { items: [{ id: 'a' }] }, - meta: { command: 'project list', nextOffset: 4, outputTokenCount: expect.any(Number) }, - output: { truncated: true }, + meta: { command: 'project list' }, + output: { + format: 'json', + nextOffset: 4, + tokenCount: expect.any(Number), + tokenLimit: 4, + tokenOffset: 0, + truncated: true, + }, }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.meta).not.toHaveProperty('nextOffset') + expect(response.meta).not.toHaveProperty('outputTokenCount') }) test('rejects empty selections and omits token count unless requested', async () => { @@ -203,26 +214,103 @@ describe('createClientRequest', () => { ok: false, error: { code: 'INVALID_RPC_REQUEST' }, }) - await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ - command: 'project list', - args: { projectId: 'p1' }, - options: {}, - }), - ).resolves.not.toMatchObject({ meta: { outputTokenCount: expect.any(Number) } }) + const response = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.output).toMatchObject({ format: 'json' }) + expect(response.output).not.toHaveProperty('tokenCount') + expect(response.output).not.toHaveProperty('tokenLimit') + expect(response.output).not.toHaveProperty('tokenOffset') + expect(response.output).not.toHaveProperty('nextOffset') + + const counted = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenCount: true, + }) + expect(counted).toMatchObject({ + ok: true, + output: { format: 'json', tokenCount: expect.any(Number) }, + }) + if ('stream' in counted || !counted.ok || !counted.output) throw new Error('expected success') + expect(counted.output).not.toHaveProperty('tokenLimit') + expect(counted.output).not.toHaveProperty('tokenOffset') + expect(counted.output).not.toHaveProperty('nextOffset') + expect(counted.output).not.toHaveProperty('truncated') + }) + + test('keeps token metadata on output for non-truncated and offset-only requests', async () => { + const { ctx } = createFixture() + const request = createClientRequest(ctx, { env: { API_KEY: 'k' } }).request + const limited = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenLimit: 100, + }) + expect(limited).toMatchObject({ + ok: true, + output: { + format: 'json', + tokenCount: expect.any(Number), + tokenLimit: 100, + tokenOffset: 0, + }, + }) + if ('stream' in limited || !limited.ok || !limited.output) throw new Error('expected success') + expect(limited.output).not.toHaveProperty('nextOffset') + expect(limited.output).not.toHaveProperty('truncated') + + const offset = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenOffset: 1, + }) + expect(offset).toMatchObject({ + ok: true, + output: { + format: 'json', + tokenCount: expect.any(Number), + tokenOffset: 1, + truncated: true, + }, + }) + if ('stream' in offset || !offset.ok || !offset.output) throw new Error('expected success') + expect(offset.output).not.toHaveProperty('nextOffset') }) test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { const { ctx, order } = createFixture() const { request } = createClientRequest(ctx, { env: { API_KEY: 'k' } }) - const response = await request({ command: 'project stream' }) + const response = await request({ + command: 'project stream', + outputTokenCount: true, + outputTokenLimit: 1, + }) if (!('stream' in response)) throw new Error('expected stream') const records: unknown[] = [] for await (const record of response.records()) records.push(record) expect(records).toMatchObject([ { type: 'chunk', data: { step: 1 } }, { type: 'chunk', data: { step: 2 } }, - { type: 'done', ok: true, meta: { command: 'project stream', cta: expect.any(Object) } }, + { + type: 'done', + ok: true, + meta: { command: 'project stream', cta: expect.any(Object) }, + output: { + format: 'json', + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 0, + truncated: true, + }, + }, ]) const failed = await request({ command: 'project fail-stream' }) diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 5894803..6f6909f 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -97,7 +97,7 @@ export function createClientRequest( if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) if (!result.ok) - return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta), rpc) + return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta)) return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) }, } @@ -126,13 +126,7 @@ function streamResponse( const { value, done } = await stream.next() if (done) { if (isSentinel(value) && value[sentinel] === 'error') { - terminal = errorRecord( - command, - start, - sentinelError(value), - formatCta('', value.cta), - request, - ) + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) } else { const data = isSentinel(value) ? value.data : undefined terminal = { @@ -150,13 +144,7 @@ function streamResponse( return terminal } if (isSentinel(value) && value[sentinel] === 'error') { - terminal = errorRecord( - command, - start, - sentinelError(value), - formatCta('', value.cta), - request, - ) + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) yield terminal return terminal } @@ -171,7 +159,6 @@ function streamResponse( message: error instanceof Error ? error.message : String(error), }, undefined, - request, ) yield terminal return terminal @@ -191,13 +178,12 @@ function successEnvelope( ): Extract { const selected = applySelection(data, request.selection) const output = renderOutput(selected, request) + const payload = outputPayload(output, request) return { ok: true, data: selected, - ...(output.text - ? { output: { text: output.text, ...(output.truncated ? { truncated: true } : undefined) } } - : undefined), - meta: meta(command, start, cta, output, request), + ...(payload ? { output: payload } : undefined), + meta: meta(command, start, cta), } } @@ -211,12 +197,11 @@ function errorEnvelope( retryable?: boolean | undefined }, cta?: unknown | undefined, - request: ClientRequest.Request = { command }, ): Extract { return { ok: false, error, - meta: meta(command, start, cta, renderOutput(undefined, request), request), + meta: meta(command, start, cta), } } @@ -230,9 +215,8 @@ function errorRecord( retryable?: boolean | undefined }, cta: unknown | undefined, - request: ClientRequest.Request, ): Extract { - return { type: 'error', ...errorEnvelope(command, start, error, cta, request) } + return { type: 'error', ...errorEnvelope(command, start, error, cta) } } function applySelection(data: unknown, selection: string[] | undefined) { @@ -244,34 +228,57 @@ function applySelection(data: unknown, selection: string[] | undefined) { } function renderOutput(data: unknown, request: ClientRequest.Request) { - const text = Formatter.format(data, request.outputFormat ?? 'json') + const format = request.outputFormat ?? 'json' + const text = Formatter.format(data, format) const count = estimateTokenCount(text) const offset = request.outputTokenOffset ?? 0 if (request.outputTokenLimit === undefined && request.outputTokenOffset === undefined) - return { text, count, truncated: false } + return { text, format, count, offset, truncated: false } const end = request.outputTokenLimit === undefined ? count : offset + request.outputTokenLimit const sliced = sliceByTokens(text, offset, end) return { text: sliced, + format, count, - truncated: end < count, + offset, + truncated: offset > 0 || end < count, nextOffset: end < count ? end : undefined, } } -function meta( - command: string, - start: number, - cta: unknown | undefined, - output: { count: number; nextOffset?: number | undefined }, +function outputPayload( + output: ReturnType, request: ClientRequest.Request, -): ClientRequest.Meta { +): ClientRequest.Output | undefined { + if (!output.text && !includeTokenMetadata(request)) return undefined + return { + text: output.text, + format: output.format, + ...(output.nextOffset !== undefined ? { nextOffset: output.nextOffset } : undefined), + ...(includeTokenMetadata(request) ? { tokenCount: output.count } : undefined), + ...(request.outputTokenLimit !== undefined + ? { tokenLimit: request.outputTokenLimit } + : undefined), + ...(request.outputTokenLimit !== undefined || request.outputTokenOffset !== undefined + ? { tokenOffset: output.offset } + : undefined), + ...(output.truncated ? { truncated: true } : undefined), + } +} + +function includeTokenMetadata(request: ClientRequest.Request) { + return ( + request.outputTokenCount || + request.outputTokenLimit !== undefined || + request.outputTokenOffset !== undefined + ) +} + +function meta(command: string, start: number, cta: unknown | undefined): ClientRequest.Meta { return { command, duration: `${Math.round(performance.now() - start)}ms`, ...(cta ? { cta } : undefined), - ...(request.outputTokenCount ? { outputTokenCount: output.count } : undefined), - ...(output.nextOffset !== undefined ? { nextOffset: output.nextOffset } : undefined), } } From c7dc375fbe836088fc4e3591fad426ada141d6eb Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 16:41:40 +0200 Subject: [PATCH 21/55] fix(client): share rendered output default --- src/Cli.ts | 34 ++++++++++++++++++----------- src/Formatter.ts | 5 ++++- src/internal/client-request.test.ts | 11 +++++----- src/internal/client-request.ts | 4 ++-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index c3fb1da..c3281ea 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -513,7 +513,7 @@ async function serveImpl( } catch (error) { const message = error instanceof Error ? error.message : String(error) if (human) writeln(formatHumanError({ code: 'UNKNOWN', message })) - else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon')) + else writeln(Formatter.format({ code: 'UNKNOWN', message }, Formatter.defaultFormat)) exit(1) return } @@ -716,7 +716,10 @@ async function serveImpl( if (human) { writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message })) writeln(formatHumanCta(cta)) - } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon')) + } else + writeln( + Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat), + ) exit(1) return } @@ -763,7 +766,7 @@ async function serveImpl( code: 'LIST_SKILLS_FAILED', message: err instanceof Error ? err.message : String(err), }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -819,13 +822,13 @@ async function serveImpl( if (fullOutput || formatExplicit) { const output: Record = { skills: result.paths } if (fullOutput && result.agents.length > 0) output.agents = result.agents - writeln(Formatter.format(output, formatExplicit ? formatFlag : 'toon')) + writeln(Formatter.format(output, formatExplicit ? formatFlag : Formatter.defaultFormat)) } } catch (err) { writeln( Formatter.format( { code: 'SYNC_SKILLS_FAILED', message: err instanceof Error ? err.message : String(err) }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -854,7 +857,10 @@ async function serveImpl( if (human) { writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message })) writeln(formatHumanCta(cta)) - } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon')) + } else + writeln( + Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat), + ) exit(1) return } @@ -903,14 +909,14 @@ async function serveImpl( writeln( Formatter.format( { name, command: result.command, agents: result.agents }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) } catch (err) { writeln( Formatter.format( { code: 'MCP_ADD_FAILED', message: err instanceof Error ? err.message : String(err) }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -1101,7 +1107,7 @@ async function serveImpl( return } const cmd = resolved.command - const format = formatExplicit ? formatFlag : 'toon' + const format = formatExplicit ? formatFlag : Formatter.defaultFormat const result: Record = {} if (cmd.args) result.args = Schema.toJsonSchema(cmd.args) if (cmd.env) result.env = Schema.toJsonSchema(cmd.env) @@ -1124,9 +1130,11 @@ async function serveImpl( const start = performance.now() - // Resolve effective format: explicit --format/--json → command default → CLI default → toon + // Resolve effective format: explicit --format/--json → command default → CLI default → Formatter.defaultFormat const resolvedFormat = 'command' in resolved && (resolved as any).command.format - const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon' + const format = formatExplicit + ? formatFlag + : resolvedFormat || options.format || Formatter.defaultFormat // Fall back to root fetch/command when no subcommand matches, // but only if the token doesn't look like a typo of a known command. @@ -2294,7 +2302,7 @@ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Option let help = false let version = false let schema = false - let format: Formatter.Format = 'toon' + let format: Formatter.Format = Formatter.defaultFormat let formatExplicit = false let configPath: string | undefined let configDisabled = false @@ -2801,7 +2809,7 @@ async function handleStreaming( // Incremental: no explicit format (default toon), or explicit jsonl // Buffered: explicit json/yaml/toon/md const useJsonl = ctx.format === 'jsonl' - const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === 'toon') + const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === Formatter.defaultFormat) if (incremental) { // Incremental output: write each chunk as it arrives diff --git a/src/Formatter.ts b/src/Formatter.ts index 21bfbdd..2685d8f 100644 --- a/src/Formatter.ts +++ b/src/Formatter.ts @@ -4,8 +4,11 @@ import { stringify as yamlStringify } from 'yaml' /** Supported output formats. */ export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl' +/** Default rendered output format. */ +export const defaultFormat = 'toon' satisfies Format + /** Serializes a value to the specified format. Defaults to TOON. */ -export function format(value: unknown, fmt: Format = 'toon'): string { +export function format(value: unknown, fmt: Format = defaultFormat): string { if (value == null) return '' if (fmt === 'json') { if (typeof value === 'string') { diff --git a/src/internal/client-request.test.ts b/src/internal/client-request.test.ts index 02fa244..fb38699 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/client-request.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' import * as Cli from '../Cli.js' +import * as Formatter from '../Formatter.js' import { createClientRequest } from './client-request.js' import * as RuntimeContext from './client-runtime-context.js' @@ -221,7 +222,7 @@ describe('createClientRequest', () => { }) if ('stream' in response || !response.ok || !response.output) throw new Error('expected success') - expect(response.output).toMatchObject({ format: 'json' }) + expect(response.output).toMatchObject({ format: Formatter.defaultFormat }) expect(response.output).not.toHaveProperty('tokenCount') expect(response.output).not.toHaveProperty('tokenLimit') expect(response.output).not.toHaveProperty('tokenOffset') @@ -235,7 +236,7 @@ describe('createClientRequest', () => { }) expect(counted).toMatchObject({ ok: true, - output: { format: 'json', tokenCount: expect.any(Number) }, + output: { format: Formatter.defaultFormat, tokenCount: expect.any(Number) }, }) if ('stream' in counted || !counted.ok || !counted.output) throw new Error('expected success') expect(counted.output).not.toHaveProperty('tokenLimit') @@ -256,7 +257,7 @@ describe('createClientRequest', () => { expect(limited).toMatchObject({ ok: true, output: { - format: 'json', + format: Formatter.defaultFormat, tokenCount: expect.any(Number), tokenLimit: 100, tokenOffset: 0, @@ -275,7 +276,7 @@ describe('createClientRequest', () => { expect(offset).toMatchObject({ ok: true, output: { - format: 'json', + format: Formatter.defaultFormat, tokenCount: expect.any(Number), tokenOffset: 1, truncated: true, @@ -304,7 +305,7 @@ describe('createClientRequest', () => { ok: true, meta: { command: 'project stream', cta: expect.any(Object) }, output: { - format: 'json', + format: Formatter.defaultFormat, tokenCount: expect.any(Number), tokenLimit: 1, tokenOffset: 0, diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 6f6909f..7845035 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -84,7 +84,7 @@ export function createClientRequest( argv: [], env: ctx.env, envSource: options.env, - format: rpc.outputFormat ?? 'json', + format: rpc.outputFormat ?? Formatter.defaultFormat, formatExplicit: true, inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, middlewares: resolved.middlewares, @@ -228,7 +228,7 @@ function applySelection(data: unknown, selection: string[] | undefined) { } function renderOutput(data: unknown, request: ClientRequest.Request) { - const format = request.outputFormat ?? 'json' + const format = request.outputFormat ?? Formatter.defaultFormat const text = Formatter.format(data, format) const count = estimateTokenCount(text) const offset = request.outputTokenOffset ?? 0 From 8d78ee4e598d7c5549dddbac992343f93dfa9301 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 16:57:23 +0200 Subject: [PATCH 22/55] fix(client): canonicalize runtime client contracts --- src/client/Local.ts | 5 +++- src/client/transports/HttpTransport.test.ts | 25 ++++++++++++++++ src/client/transports/MemoryTransport.test.ts | 30 +++++++++++++++++-- src/internal/client-discover.ts | 4 ++- src/internal/client-local.ts | 3 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/client/Local.ts b/src/client/Local.ts index dfbbd95..1399de3 100644 --- a/src/client/Local.ts +++ b/src/client/Local.ts @@ -29,7 +29,10 @@ export type McpAddOptions = { export type SyncedSkills = SyncSkills.sync.Result /** Skills list result. */ -export type SkillsList = SyncSkills.list.Skill[] +export type SkillsList = { + /** Listed skills. */ + skills: SyncSkills.list.Skill[] +} /** MCP registration result. */ export type McpRegistration = SyncMcp.register.Result diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index 1137111..559c459 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -297,6 +297,31 @@ describe('HttpTransport', () => { { request: { resource: 'llmsFull', command: 'status', format: 'json' }, url: 'https://example.com/_incur/llms-full?command=status&format=json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, + url: 'https://example.com/_incur/llms-full?command=status&format=jsonl', assert(response) { if (!('body' in response)) throw new Error('expected body') expect(response.contentType).toBe('text/plain') diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index 7db6176..4f17112 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -140,6 +140,30 @@ describe('MemoryTransport', () => { }, { request: { resource: 'llmsFull', command: 'status', format: 'json' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, assert(response) { if (!('body' in response)) throw new Error('expected body') expect(response.contentType).toBe('text/plain') @@ -330,8 +354,8 @@ describe('MemoryTransport', () => { expect(typeof transport.local.skills.add).toBe('function') expect(typeof transport.local.skills.list).toBe('function') expect(typeof transport.local.mcp.add).toBe('function') - await expect(transport.local.skills.list()).resolves.toEqual([ - expect.objectContaining({ installed: false, name: 'app-status' }), - ]) + await expect(transport.local.skills.list()).resolves.toEqual({ + skills: [expect.objectContaining({ installed: false, name: 'app-status' })], + }) }) }) diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 5bf14b9..75ad4e4 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -134,6 +134,8 @@ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { const full = parsed.resource === 'llmsFull' const format = parsed.format ?? 'md' + const data = manifest(scoped.commands, scoped.prefix, full) + if (format === 'json') return { contentType: 'application/json', data } if (format === 'md') { const groups = new Map() const entries = skillCommands(scoped.commands, scoped.prefix, groups, scoped.rootCommand) @@ -145,7 +147,7 @@ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { } return { contentType: 'text/plain', - body: Formatter.format(manifest(scoped.commands, scoped.prefix, full), format), + body: Formatter.format(data, format), } }, } diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts index 8f76eb5..155f60f 100644 --- a/src/internal/client-local.ts +++ b/src/internal/client-local.ts @@ -32,13 +32,14 @@ export function createClientLocal(ctx: RuntimeContext.RuntimeCliContext) { }, async list(options: Local.SkillsListOptions = {}) { try { - return await SyncSkills.list(ctx.name, ctx.commands, { + const skills = await SyncSkills.list(ctx.name, ctx.commands, { cwd: ctx.sync?.cwd, depth: options.depth ?? ctx.sync?.depth ?? 1, description: ctx.description, include: ctx.sync?.include, rootCommand: ctx.rootCommand, }) + return { skills } } catch (error) { throw new LocalError('Failed to list local skills.', { cause: error instanceof Error ? error : new Error(String(error)), From 60a802922f4a7ab67f71b0449ae0e1880403b638 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:09:48 +0200 Subject: [PATCH 23/55] fix(client): preserve HTTP transport error metadata --- src/client/Request.ts | 2 + src/client/transports/HttpTransport.test.ts | 51 +++++++++++++++++++++ src/client/transports/HttpTransport.ts | 8 +++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/client/Request.ts b/src/client/Request.ts index 0e646a5..a5c99cd 100644 --- a/src/client/Request.ts +++ b/src/client/Request.ts @@ -66,6 +66,8 @@ export type Envelope = retryable?: boolean | undefined } meta: Meta + /** HTTP status when the response came from an HTTP transport. */ + status?: number | undefined } /** Non-streaming request response. */ diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index 559c459..dfab133 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -120,6 +120,21 @@ describe('HttpTransport', () => { }) }) + test('preserves HTTP status on failed RPC envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.request({ command: 'missing' })).resolves.toMatchObject({ + ok: false, + status: 404, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + }) + test('wraps fetch rejection and rejects malformed JSON envelopes', async () => { const failing = vi.fn(async () => { throw new Error('offline') @@ -158,11 +173,47 @@ describe('HttpTransport', () => { error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, ok: false, }, + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, message: expect.stringContaining("Unknown skill 'missing'."), status: 404, }) }) + test('preserves structured discovery error details', async () => { + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string', + path: 'command', + received: 'number', + }, + ], + message: 'Invalid discovery request.', + retryable: false, + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ), + ) as typeof globalThis.fetch + const transport = resolve(fetch) + + await expect(transport.discover({ resource: 'help' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + error: { code: 'VALIDATION_ERROR', message: 'Invalid discovery request.' }, + fieldErrors: [expect.objectContaining({ path: 'command' })], + retryable: false, + status: 400, + }) + }) + test('streams records from the CLI HTTP route', async () => { const cli = Cli.create('app').command('stream', { async *run() { diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts index 3176a8a..7efb294 100644 --- a/src/client/transports/HttpTransport.ts +++ b/src/client/transports/HttpTransport.ts @@ -82,6 +82,7 @@ async function parseRpcResponse( if (contentType !== 'application/json') throw new ClientError('RPC response was not JSON.') const value = await parseJson(response) if (!isEnvelope(value)) throw new ClientError('Malformed RPC envelope.') + if (!value.ok) return { ...value, status: response.status } return value } @@ -164,6 +165,9 @@ async function parseDiscoverResponse(response: Response): Promise['error'] } { return ( typeof value === 'object' && value !== null && From dc1cd11124111d5c633d07a198d483967e737fef Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:13:43 +0200 Subject: [PATCH 24/55] refactor(client): share structured command collection --- src/Cli.ts | 2 +- src/Typegen.test.ts | 12 ++++++++++ src/Typegen.ts | 24 ++++--------------- src/client/transports/MemoryTransport.ts | 2 +- src/internal/client-discover.ts | 2 +- src/internal/client-local.ts | 2 +- src/internal/client-request.test.ts | 2 +- src/internal/client-request.ts | 2 +- ...ontext.test.ts => runtime-context.test.ts} | 10 ++++---- ...-runtime-context.ts => runtime-context.ts} | 4 ++-- 10 files changed, 29 insertions(+), 33 deletions(-) rename src/internal/{client-runtime-context.test.ts => runtime-context.test.ts} (87%) rename src/internal/{client-runtime-context.ts => runtime-context.ts} (97%) diff --git a/src/Cli.ts b/src/Cli.ts index c3281ea..7ad2a0f 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -14,7 +14,7 @@ import * as Formatter from './Formatter.js' import * as Help from './Help.js' import { createClientDiscover, DiscoverError } from './internal/client-discover.js' import { createClientRequest, getClientRequestStatus } from './internal/client-request.js' -import * as RuntimeContext from './internal/client-runtime-context.js' +import * as RuntimeContext from './internal/runtime-context.js' import { builtinCommands, type CommandMeta, diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..5c39ad6 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -202,4 +202,16 @@ describe('fromCli', () => { " `) }) + + test('includes root commands and excludes raw fetch gateways', () => { + const cli = Cli.create('status', { + run: () => ({ ok: true }), + }).command('raw', { + fetch: () => new Response('{}'), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain("'status': { args: {}; options: {} }") + expect(output).not.toContain("'raw'") + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..21f57da 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import { z } from 'zod' import * as Cli from './Cli.js' +import * as RuntimeContext from './internal/runtime-context.js' import { importCli } from './internal/utils.js' /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ @@ -12,36 +13,19 @@ export async function generate(input: string, output: string): Promise { /** Generates a `.d.ts` declaration string for the `incur` module augmentation. */ export function fromCli(cli: Cli.Cli): string { - const commands = Cli.toCommands.get(cli) - if (!commands) throw new Error('No commands registered on this CLI instance') - - const entries = collectEntries(commands, []) + const entries = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli)) const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] - for (const { name, args, options } of entries) + for (const { id, command } of entries) lines.push( - ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`, + ` '${id}': { args: ${schemaToType(command.args)}; options: ${schemaToType(command.options)} }`, ) lines.push(' }', ' }', '}', '') return lines.join('\n') } -/** Recursively collects leaf commands with their full paths and schemas. */ -function collectEntries( - commands: Map, - prefix: string[], -): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] { - const result: ReturnType = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) - else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) - } - return result.sort((a, b) => a.name.localeCompare(b.name)) -} - /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ function schemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index a922927..f3e79a1 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -2,7 +2,7 @@ import * as Cli from '../../Cli.js' import { createClientDiscover } from '../../internal/client-discover.js' import { createClientLocal } from '../../internal/client-local.js' import { createClientRequest } from '../../internal/client-request.js' -import * as RuntimeContext from '../../internal/client-runtime-context.js' +import * as RuntimeContext from '../../internal/runtime-context.js' import { ClientError } from '../ClientError.js' import type * as Discover from '../Discover.js' import type * as Local from '../Local.js' diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 75ad4e4..10086d6 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -10,7 +10,7 @@ import * as Help from '../Help.js' import * as Mcp from '../Mcp.js' import * as Openapi from '../Openapi.js' import * as Skill from '../Skill.js' -import * as RuntimeContext from './client-runtime-context.js' +import * as RuntimeContext from './runtime-context.js' type CommandDefinition = CliCommandDefinition diff --git a/src/internal/client-local.ts b/src/internal/client-local.ts index 155f60f..778a4d9 100644 --- a/src/internal/client-local.ts +++ b/src/internal/client-local.ts @@ -2,7 +2,7 @@ import type * as Local from '../client/Local.js' import { BaseError } from '../Errors.js' import * as SyncMcp from '../SyncMcp.js' import * as SyncSkills from '../SyncSkills.js' -import type * as RuntimeContext from './client-runtime-context.js' +import type * as RuntimeContext from './runtime-context.js' /** Local setup/admin failure. */ export class LocalError extends BaseError { diff --git a/src/internal/client-request.test.ts b/src/internal/client-request.test.ts index fb38699..ae44b3e 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/client-request.test.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import * as Cli from '../Cli.js' import * as Formatter from '../Formatter.js' import { createClientRequest } from './client-request.js' -import * as RuntimeContext from './client-runtime-context.js' +import * as RuntimeContext from './runtime-context.js' function createFixture() { const order: string[] = [] diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 7845035..13f6a93 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -5,7 +5,7 @@ import type * as ClientRequest from '../client/Request.js' import type { FieldError } from '../Errors.js' import * as Filter from '../Filter.js' import * as Formatter from '../Formatter.js' -import * as RuntimeContext from './client-runtime-context.js' +import * as RuntimeContext from './runtime-context.js' import * as Command from './command.js' const requestSchema = z.object({ diff --git a/src/internal/client-runtime-context.test.ts b/src/internal/runtime-context.test.ts similarity index 87% rename from src/internal/client-runtime-context.test.ts rename to src/internal/runtime-context.test.ts index ab0db2b..aedd2f6 100644 --- a/src/internal/client-runtime-context.test.ts +++ b/src/internal/runtime-context.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' import * as Cli from '../Cli.js' -import * as RuntimeContext from './client-runtime-context.js' +import * as RuntimeContext from './runtime-context.js' -describe('client-runtime-context', () => { - test('collects canonical client command IDs and excludes aliases/raw gateways', () => { +describe('runtime-context', () => { + test('collects canonical structured command IDs and excludes aliases/raw gateways', () => { const root = Cli.create('root', { run() { return null @@ -33,7 +33,7 @@ describe('client-runtime-context', () => { root.command(router) const ctx = RuntimeContext.fromCli(root) - expect(RuntimeContext.collectClientCommands(ctx).map((entry) => entry.id)).toEqual([ + expect(RuntimeContext.collectStructuredCommands(ctx).map((entry) => entry.id)).toEqual([ 'mounted', 'project nested leaf', 'root', @@ -76,7 +76,7 @@ describe('client-runtime-context', () => { }, }) - const command = RuntimeContext.collectClientCommands(RuntimeContext.fromCli(cli))[0]! + const command = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli))[0]! expect(command.id).toBe('api getUser') expect(command.command.args?.shape.id).toBeDefined() expect(command.command.output).toBeDefined() diff --git a/src/internal/client-runtime-context.ts b/src/internal/runtime-context.ts similarity index 97% rename from src/internal/client-runtime-context.ts rename to src/internal/runtime-context.ts index b868af8..c3efc30 100644 --- a/src/internal/client-runtime-context.ts +++ b/src/internal/runtime-context.ts @@ -157,8 +157,8 @@ export function resolveCanonical( return { id, command: entry, middlewares: [...middlewares, ...(entry.middleware ?? [])] } } -/** Traverses callable client command entries. Aliases and raw fetch gateways are excluded. */ -export function collectClientCommands(ctx: RuntimeCliContext): ResolvedCommand[] { +/** Traverses structured command entries. Aliases and raw fetch gateways are excluded. */ +export function collectStructuredCommands(ctx: RuntimeCliContext): ResolvedCommand[] { const result: ResolvedCommand[] = [] if (ctx.rootCommand) result.push({ id: ctx.name, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] }) From 2f1d7f9c25f33f3221fd55c083aac21b6a0e7ab5 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:14:51 +0200 Subject: [PATCH 25/55] fix(typegen): emit exact optional property types --- src/Typegen.test.ts | 41 +++++++++++++++++++++++++++++------------ src/Typegen.ts | 22 +++++++++++++++------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index 5c39ad6..d0406b7 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -16,8 +16,8 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'get': { args: { id: number }; options: {} } - 'list': { args: {}; options: { limit: number } } + get: { args: { id: number }; options: {} } + list: { args: {}; options: { limit: number } } } } } @@ -32,7 +32,7 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'ping': { args: {}; options: {} } + ping: { args: {}; options: {} } } } } @@ -57,8 +57,8 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'pr create': { args: { title: string }; options: {} } - 'pr list': { args: {}; options: { state: string } } + "pr create": { args: { title: string }; options: {} } + "pr list": { args: {}; options: { state: string } } } } } @@ -80,7 +80,7 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'pr review approve': { args: { id: number }; options: {} } + "pr review approve": { args: { id: number }; options: {} } } } } @@ -125,7 +125,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(/^ {6}(\w+):/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -169,7 +169,7 @@ describe('fromCli', () => { expect(output).toContain('config: { host: string; port: number }') }) - test('optional properties use optional modifier', () => { + test('optional properties include undefined for exact optional property types', () => { const cli = Cli.create('test').command('create', { args: z.object({ name: z.string() }), options: z.object({ @@ -180,7 +180,7 @@ describe('fromCli', () => { }) const output = Typegen.fromCli(cli) - expect(output).toContain('verbose?: boolean') + expect(output).toContain('verbose?: boolean | undefined') expect(output).toContain('output: string') }) @@ -194,8 +194,8 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'ping': { args: {}; options: {} } - 'pr list': { args: {}; options: {} } + ping: { args: {}; options: {} } + "pr list": { args: {}; options: {} } } } } @@ -211,7 +211,24 @@ describe('fromCli', () => { }) const output = Typegen.fromCli(cli) - expect(output).toContain("'status': { args: {}; options: {} }") + expect(output).toContain('status: { args: {}; options: {} }') expect(output).not.toContain("'raw'") }) + + test('escapes command and property keys', () => { + const cli = Cli.create('test').command('bad key "quoted"', { + options: z.object({ + 'bad-key': z.string().optional(), + 'quote"key': z.number(), + nested: z.object({ 'child-key': z.string().optional() }), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"bad key \\"quoted\\""') + expect(output).toContain('"bad-key"?: string | undefined') + expect(output).toContain('"quote\\"key": number') + expect(output).toContain('nested: { "child-key"?: string | undefined }') + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 21f57da..58da22d 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -19,7 +19,7 @@ export function fromCli(cli: Cli.Cli): string { for (const { id, command } of entries) lines.push( - ` '${id}': { args: ${schemaToType(command.args)}; options: ${schemaToType(command.options)} }`, + ` ${propertyKey(id)}: { args: ${schemaToType(command.args)}; options: ${schemaToType(command.options)} }`, ) lines.push(' }', ' }', '}', '') @@ -34,9 +34,11 @@ function schemaToType(schema: z.ZodObject | undefined): string { const properties = json.properties as Record> | undefined if (!properties || Object.keys(properties).length === 0) return '{}' const required = new Set((json.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) + const entries = Object.entries(properties).map(([key, value]) => { + const type = resolveType(value, defs) + if (required.has(key)) return `${propertyKey(key)}: ${type}` + return `${propertyKey(key)}?: ${type} | undefined` + }) return `{ ${entries.join('; ')} }` } @@ -82,12 +84,18 @@ function resolveType( const properties = schema.properties as Record> | undefined if (!properties || Object.keys(properties).length === 0) return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) + const entries = Object.entries(properties).map(([key, value]) => { + const type = resolveType(value, defs) + if (required.has(key)) return `${propertyKey(key)}: ${type}` + return `${propertyKey(key)}?: ${type} | undefined` + }) return `{ ${entries.join('; ')} }` } default: return 'unknown' } } + +function propertyKey(key: string) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +} From 4e8a8a838fcc01fccd10ab0e45adbab00d49f66a Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:16:05 +0200 Subject: [PATCH 26/55] fix(typegen): emit command output metadata --- src/Typegen.test.ts | 32 ++++++++++++++++++++++++++++++++ src/Typegen.ts | 23 ++++++++++++----------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index d0406b7..466cd59 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -118,6 +118,38 @@ describe('fromCli', () => { expect(output).toContain('tags: string[]') }) + test('emits scalar and array output schemas', () => { + const cli = Cli.create('test') + .command('read', { + output: z.string(), + run: () => 'content', + }) + .command('list', { + output: z.array(z.object({ id: z.string(), active: z.boolean() })), + run: () => [{ id: 'one', active: true }], + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('read: { args: {}; options: {}; output: string }') + expect(output).toContain( + 'list: { args: {}; options: {}; output: { id: string; active: boolean }[] }', + ) + }) + + test('marks async generator commands as streams', () => { + const cli = Cli.create('test').command('tail', { + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'ok' } + }, + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain( + 'tail: { args: {}; options: {}; output: { line: string }; stream: true }', + ) + }) + test('commands are sorted alphabetically', () => { const cli = Cli.create('test') .command('zebra', { run: () => ({}) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 58da22d..3d92af7 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -19,7 +19,7 @@ export function fromCli(cli: Cli.Cli): string { for (const { id, command } of entries) lines.push( - ` ${propertyKey(id)}: { args: ${schemaToType(command.args)}; options: ${schemaToType(command.options)} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) lines.push(' }', ' }', '}', '') @@ -27,19 +27,16 @@ export function fromCli(cli: Cli.Cli): string { } /** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function schemaToType(schema: z.ZodObject | undefined): string { +function objectSchemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' + return schemaToType(schema) +} + +/** Converts a Zod schema to a TypeScript type string. */ +function schemaToType(schema: z.ZodType): string { const json = z.toJSONSchema(schema) as Record const defs = (json.$defs ?? {}) as Record> - const properties = json.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' - const required = new Set((json.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map(([key, value]) => { - const type = resolveType(value, defs) - if (required.has(key)) return `${propertyKey(key)}: ${type}` - return `${propertyKey(key)}?: ${type} | undefined` - }) - return `{ ${entries.join('; ')} }` + return resolveType(json, defs) } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ @@ -99,3 +96,7 @@ function resolveType( function propertyKey(key: string) { return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) } + +function isStream(command: Cli.CommandDefinition) { + return command.run.constructor.name === 'AsyncGeneratorFunction' +} From 4eede40b28a4b7904034dde4805849e7c6b2784a Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 17:57:54 +0200 Subject: [PATCH 27/55] fix: reuse cli discovery projection --- src/Cli.ts | 51 ++++----- src/client/transports/MemoryTransport.test.ts | 73 ++++++++++++ src/internal/client-discover.ts | 108 ++++-------------- 3 files changed, 117 insertions(+), 115 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 7ad2a0f..923ef7d 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -14,7 +14,6 @@ import * as Formatter from './Formatter.js' import * as Help from './Help.js' import { createClientDiscover, DiscoverError } from './internal/client-discover.js' import { createClientRequest, getClientRequestStatus } from './internal/client-request.js' -import * as RuntimeContext from './internal/runtime-context.js' import { builtinCommands, type CommandMeta, @@ -26,6 +25,7 @@ import { import * as Command from './internal/command.js' import { isRecord, suggest, toKebab } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' +import * as RuntimeContext from './internal/runtime-context.js' import type { OneOf } from './internal/types.js' import * as Mcp from './Mcp.js' import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from './middleware.js' @@ -1106,14 +1106,8 @@ async function serveImpl( exit(1) return } - const cmd = resolved.command const format = formatExplicit ? formatFlag : Formatter.defaultFormat - const result: Record = {} - if (cmd.args) result.args = Schema.toJsonSchema(cmd.args) - if (cmd.env) result.env = Schema.toJsonSchema(cmd.env) - if (cmd.options) result.options = Schema.toJsonSchema(cmd.options) - if (cmd.output) result.output = Schema.toJsonSchema(cmd.output) - writeln(Formatter.format(result, format)) + writeln(Formatter.format(buildCommandSchema(resolved.command) ?? {}, format)) return } @@ -1825,8 +1819,7 @@ async function fetchImpl( if (segments[2] === 'index.json' && segments.length === 3) { const files = Skill.split(name, cmds, 1, groups) const skills = files.map((f) => { - const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/) - const meta = fmMatch ? (yamlParse(fmMatch[1]!) as Record) : {} + const meta = parseSkillFrontmatter(f.content) return { name: f.dir || name, description: meta.description ?? '', @@ -2604,7 +2597,7 @@ export type CommandsMap = Record< /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ export type CommandEntry = - | CommandDefinition + | CommandDefinition | InternalGroup | InternalFetchGateway | InternalAlias @@ -2688,7 +2681,7 @@ export function resolveAlias( export const toCommands = new WeakMap>() /** @internal Maps CLI instances to their middleware arrays. */ -const toMiddlewares = new WeakMap() +export const toMiddlewares = new WeakMap() /** @internal Maps root CLI instances to their command definitions. */ export const toRootDefinition = new WeakMap>() @@ -3017,7 +3010,7 @@ function formatCta(name: string, cta: Cta): FormattedCta { } /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */ -function buildIndexManifest(commands: Map, prefix: string[] = []) { +export function buildIndexManifest(commands: Map, prefix: string[] = []) { return { version: 'incur.v1', commands: collectIndexCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), @@ -3047,7 +3040,7 @@ function collectIndexCommands( } /** @internal Builds the `--llms` manifest from the command tree. */ -function buildManifest(commands: Map, prefix: string[] = []) { +export function buildManifest(commands: Map, prefix: string[] = []) { return { version: 'incur.v1', commands: collectCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), @@ -3078,14 +3071,13 @@ function collectCommands( const cmd: (typeof result)[number] = { name: path.join(' ') } if (entry.description) cmd.description = entry.description - const inputSchema = buildInputSchema(entry.args, entry.env, entry.options) - const outputSchema = entry.output ? Schema.toJsonSchema(entry.output) : undefined - if (inputSchema || outputSchema) { + const schema = buildCommandSchema(entry) + if (schema) { cmd.schema = {} - if (inputSchema?.args) cmd.schema.args = inputSchema.args - if (inputSchema?.env) cmd.schema.env = inputSchema.env - if (inputSchema?.options) cmd.schema.options = inputSchema.options - if (outputSchema) cmd.schema.output = outputSchema + if (schema.args) cmd.schema.args = schema.args + if (schema.env) cmd.schema.env = schema.env + if (schema.options) cmd.schema.options = schema.options + if (schema.output) cmd.schema.output = schema.output } const examples = formatExamples(entry.examples) @@ -3188,27 +3180,32 @@ export function parseSkillFrontmatter(content: string): { return meta as { description?: string | undefined; name?: string | undefined } } -/** @internal Builds separate args, env, and options JSON Schemas. */ -function buildInputSchema( - args: z.ZodObject | undefined, - env: z.ZodObject | undefined, - options: z.ZodObject | undefined, +/** @internal Builds separate command JSON Schemas. */ +export function buildCommandSchema( + command: Pick< + CommandDefinition, + 'args' | 'env' | 'options' | 'output' + >, ): | { args?: Record | undefined env?: Record | undefined options?: Record | undefined + output?: Record | undefined } | undefined { - if (!args && !env && !options) return undefined + const { args, env, options, output } = command + if (!args && !env && !options && !output) return undefined const result: { args?: Record | undefined env?: Record | undefined options?: Record | undefined + output?: Record | undefined } = {} if (args) result.args = Schema.toJsonSchema(args) if (env) result.env = Schema.toJsonSchema(env) if (options) result.options = Schema.toJsonSchema(options) + if (output) result.output = Schema.toJsonSchema(output) return result } diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index 4f17112..791fdbd 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -323,6 +323,79 @@ describe('MemoryTransport', () => { } }) + test('discovery reuses CLI manifest and skill projection behavior', async () => { + const cli = Cli.create('app', { description: 'App' }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string() }), + examples: [ + { + args: { id: '123' }, + options: { verbose: true }, + description: 'Verbose status', + }, + ], + run(c) { + return { id: c.args.id } + }, + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { name: 'status', description: 'Show status' }, + ], + }, + }) + + const full = await transport.discover({ resource: 'llmsFull', format: 'json' }) + expect(full).toMatchObject({ + contentType: 'application/json', + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { + name: 'status', + description: 'Show status', + examples: [ + { + command: 'status 123 --verbose true', + description: 'Verbose status', + }, + ], + schema: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + ], + }, + }) + + const schema = await transport.discover({ resource: 'schema', command: 'status' }) + expect(schema).toMatchObject({ + data: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }) + + const markdown = await transport.discover({ resource: 'llmsFull' }) + if (!('body' in markdown)) throw new Error('expected markdown body') + expect(markdown.body).toContain('Verbose status') + expect(markdown.body).toContain('## Output') + expect(markdown.body).toContain('Fetch gateway. Pass path segments') + expect(markdown.body).not.toMatch(/^# app st$/m) + }) + test('wraps discovery failures as client errors with internal cause', async () => { const cli = Cli.create('app').command('status', { run() { diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index 10086d6..ef08b11 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -1,8 +1,7 @@ -import { parse as yamlParse, stringify as yamlStringify } from 'yaml' +import { stringify as yamlStringify } from 'yaml' import { z } from 'zod' import * as Cli from '../Cli.js' -import type { CommandDefinition as CliCommandDefinition, CommandEntry } from '../Cli.js' import type * as ClientDiscover from '../client/Discover.js' import { BaseError } from '../Errors.js' import * as Formatter from '../Formatter.js' @@ -12,8 +11,6 @@ import * as Openapi from '../Openapi.js' import * as Skill from '../Skill.js' import * as RuntimeContext from './runtime-context.js' -type CommandDefinition = CliCommandDefinition - /** Discover failure with protocol code and HTTP status metadata. */ export class DiscoverError extends BaseError { override name = 'Incur.DiscoverError' @@ -75,7 +72,7 @@ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { contentType: 'application/json', data: { skills: files.map((file) => { - const meta = parseFrontmatter(file.content) + const meta = Cli.parseSkillFrontmatter(file.content) return { name: file.dir || ctx.name, description: meta.description ?? '', @@ -113,32 +110,41 @@ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { contentType: 'text/plain', body: Help.formatRoot(scoped.id, { description: scoped.description, - commands: collect(scoped.commands, [], false).map(({ name, description }) => ({ - name, - ...(description ? { description } : undefined), - })), + commands: Cli.buildIndexManifest(scoped.commands, []).commands.map( + ({ name, description }) => ({ + name, + ...(description ? { description } : undefined), + }), + ), }), } } if (parsed.resource === 'schema') { if (scoped.type === 'command') { - const schema = RuntimeContext.buildInputSchema(scoped.command) + const schema = Cli.buildCommandSchema(scoped.command) return { contentType: 'application/json', data: schema ?? {} } } return { contentType: 'application/json', - data: manifest(scoped.commands, scoped.prefix, true), + data: Cli.buildManifest(scoped.commands, scoped.prefix), } } const full = parsed.resource === 'llmsFull' const format = parsed.format ?? 'md' - const data = manifest(scoped.commands, scoped.prefix, full) + const data = full + ? Cli.buildManifest(scoped.commands, scoped.prefix) + : Cli.buildIndexManifest(scoped.commands, scoped.prefix) if (format === 'json') return { contentType: 'application/json', data } if (format === 'md') { const groups = new Map() - const entries = skillCommands(scoped.commands, scoped.prefix, groups, scoped.rootCommand) + const entries = Cli.collectSkillCommands( + scoped.commands, + scoped.prefix, + groups, + scoped.rootCommand, + ) const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name const body = full ? Skill.generate(name, entries, groups) @@ -199,84 +205,10 @@ function openapi(ctx: RuntimeContext.RuntimeCliContext) { function skills(ctx: RuntimeContext.RuntimeCliContext) { const groups = new Map() - const entries = skillCommands(ctx.commands, [], groups, ctx.rootCommand) + const entries = Cli.collectSkillCommands(ctx.commands, [], groups, ctx.rootCommand) return { files: Skill.split(ctx.name, entries, 1, groups) } } -function manifest(commands: Map, prefix: string[], full: boolean) { - return { - version: 'incur.v1', - commands: collect(commands, prefix, full).sort((a, b) => a.name.localeCompare(b.name)), - } -} - -function collect(commands: Map, prefix: string[], full: boolean) { - const result: { - name: string - description?: string | undefined - schema?: Record | undefined - }[] = [] - for (const [name, entry] of commands) { - if (RuntimeContext.isAlias(entry) || RuntimeContext.isFetchGateway(entry)) continue - const path = [...prefix, name] - if (RuntimeContext.isGroup(entry)) result.push(...collect(entry.commands, path, full)) - else { - const command: (typeof result)[number] = { name: path.join(' ') } - if (entry.description) command.description = entry.description - if (full) { - const input = RuntimeContext.buildInputSchema(entry) - if (input || entry.output) { - command.schema = {} - if (input?.args) command.schema.args = input.args - if (input?.env) command.schema.env = input.env - if (input?.options) command.schema.options = input.options - if (entry.output) command.schema.output = z.toJSONSchema(entry.output) - } - } - result.push(command) - } - } - return result -} - -function skillCommands( - commands: Map, - prefix: string[], - groups: Map, - rootCommand?: CommandDefinition | undefined, -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - if (rootCommand) result.push(toSkillCommand(rootCommand, undefined)) - for (const [name, entry] of commands) { - if (RuntimeContext.isAlias(entry) || RuntimeContext.isFetchGateway(entry)) continue - const path = [...prefix, name] - if (RuntimeContext.isGroup(entry)) { - if (entry.description) groups.set(path.join(' '), entry.description) - result.push(...skillCommands(entry.commands, path, groups)) - continue - } - result.push(toSkillCommand(entry, path.join(' '))) - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} - -function toSkillCommand(command: CommandDefinition, name: string | undefined) { - return { - ...(name ? { name } : undefined), - ...(command.description ? { description: command.description } : undefined), - ...(command.args ? { args: command.args } : undefined), - ...(command.env ? { env: command.env } : undefined), - ...(command.hint ? { hint: command.hint } : undefined), - ...(command.options ? { options: command.options } : undefined), - ...(command.output ? { output: command.output } : undefined), - } satisfies Skill.CommandInfo -} - -function parseFrontmatter(content: string) { - const match = content.match(/^---\n([\s\S]*?)\n---/) - return match ? (yamlParse(match[1]!) as Record) : {} -} - function safeSkillName(name: string) { return name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '..' } From 0be074c055797e5d823d608016dbcb4c61a04d6e Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 19:46:20 +0200 Subject: [PATCH 28/55] fix(client): preserve runtime cli metadata --- src/Cli.ts | 74 +++++++++++++++++++++++----------- src/Openapi.ts | 35 ++++++++++------ src/e2e.test.ts | 47 +++++++++++---------- src/internal/client-request.ts | 2 +- 4 files changed, 97 insertions(+), 61 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 923ef7d..71162ca 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -220,15 +220,22 @@ export function create( const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0') if (def.openapi && rootFetch) { - pending.push( - (async () => { - const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl }) - const generated = await Openapi.generateCommands(spec, rootFetch, { - config: def.openapiConfig, - }) - for (const [name, command] of generated) commands.set(name, command) - })(), - ) + if (isResolvedOpenapi(def.openapi)) { + const generated = Openapi.generateCommandsSync(def.openapi, rootFetch, { + config: def.openapiConfig, + }) + for (const [name, command] of generated) commands.set(name, command) + } else { + pending.push( + (async () => { + const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl }) + const generated = await Openapi.generateCommands(spec, rootFetch, { + config: def.openapiConfig, + }) + for (const [name, command] of generated) commands.set(name, command) + })(), + ) + } } const cli: Cli = { @@ -243,23 +250,35 @@ export function create( const fetch = resolveFetch(def.fetch) // OpenAPI + fetch → generate typed command group (async, resolved before serve) if (def.openapi) { - pending.push( - (async () => { - const spec = await Openapi.resolve(def.openapi, { - baseUrl: fetchBaseUrl(def.fetch), - }) - const generated = await Openapi.generateCommands(spec, fetch, { + const setOpenapiGroup = (generated: Map) => { + commands.set(nameOrCli, { + _group: true, + description: def.description, + commands: generated as Map, + ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), + } as InternalGroup) + } + if (isResolvedOpenapi(def.openapi)) { + setOpenapiGroup( + Openapi.generateCommandsSync(def.openapi, fetch, { basePath: def.basePath, config: def.openapiConfig, - }) - commands.set(nameOrCli, { - _group: true, - description: def.description, - commands: generated as Map, - ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), - } as InternalGroup) - })(), - ) + }), + ) + } else + pending.push( + (async () => { + const spec = await Openapi.resolve(def.openapi, { + baseUrl: fetchBaseUrl(def.fetch), + }) + setOpenapiGroup( + await Openapi.generateCommands(spec, fetch, { + basePath: def.basePath, + config: def.openapiConfig, + }), + ) + })(), + ) return cli } commands.set(nameOrCli, { @@ -342,7 +361,10 @@ export function create( if (rootDef && def.aliases) toRootAliases.set(cli as unknown as Root, def.aliases) if (def.options) toRootOptions.set(cli, def.options) if (def.config !== undefined) toConfigEnabled.set(cli, true) + if (def.mcp) toMcpOptions.set(cli, def.mcp) if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy) + if (def.sync) toSyncOptions.set(cli, def.sync) + if (def.version !== undefined) toVersion.set(cli, def.version) toMiddlewares.set(cli, middlewares) toCommands.set(cli, commands) return cli @@ -2646,6 +2668,10 @@ function fetchBaseUrl(source: FetchSource) { return typeof source === 'function' ? undefined : source.url } +function isResolvedOpenapi(source: Openapi.OpenAPISource): source is Openapi.OpenAPISpec { + return typeof source !== 'string' && !(source instanceof URL) +} + /** @internal Type guard for command groups. */ export function isGroup(entry: CommandEntry): entry is InternalGroup { return '_group' in entry diff --git a/src/Openapi.ts b/src/Openapi.ts index 78e9dac..44c593f 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -27,19 +27,21 @@ export type Config = { } /** Inferred command map for operation commands generated from a literal OpenAPI spec. */ -export type Commands = - spec extends OpenAPISpec - ? { - [path in keyof NonNullable & string as OperationCommandName< - name, - NonNullable[path] - >]: { - args: Record - options: Record - output: unknown - } +export type Commands< + name extends string, + spec extends OpenAPISource | undefined, +> = spec extends OpenAPISpec + ? { + [path in keyof NonNullable & string as OperationCommandName< + name, + NonNullable[path] + >]: { + args: Record + options: Record + output: unknown } - : {} + } + : {} type OperationCommandName = item extends object ? { @@ -374,6 +376,15 @@ export async function generateCommands( fetch: FetchHandler, options: generateCommands.Options = {}, ): Promise> { + return generateCommandsSync(spec, fetch, options) +} + +/** Synchronously generates incur command entries from an already-loaded OpenAPI spec. */ +export function generateCommandsSync( + spec: OpenAPISpec, + fetch: FetchHandler, + options: generateCommands.Options = {}, +): Map { const resolved = dereference(structuredClone(spec)) as OpenAPISpec const commands = new Map() const paths = (resolved.paths ?? {}) as Record> diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 4cd126c..0743564 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1604,30 +1604,29 @@ describe('typegen', () => { "declare module 'incur' { interface Register { commands: { - 'api': { args: {}; options: {} } - 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } - 'auth logout': { args: {}; options: {} } - 'auth status': { args: {}; options: {} } - 'config': { args: { key?: string }; options: {} } - 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string } } - 'explode': { args: {}; options: {} } - 'explode-clac': { args: {}; options: {} } - 'noop': { args: {}; options: {} } - 'ping': { args: {}; options: {} } - 'project create': { args: { name: string }; options: { description: string; private: boolean } } - 'project delete': { args: { id: string }; options: { force: boolean } } - 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } } - 'project deploy rollback': { args: { deployId: string }; options: {} } - 'project deploy status': { args: { deployId: string }; options: {} } - 'project get': { args: { id: string }; options: {} } - 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } } - 'slow': { args: {}; options: {} } - 'stream': { args: {}; options: {} } - 'stream-error': { args: {}; options: {} } - 'stream-ok': { args: {}; options: {} } - 'stream-text': { args: {}; options: {} } - 'stream-throw': { args: {}; options: {} } - 'validate-fail': { args: { email: string; age: number }; options: {} } + "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: {} } } } } diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 13f6a93..131cdea 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -5,8 +5,8 @@ import type * as ClientRequest from '../client/Request.js' import type { FieldError } from '../Errors.js' import * as Filter from '../Filter.js' import * as Formatter from '../Formatter.js' -import * as RuntimeContext from './runtime-context.js' import * as Command from './command.js' +import * as RuntimeContext from './runtime-context.js' const requestSchema = z.object({ command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), From a1acb15991e73374ef78b1b4cbf42d36a8b062f9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 21:30:45 +0200 Subject: [PATCH 29/55] refactor(client): rename request and discover type namespaces --- src/client/ClientError.ts | 18 +++++------ src/client/{Discover.ts => Resources.ts} | 4 +-- src/client/{Request.ts => Rpc.ts} | 12 ++++---- src/client/index.ts | 4 +-- src/client/transports/HttpTransport.test.ts | 4 +-- src/client/transports/HttpTransport.ts | 30 ++++++++----------- src/client/transports/MemoryTransport.test.ts | 4 +-- src/client/transports/MemoryTransport.ts | 10 +++---- src/internal/client-discover.ts | 4 +-- src/internal/client-request.ts | 30 +++++++++---------- 10 files changed, 56 insertions(+), 64 deletions(-) rename src/client/{Discover.ts => Resources.ts} (84%) rename src/client/{Request.ts => Rpc.ts} (90%) diff --git a/src/client/ClientError.ts b/src/client/ClientError.ts index 4b1d210..58addcb 100644 --- a/src/client/ClientError.ts +++ b/src/client/ClientError.ts @@ -1,5 +1,5 @@ import { BaseError } from '../Errors.js' -import type * as Request from './Request.js' +import type * as Rpc from './Rpc.js' /** Error thrown by client transports. */ export class ClientError extends BaseError { @@ -8,12 +8,12 @@ export class ClientError extends BaseError { code: string | undefined /** Full error envelope or diagnostic payload. */ data: unknown | undefined - /** Request error object. */ - error: Extract['error'] | undefined + /** RPC error object. */ + error: Extract['error'] | undefined /** Field validation errors. */ - fieldErrors: Extract['error']['fieldErrors'] | undefined + fieldErrors: Extract['error']['fieldErrors'] | undefined /** Response metadata. */ - meta: Request.Meta | undefined + meta: Rpc.Meta | undefined /** Whether the operation can be retried. */ retryable: boolean | undefined /** HTTP status when available. */ @@ -38,12 +38,12 @@ export declare namespace ClientError { code?: string | undefined /** Full error envelope or diagnostic payload. */ data?: unknown | undefined - /** Request error object. */ - error?: Extract['error'] | undefined + /** RPC error object. */ + error?: Extract['error'] | undefined /** Field validation errors. */ - fieldErrors?: Extract['error']['fieldErrors'] | undefined + fieldErrors?: Extract['error']['fieldErrors'] | undefined /** Response metadata. */ - meta?: Request.Meta | undefined + meta?: Rpc.Meta | undefined /** Whether the operation can be retried. */ retryable?: boolean | undefined /** HTTP status when available. */ diff --git a/src/client/Discover.ts b/src/client/Resources.ts similarity index 84% rename from src/client/Discover.ts rename to src/client/Resources.ts index 39a1c87..62fc641 100644 --- a/src/client/Discover.ts +++ b/src/client/Resources.ts @@ -1,6 +1,6 @@ import type * as Formatter from '../Formatter.js' -/** Request accepted by `transport.discover()`. */ +/** Resource request accepted by `transport.discover()`. */ export type Request = | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } @@ -11,7 +11,7 @@ export type Request = | { resource: 'skill'; name: string } | { resource: 'mcpTools' } -/** Response returned by `transport.discover()`. */ +/** Resource response returned by `transport.discover()`. */ export type Response = | { contentType: string; body: string } | { contentType: string; data: unknown } diff --git a/src/client/Request.ts b/src/client/Rpc.ts similarity index 90% rename from src/client/Request.ts rename to src/client/Rpc.ts index a5c99cd..5b2477d 100644 --- a/src/client/Request.ts +++ b/src/client/Rpc.ts @@ -1,7 +1,7 @@ import type { FieldError } from '../Errors.js' import type * as Formatter from '../Formatter.js' -/** Request accepted by `transport.request()`. */ +/** RPC request accepted by `transport.request()`. */ export type Request = { /** Canonical command ID. */ command: string @@ -39,7 +39,7 @@ export type Output = { truncated?: boolean | undefined } -/** Request metadata. */ +/** RPC response metadata. */ export type Meta = { /** Canonical command ID. */ command: string @@ -49,7 +49,7 @@ export type Meta = { duration: string } -/** Full request success/error envelope. */ +/** Full RPC success/error envelope. */ export type Envelope = | { ok: true @@ -70,16 +70,16 @@ export type Envelope = status?: number | undefined } -/** Non-streaming request response. */ +/** Non-streaming RPC response. */ export type Response = Envelope -/** Streaming request record. */ +/** Streaming RPC record. */ export type StreamRecord = | { type: 'chunk'; data: unknown } | ({ type: 'done' } & Extract) | ({ type: 'error' } & Extract) -/** Streaming request response. */ +/** Streaming RPC response. */ export type StreamResponse = { stream: true records(): AsyncGenerator diff --git a/src/client/index.ts b/src/client/index.ts index 804cfdc..577800e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,7 +1,7 @@ export { ClientError } from './ClientError.js' -export * as Discover from './Discover.js' +export * as Resources from './Resources.js' export * as HttpTransport from './transports/HttpTransport.js' export * as Local from './Local.js' export * as MemoryTransport from './transports/MemoryTransport.js' -export * as Request from './Request.js' +export * as Rpc from './Rpc.js' export * as Transport from './transports/Transport.js' diff --git a/src/client/transports/HttpTransport.test.ts b/src/client/transports/HttpTransport.test.ts index dfab133..cbe9a56 100644 --- a/src/client/transports/HttpTransport.test.ts +++ b/src/client/transports/HttpTransport.test.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import * as Cli from '../../Cli.js' import { ClientError } from '../ClientError.js' -import type * as Discover from '../Discover.js' +import type * as Resources from '../Resources.js' import * as HttpTransport from './HttpTransport.js' function resolve(fetch: typeof globalThis.fetch) { @@ -298,7 +298,7 @@ describe('HttpTransport', () => { const { requests, transport } = connect(cli) const cases: { - request: Discover.Request + request: Resources.Request url: string assert(response: Awaited>): void }[] = [ diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts index 7efb294..c136c30 100644 --- a/src/client/transports/HttpTransport.ts +++ b/src/client/transports/HttpTransport.ts @@ -1,6 +1,6 @@ import { ClientError } from '../ClientError.js' -import type * as Discover from '../Discover.js' -import type * as ClientRequest from '../Request.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' import type * as Transport from './Transport.js' /** HTTP transport factory. */ @@ -8,10 +8,8 @@ export type HttpTransport = Transport.Factory< 'http', { baseUrl: URL - request( - request: ClientRequest.Request, - ): Promise - discover(request: Discover.Request): Promise + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise } > @@ -71,9 +69,7 @@ async function requestFetch(fetcher: typeof globalThis.fetch, input: URL, init: } } -async function parseRpcResponse( - response: Response, -): Promise { +async function parseRpcResponse(response: Response): Promise { const contentType = essence(response.headers.get('content-type') ?? '') if (contentType === 'application/x-ndjson') { if (!response.body) throw new ClientError('Streaming RPC response is missing a body.') @@ -86,14 +82,14 @@ async function parseRpcResponse( return value } -function streamResponse(body: ReadableStream): ClientRequest.StreamResponse { +function streamResponse(body: ReadableStream): Rpc.StreamResponse { return { stream: true, async *records() { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' - let terminal: ClientRequest.StreamRecord | undefined + let terminal: Rpc.StreamRecord | undefined try { while (true) { const { value, done } = await reader.read() @@ -132,7 +128,7 @@ function* drainRecords(buffer: string): Generator<{ line: string; rest: string } } } -function parseRecord(line: string): ClientRequest.StreamRecord { +function parseRecord(line: string): Rpc.StreamRecord { let value: unknown try { value = JSON.parse(line) @@ -155,7 +151,7 @@ async function parseJson(response: Response) { } } -async function parseDiscoverResponse(response: Response): Promise { +async function parseDiscoverResponse(response: Response): Promise { const contentType = response.headers.get('content-type') ?? '' if (!response.ok) { const data = contentType.includes('application/json') @@ -176,7 +172,7 @@ async function parseDiscoverResponse(response: Response): Promise { if (request.resource === 'llms') return '_incur/llms' if (request.resource === 'llmsFull') return '_incur/llms-full' @@ -214,7 +210,7 @@ function essence(value: string) { return value.split(';', 1)[0]!.trim().toLowerCase() } -function isEnvelope(value: unknown): value is ClientRequest.Response { +function isEnvelope(value: unknown): value is Rpc.Response { return ( typeof value === 'object' && value !== null && @@ -223,7 +219,7 @@ function isEnvelope(value: unknown): value is ClientRequest.Response { ) } -function isRecord(value: unknown): value is ClientRequest.StreamRecord { +function isRecord(value: unknown): value is Rpc.StreamRecord { return ( typeof value === 'object' && value !== null && @@ -235,7 +231,7 @@ function isRecord(value: unknown): value is ClientRequest.StreamRecord { function isErrorPayload( value: unknown, -): value is { error: Extract['error'] } { +): value is { error: Extract['error'] } { return ( typeof value === 'object' && value !== null && diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index 791fdbd..bcd5717 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import * as Cli from '../../Cli.js' import { DiscoverError } from '../../internal/client-discover.js' import { ClientError } from '../ClientError.js' -import type * as Discover from '../Discover.js' +import type * as Resources from '../Resources.js' import * as MemoryTransport from './MemoryTransport.js' describe('MemoryTransport', () => { @@ -96,7 +96,7 @@ describe('MemoryTransport', () => { }) const transport = MemoryTransport.create(cli)() const cases: { - request: Discover.Request + request: Resources.Request assert(response: Awaited>): void }[] = [ { diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index f3e79a1..22a0bd3 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -4,19 +4,17 @@ import { createClientLocal } from '../../internal/client-local.js' import { createClientRequest } from '../../internal/client-request.js' import * as RuntimeContext from '../../internal/runtime-context.js' import { ClientError } from '../ClientError.js' -import type * as Discover from '../Discover.js' import type * as Local from '../Local.js' -import type * as ClientRequest from '../Request.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' import type * as Transport from './Transport.js' /** Memory transport factory. */ export type MemoryTransport = Transport.Factory< 'memory', { - request( - request: ClientRequest.Request, - ): Promise - discover(request: Discover.Request): Promise + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise local: Local.Runtime } > diff --git a/src/internal/client-discover.ts b/src/internal/client-discover.ts index ef08b11..85e4ac1 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/client-discover.ts @@ -2,7 +2,7 @@ import { stringify as yamlStringify } from 'yaml' import { z } from 'zod' import * as Cli from '../Cli.js' -import type * as ClientDiscover from '../client/Discover.js' +import type * as Resources from '../client/Resources.js' import { BaseError } from '../Errors.js' import * as Formatter from '../Formatter.js' import * as Help from '../Help.js' @@ -48,7 +48,7 @@ const requestSchema = z.discriminatedUnion('resource', [ /** Creates the shared client discovery executor. */ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { return { - async discover(request: unknown): Promise { + async discover(request: unknown): Promise { const parsedRequest = requestSchema.safeParse(request) if (!parsedRequest.success) throw new DiscoverError('VALIDATION_ERROR', 'Invalid discovery request.', 400) diff --git a/src/internal/client-request.ts b/src/internal/client-request.ts index 131cdea..983d0a3 100644 --- a/src/internal/client-request.ts +++ b/src/internal/client-request.ts @@ -1,7 +1,7 @@ import { estimateTokenCount, sliceByTokens } from 'tokenx' import { z } from 'zod' -import type * as ClientRequest from '../client/Request.js' +import type * as Rpc from '../client/Rpc.js' import type { FieldError } from '../Errors.js' import * as Filter from '../Filter.js' import * as Formatter from '../Formatter.js' @@ -34,9 +34,7 @@ export function createClientRequest( options: createClientRequest.Options = {}, ) { return { - async request( - request: unknown, - ): Promise { + async request(request: unknown): Promise { const start = performance.now() const parsed = requestSchema.safeParse(request) if (!parsed.success) @@ -115,12 +113,12 @@ function streamResponse( stream: AsyncGenerator, command: string, start: number, - request: ClientRequest.Request, -): ClientRequest.StreamResponse { + request: Rpc.Request, +): Rpc.StreamResponse { return { stream: true, async *records() { - let terminal: ClientRequest.StreamRecord + let terminal: Rpc.StreamRecord try { while (true) { const { value, done } = await stream.next() @@ -174,8 +172,8 @@ function successEnvelope( start: number, data: unknown, cta?: unknown | undefined, - request: ClientRequest.Request = { command }, -): Extract { + request: Rpc.Request = { command }, +): Extract { const selected = applySelection(data, request.selection) const output = renderOutput(selected, request) const payload = outputPayload(output, request) @@ -197,7 +195,7 @@ function errorEnvelope( retryable?: boolean | undefined }, cta?: unknown | undefined, -): Extract { +): Extract { return { ok: false, error, @@ -215,7 +213,7 @@ function errorRecord( retryable?: boolean | undefined }, cta: unknown | undefined, -): Extract { +): Extract { return { type: 'error', ...errorEnvelope(command, start, error, cta) } } @@ -227,7 +225,7 @@ function applySelection(data: unknown, selection: string[] | undefined) { ) } -function renderOutput(data: unknown, request: ClientRequest.Request) { +function renderOutput(data: unknown, request: Rpc.Request) { const format = request.outputFormat ?? Formatter.defaultFormat const text = Formatter.format(data, format) const count = estimateTokenCount(text) @@ -248,8 +246,8 @@ function renderOutput(data: unknown, request: ClientRequest.Request) { function outputPayload( output: ReturnType, - request: ClientRequest.Request, -): ClientRequest.Output | undefined { + request: Rpc.Request, +): Rpc.Output | undefined { if (!output.text && !includeTokenMetadata(request)) return undefined return { text: output.text, @@ -266,7 +264,7 @@ function outputPayload( } } -function includeTokenMetadata(request: ClientRequest.Request) { +function includeTokenMetadata(request: Rpc.Request) { return ( request.outputTokenCount || request.outputTokenLimit !== undefined || @@ -274,7 +272,7 @@ function includeTokenMetadata(request: ClientRequest.Request) { ) } -function meta(command: string, start: number, cta: unknown | undefined): ClientRequest.Meta { +function meta(command: string, start: number, cta: unknown | undefined): Rpc.Meta { return { command, duration: `${Math.round(performance.now() - start)}ms`, From f00ba5238abe175ff686e84861991b6280931843 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 21:52:40 +0200 Subject: [PATCH 30/55] refactor(client): align internal handler names --- src/Cli.ts | 14 +++---- src/client/Local.ts | 4 +- src/client/transports/MemoryTransport.test.ts | 4 +- src/client/transports/MemoryTransport.ts | 14 +++---- .../{client-local.ts => handlers/local.ts} | 14 +++---- .../resources.ts} | 38 +++++++++---------- .../rpc.test.ts} | 38 +++++++++---------- .../{client-request.ts => handlers/rpc.ts} | 24 ++++++------ 8 files changed, 75 insertions(+), 75 deletions(-) rename src/internal/{client-local.ts => handlers/local.ts} (84%) rename src/internal/{client-discover.ts => handlers/resources.ts} (85%) rename src/internal/{client-request.test.ts => handlers/rpc.test.ts} (88%) rename src/internal/{client-request.ts => handlers/rpc.ts} (94%) diff --git a/src/Cli.ts b/src/Cli.ts index 71162ca..3318c5d 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -12,8 +12,6 @@ import * as Fetch from './Fetch.js' import * as Filter from './Filter.js' import * as Formatter from './Formatter.js' import * as Help from './Help.js' -import { createClientDiscover, DiscoverError } from './internal/client-discover.js' -import { createClientRequest, getClientRequestStatus } from './internal/client-request.js' import { builtinCommands, type CommandMeta, @@ -23,6 +21,8 @@ import { shells, } from './internal/command.js' import * as Command from './internal/command.js' +import { createResourcesHandler, ResourcesError } from './internal/handlers/resources.js' +import { createRpcHandler, getRpcStatus } from './internal/handlers/rpc.js' import { isRecord, suggest, toKebab } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' import * as RuntimeContext from './internal/runtime-context.js' @@ -1713,7 +1713,7 @@ async function fetchImpl( } if (segments[1] === 'rpc' && segments.length === 2 && req.method === 'POST') { - const client = createClientRequest(ctx) + const client = createRpcHandler(ctx) let body: unknown try { body = await req.json() @@ -1747,7 +1747,7 @@ async function fetchImpl( }) } return new Response(JSON.stringify(response), { - status: response.ok ? 200 : getClientRequestStatus(response.error.code), + status: response.ok ? 200 : getRpcStatus(response.error.code), headers: { 'content-type': 'application/json' }, }) } @@ -1766,7 +1766,7 @@ async function fetchImpl( })() if (resource) { try { - const client = createClientDiscover(ctx) + const client = createResourcesHandler(ctx) const discovery = await client.discover({ resource, ...(url.searchParams.get('command') @@ -1785,8 +1785,8 @@ async function fetchImpl( }, ) } catch (error) { - const status = error instanceof DiscoverError ? error.status : 500 - const code = error instanceof DiscoverError ? error.code : 'DISCOVERY_ERROR' + const status = error instanceof ResourcesError ? error.status : 500 + const code = error instanceof ResourcesError ? error.code : 'DISCOVERY_ERROR' return new Response( JSON.stringify({ ok: false, diff --git a/src/client/Local.ts b/src/client/Local.ts index 1399de3..de8405e 100644 --- a/src/client/Local.ts +++ b/src/client/Local.ts @@ -37,8 +37,8 @@ export type SkillsList = { /** MCP registration result. */ export type McpRegistration = SyncMcp.register.Result -/** Memory-only local runtime exposed by the memory transport. */ -export type Runtime = { +/** Memory-only local operations exposed by the memory transport. */ +export type Handler = { /** Skill setup actions. */ skills: { /** Sync generated skill files. */ diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts index bcd5717..b0e3300 100644 --- a/src/client/transports/MemoryTransport.test.ts +++ b/src/client/transports/MemoryTransport.test.ts @@ -3,7 +3,7 @@ import { parse as yamlParse } from 'yaml' import { z } from 'zod' import * as Cli from '../../Cli.js' -import { DiscoverError } from '../../internal/client-discover.js' +import { ResourcesError } from '../../internal/handlers/resources.js' import { ClientError } from '../ClientError.js' import type * as Resources from '../Resources.js' import * as MemoryTransport from './MemoryTransport.js' @@ -405,7 +405,7 @@ describe('MemoryTransport', () => { const transport = MemoryTransport.create(cli)() await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ - cause: expect.any(DiscoverError), + cause: expect.any(ResourcesError), code: 'SKILL_NOT_FOUND', message: expect.stringContaining('Discover request failed.'), status: 404, diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index 22a0bd3..1fae7a0 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -1,7 +1,7 @@ import * as Cli from '../../Cli.js' -import { createClientDiscover } from '../../internal/client-discover.js' -import { createClientLocal } from '../../internal/client-local.js' -import { createClientRequest } from '../../internal/client-request.js' +import { createLocalHandler } from '../../internal/handlers/local.js' +import { createResourcesHandler } from '../../internal/handlers/resources.js' +import { createRpcHandler } from '../../internal/handlers/rpc.js' import * as RuntimeContext from '../../internal/runtime-context.js' import { ClientError } from '../ClientError.js' import type * as Local from '../Local.js' @@ -15,7 +15,7 @@ export type MemoryTransport = Transport.Factory< { request(request: Rpc.Request): Promise discover(request: Resources.Request): Promise - local: Local.Runtime + local: Local.Handler } > @@ -29,9 +29,9 @@ export type Options = { export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { return () => { const ctx = RuntimeContext.fromCli(cli) - const { request } = createClientRequest(ctx, { env: options.env }) - const { discover } = createClientDiscover(ctx) - const { local } = createClientLocal(ctx) + const { request } = createRpcHandler(ctx, { env: options.env }) + const { discover } = createResourcesHandler(ctx) + const { local } = createLocalHandler(ctx) return { config: { key: 'memory', name: 'Memory', type: 'memory' }, request, diff --git a/src/internal/client-local.ts b/src/internal/handlers/local.ts similarity index 84% rename from src/internal/client-local.ts rename to src/internal/handlers/local.ts index 778a4d9..d9c769f 100644 --- a/src/internal/client-local.ts +++ b/src/internal/handlers/local.ts @@ -1,16 +1,16 @@ -import type * as Local from '../client/Local.js' -import { BaseError } from '../Errors.js' -import * as SyncMcp from '../SyncMcp.js' -import * as SyncSkills from '../SyncSkills.js' -import type * as RuntimeContext from './runtime-context.js' +import type * as Local from '../../client/Local.js' +import { BaseError } from '../../Errors.js' +import * as SyncMcp from '../../SyncMcp.js' +import * as SyncSkills from '../../SyncSkills.js' +import type * as RuntimeContext from '../runtime-context.js' /** Local setup/admin failure. */ export class LocalError extends BaseError { override name = 'Incur.LocalError' } -/** Creates local setup/admin wrappers for a memory transport. */ -export function createClientLocal(ctx: RuntimeContext.RuntimeCliContext) { +/** Creates the shared in-process local handler. */ +export function createLocalHandler(ctx: RuntimeContext.RuntimeCliContext) { return { local: { skills: { diff --git a/src/internal/client-discover.ts b/src/internal/handlers/resources.ts similarity index 85% rename from src/internal/client-discover.ts rename to src/internal/handlers/resources.ts index 85e4ac1..4885acb 100644 --- a/src/internal/client-discover.ts +++ b/src/internal/handlers/resources.ts @@ -1,19 +1,19 @@ import { stringify as yamlStringify } from 'yaml' import { z } from 'zod' -import * as Cli from '../Cli.js' -import type * as Resources from '../client/Resources.js' -import { BaseError } from '../Errors.js' -import * as Formatter from '../Formatter.js' -import * as Help from '../Help.js' -import * as Mcp from '../Mcp.js' -import * as Openapi from '../Openapi.js' -import * as Skill from '../Skill.js' -import * as RuntimeContext from './runtime-context.js' +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import { BaseError } from '../../Errors.js' +import * as Formatter from '../../Formatter.js' +import * as Help from '../../Help.js' +import * as Mcp from '../../Mcp.js' +import * as Openapi from '../../Openapi.js' +import * as Skill from '../../Skill.js' +import * as RuntimeContext from '../runtime-context.js' -/** Discover failure with protocol code and HTTP status metadata. */ -export class DiscoverError extends BaseError { - override name = 'Incur.DiscoverError' +/** Resources failure with protocol code and HTTP status metadata. */ +export class ResourcesError extends BaseError { + override name = 'Incur.ResourcesError' /** Machine-readable error code. */ code: string /** HTTP status for discovery routes. */ @@ -45,13 +45,13 @@ const requestSchema = z.discriminatedUnion('resource', [ z.object({ resource: z.literal('mcpTools') }), ]) -/** Creates the shared client discovery executor. */ -export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { +/** Creates the shared in-process resources handler. */ +export function createResourcesHandler(ctx: RuntimeContext.RuntimeCliContext) { return { async discover(request: unknown): Promise { const parsedRequest = requestSchema.safeParse(request) if (!parsedRequest.success) - throw new DiscoverError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + throw new ResourcesError('VALIDATION_ERROR', 'Invalid discovery request.', 400) const parsed = parsedRequest.data if (parsed.resource === 'openapi') { const spec = openapi(ctx) @@ -83,10 +83,10 @@ export function createClientDiscover(ctx: RuntimeContext.RuntimeCliContext) { } } if (!safeSkillName(parsed.name)) - throw new DiscoverError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + throw new ResourcesError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) const file = files.find((value) => (value.dir || ctx.name) === parsed.name) if (!file) - throw new DiscoverError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + throw new ResourcesError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) return { contentType: 'text/markdown', body: file.content } } @@ -171,9 +171,9 @@ function scope(ctx: RuntimeContext.RuntimeCliContext, command: string | undefine } const resolved = RuntimeContext.resolveCanonical(ctx, command) if ('error' in resolved) - throw new DiscoverError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) + throw new ResourcesError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) if ('gateway' in resolved) - throw new DiscoverError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) + throw new ResourcesError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) if ('commands' in resolved) return { type: 'group' as const, diff --git a/src/internal/client-request.test.ts b/src/internal/handlers/rpc.test.ts similarity index 88% rename from src/internal/client-request.test.ts rename to src/internal/handlers/rpc.test.ts index ae44b3e..601831e 100644 --- a/src/internal/client-request.test.ts +++ b/src/internal/handlers/rpc.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest' import { z } from 'zod' -import * as Cli from '../Cli.js' -import * as Formatter from '../Formatter.js' -import { createClientRequest } from './client-request.js' -import * as RuntimeContext from './runtime-context.js' +import * as Cli from '../../Cli.js' +import * as Formatter from '../../Formatter.js' +import * as RuntimeContext from '../runtime-context.js' +import { createRpcHandler } from './rpc.js' function createFixture() { const order: string[] = [] @@ -76,26 +76,26 @@ function createFixture() { return { cli, order, ctx: RuntimeContext.fromCli(cli) } } -describe('createClientRequest', () => { +describe('createRpcHandler', () => { test('executes root, mounted root, and mounted router commands by canonical ID', async () => { const { ctx, order } = createFixture() await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: ' root ', args: {}, options: {}, }), ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) await expect( - createClientRequest(ctx, { env: { API_KEY: 'k', TOKEN: 't' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k', TOKEN: 't' } }).request({ command: 'child', args: { id: 'c1' }, options: { loud: true }, }), ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: { projectId: 'p1' }, options: { limit: 1 }, @@ -122,7 +122,7 @@ describe('createClientRequest', () => { test('rejects invalid RPC shape, unknown commands, groups, aliases, and raw fetch gateways', async () => { const { ctx } = createFixture() - const { request } = createClientRequest(ctx) + const { request } = createRpcHandler(ctx) await expect(request({ command: '' })).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' }, @@ -148,28 +148,28 @@ describe('createClientRequest', () => { test('validates structured args, options, CLI env, and command env independently', async () => { const { ctx } = createFixture() await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: {}, options: { limit: 1 }, }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: { projectId: 'p' }, options: { limit: 'bad' }, }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - createClientRequest(ctx).request({ + createRpcHandler(ctx).request({ command: 'project list', args: { projectId: 'p' }, options: {}, }), ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) await expect( - createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'child', args: { id: 'c' }, options: {}, @@ -179,7 +179,7 @@ describe('createClientRequest', () => { test('applies selection, formatting, token metadata, and CTA metadata', async () => { const { ctx } = createFixture() - const response = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: { projectId: 'p1' }, options: {}, @@ -210,12 +210,12 @@ describe('createClientRequest', () => { test('rejects empty selections and omits token count unless requested', async () => { const { ctx } = createFixture() await expect( - createClientRequest(ctx).request({ command: 'project list', selection: [] }), + createRpcHandler(ctx).request({ command: 'project list', selection: [] }), ).resolves.toMatchObject({ ok: false, error: { code: 'INVALID_RPC_REQUEST' }, }) - const response = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: { projectId: 'p1' }, options: {}, @@ -228,7 +228,7 @@ describe('createClientRequest', () => { expect(response.output).not.toHaveProperty('tokenOffset') expect(response.output).not.toHaveProperty('nextOffset') - const counted = await createClientRequest(ctx, { env: { API_KEY: 'k' } }).request({ + const counted = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project list', args: { projectId: 'p1' }, options: {}, @@ -247,7 +247,7 @@ describe('createClientRequest', () => { test('keeps token metadata on output for non-truncated and offset-only requests', async () => { const { ctx } = createFixture() - const request = createClientRequest(ctx, { env: { API_KEY: 'k' } }).request + const request = createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request const limited = await request({ command: 'project list', args: { projectId: 'p1' }, @@ -288,7 +288,7 @@ describe('createClientRequest', () => { test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { const { ctx, order } = createFixture() - const { request } = createClientRequest(ctx, { env: { API_KEY: 'k' } }) + const { request } = createRpcHandler(ctx, { env: { API_KEY: 'k' } }) const response = await request({ command: 'project stream', outputTokenCount: true, diff --git a/src/internal/client-request.ts b/src/internal/handlers/rpc.ts similarity index 94% rename from src/internal/client-request.ts rename to src/internal/handlers/rpc.ts index 983d0a3..3e62394 100644 --- a/src/internal/client-request.ts +++ b/src/internal/handlers/rpc.ts @@ -1,12 +1,12 @@ import { estimateTokenCount, sliceByTokens } from 'tokenx' import { z } from 'zod' -import type * as Rpc from '../client/Rpc.js' -import type { FieldError } from '../Errors.js' -import * as Filter from '../Filter.js' -import * as Formatter from '../Formatter.js' -import * as Command from './command.js' -import * as RuntimeContext from './runtime-context.js' +import type * as Rpc from '../../client/Rpc.js' +import type { FieldError } from '../../Errors.js' +import * as Filter from '../../Filter.js' +import * as Formatter from '../../Formatter.js' +import * as Command from '../command.js' +import * as RuntimeContext from '../runtime-context.js' const requestSchema = z.object({ command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), @@ -20,18 +20,18 @@ const requestSchema = z.object({ }) const sentinel = Symbol.for('incur.sentinel') -/** Returns the HTTP status for a client request error code. */ -export function getClientRequestStatus(code: string) { +/** Returns the HTTP status for an RPC error code. */ +export function getRpcStatus(code: string) { if (code === 'COMMAND_NOT_FOUND') return 404 if (code === 'VALIDATION_ERROR' || code === 'INVALID_RPC_REQUEST') return 400 if (code === 'COMMAND_GROUP' || code === 'FETCH_GATEWAY') return 400 return 500 } -/** Creates the shared client request executor. */ -export function createClientRequest( +/** Creates the shared in-process RPC handler. */ +export function createRpcHandler( ctx: RuntimeContext.RuntimeCliContext, - options: createClientRequest.Options = {}, + options: createRpcHandler.Options = {}, ) { return { async request(request: unknown): Promise { @@ -101,7 +101,7 @@ export function createClientRequest( } } -export declare namespace createClientRequest { +export declare namespace createRpcHandler { /** Execution options. */ type Options = { /** Explicit environment source. */ From 1147a6dc7885e81c447ae00d7d74d4f427957299 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 22:02:55 +0200 Subject: [PATCH 31/55] test(client): cover internal handlers --- src/internal/handlers/local.test.ts | 244 +++++++++++++++++ src/internal/handlers/resources.test.ts | 339 ++++++++++++++++++++++++ src/internal/handlers/rpc.test.ts | 87 +++++- 3 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 src/internal/handlers/local.test.ts create mode 100644 src/internal/handlers/resources.test.ts diff --git a/src/internal/handlers/local.test.ts b/src/internal/handlers/local.test.ts new file mode 100644 index 0000000..a62cc7d --- /dev/null +++ b/src/internal/handlers/local.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import * as Cli from '../../Cli.js' +import * as RuntimeContext from '../runtime-context.js' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + register: vi.fn(), + sync: vi.fn(), +})) + +vi.mock('../../SyncSkills.js', () => ({ + list: mocks.list, + sync: mocks.sync, +})) + +vi.mock('../../SyncMcp.js', () => ({ + register: mocks.register, +})) + +import { createLocalHandler, LocalError } from './local.js' + +function createFixture() { + const cli = Cli.create('app', { + description: 'App CLI', + mcp: { agents: ['claude-code'], command: 'pnpm app --mcp' }, + sync: { + cwd: '/workspace/app', + depth: 2, + include: ['skills/*'], + suggestions: ['Run app status'], + }, + }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + return { ctx, local: createLocalHandler(ctx).local } +} + +beforeEach(() => { + mocks.list.mockReset() + mocks.register.mockReset() + mocks.sync.mockReset() +}) + +describe('createLocalHandler', () => { + test('skills.add delegates to sync with context defaults', async () => { + const { ctx, local } = createFixture() + const result = { + agents: [{ agent: 'codex', path: '/agents/codex/app' }], + paths: ['/skills/app'], + skills: [{ description: 'App CLI', name: 'app' }], + } + mocks.sync.mockResolvedValueOnce(result) + + await expect(local.skills.add()).resolves.toBe(result) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + global: true, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add options override sync defaults', async () => { + const { ctx, local } = createFixture() + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add({ depth: 4, global: false }) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 4, + description: 'App CLI', + global: false, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add defaults depth to 1 and global to true when context has no sync defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + const { local } = createLocalHandler(ctx) + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add() + expect(mocks.sync).toHaveBeenCalledWith('bare', ctx.commands, { + cwd: undefined, + depth: 1, + description: undefined, + global: true, + include: undefined, + rootCommand: undefined, + }) + }) + + test('skills.add wraps sync failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('disk full') + mocks.sync.mockRejectedValueOnce(cause) + + try { + await local.skills.add() + throw new Error('expected local.skills.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'disk full', + name: 'Incur.LocalError', + shortMessage: 'Failed to sync local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('skills.list delegates to list and wraps the array result', async () => { + const { ctx, local } = createFixture() + const skills = [{ description: 'Show status', installed: true, name: 'app-status' }] + mocks.list.mockResolvedValueOnce(skills) + + await expect(local.skills.list()).resolves.toEqual({ skills }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list option depth overrides context depth', async () => { + const { ctx, local } = createFixture() + mocks.list.mockResolvedValueOnce([]) + + await local.skills.list({ depth: 5 }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 5, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list wraps list failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('bad glob') + mocks.list.mockRejectedValueOnce(cause) + + try { + await local.skills.list() + throw new Error('expected local.skills.list to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'bad glob', + name: 'Incur.LocalError', + shortMessage: 'Failed to list local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('mcp.add delegates to register with context defaults', async () => { + const { local } = createFixture() + const result = { agents: ['Claude Code'], command: 'pnpm app --mcp' } + mocks.register.mockResolvedValueOnce(result) + + await expect(local.mcp.add()).resolves.toBe(result) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['claude-code'], + command: 'pnpm app --mcp', + global: true, + }) + }) + + test('mcp.add options override context defaults', async () => { + const { local } = createFixture() + mocks.register.mockResolvedValueOnce({ agents: ['Cursor'], command: 'node app.js --mcp' }) + + await local.mcp.add({ + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + }) + + test('mcp.add defaults global to true without context defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const { local } = createLocalHandler(RuntimeContext.fromCli(cli)) + mocks.register.mockResolvedValueOnce({ agents: [], command: 'pnpm bare --mcp' }) + + await local.mcp.add() + expect(mocks.register).toHaveBeenCalledWith('bare', { + agents: undefined, + command: undefined, + global: true, + }) + }) + + test('mcp.add wraps register failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('missing runner') + mocks.register.mockRejectedValueOnce(cause) + + try { + await local.mcp.add() + throw new Error('expected local.mcp.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'missing runner', + name: 'Incur.LocalError', + shortMessage: 'Failed to register local MCP server.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('LocalError exposes a stable name', () => { + expect(new LocalError('Nope')).toMatchObject({ + message: 'Nope', + name: 'Incur.LocalError', + }) + }) +}) diff --git a/src/internal/handlers/resources.test.ts b/src/internal/handlers/resources.test.ts new file mode 100644 index 0000000..ed10c2f --- /dev/null +++ b/src/internal/handlers/resources.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import * as RuntimeContext from '../runtime-context.js' +import { createResourcesHandler, ResourcesError } from './resources.js' + +function createFixture() { + const project = Cli.create('project', { description: 'Project commands' }) + .command('list', { + description: 'List projects', + args: z.object({ org: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ projects: z.array(z.object({ id: z.string() })) }), + run() { + return { projects: [{ id: 'p1' }] } + }, + }) + .command('empty', { + description: 'Empty schema command', + run() { + return { ok: true } + }, + }) + + const cli = Cli.create('app', { + description: 'App CLI', + version: '1.2.3', + args: z.object({ workspace: z.string().optional() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ ok: z.boolean() }), + run() { + return { ok: true } + }, + }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string(), verbose: z.boolean() }), + examples: [ + { + args: { id: '123' }, + description: 'Verbose status', + options: { verbose: true }, + }, + ], + hint: 'Use status wisely', + env: z.object({ TOKEN: z.string().optional() }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose } + }, + }) + .command(project) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + return createResourcesHandler(RuntimeContext.fromCli(cli)) +} + +async function body(response: Resources.Response) { + if (!('body' in response)) throw new Error('expected body response') + return response.body +} + +async function data(response: Resources.Response) { + if (!('data' in response)) throw new Error('expected data response') + return response.data +} + +describe('createResourcesHandler', () => { + test('rejects invalid requests, unknown scopes, fetch scopes, and unsafe skill names', async () => { + const { discover } = createFixture() + const cases: { + request: unknown + code: string + status: number + }[] = [ + { request: {}, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 1 }, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 'missing' }, code: 'COMMAND_NOT_FOUND', status: 404 }, + { request: { resource: 'schema', command: 'api' }, code: 'FETCH_GATEWAY', status: 400 }, + { + request: { resource: 'skill', name: '../status' }, + code: 'INVALID_SKILL_NAME', + status: 400, + }, + { request: { resource: 'skill', name: 'missing' }, code: 'SKILL_NOT_FOUND', status: 404 }, + ] + + for (const item of cases) + await expect(discover(item.request)).rejects.toMatchObject({ + code: item.code, + name: 'Incur.ResourcesError', + status: item.status, + }) + }) + + test('returns llms resources across root, group, leaf, and non-markdown formats', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'llms' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + await expect(discover({ resource: 'llms', command: 'project' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app project project list ` | List projects |'), + }) + await expect(discover({ resource: 'llms', command: 'status' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + + await expect(discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api', description: 'Proxy API' }), + expect.objectContaining({ name: 'project list', description: 'List projects' }), + expect.objectContaining({ + name: 'project empty', + description: 'Empty schema command', + }), + expect.objectContaining({ name: 'status', description: 'Show status' }), + ]), + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'llms', format: 'yaml' }))) + expect(yaml).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + + const jsonl = JSON.parse(await body(await discover({ resource: 'llms', format: 'jsonl' }))) + expect(jsonl).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + }) + + test('returns full manifests with schemas, examples, output, and fetch gateway guidance', async () => { + const { discover } = createFixture() + const full = await discover({ resource: 'llmsFull', format: 'json' }) + const manifest = await data(full) + const commands = (manifest as { commands: any[] }).commands + + expect(full).toMatchObject({ + contentType: 'application/json', + data: { version: 'incur.v1' }, + }) + expect(commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + expect(commands.find((command) => command.name === 'api')).toMatchObject({ + description: 'Proxy API', + }) + expect(commands.find((command) => command.name === 'project list')).toMatchObject({ + schema: { + args: { properties: { org: { type: 'string' } }, required: ['org'] }, + output: { properties: { projects: { type: 'array' } }, required: ['projects'] }, + }, + }) + expect(commands.find((command) => command.name === 'project empty')).toMatchObject({ + description: 'Empty schema command', + }) + expect(commands.find((command) => command.name === 'status')).toMatchObject({ + examples: [{ command: 'status 123 --verbose true', description: 'Verbose status' }], + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + + const markdown = await body(await discover({ resource: 'llmsFull' })) + expect(markdown).toContain('Verbose status') + expect(markdown).toContain('## Output') + expect(markdown).toContain('Fetch gateway. Pass path segments') + expect(markdown).not.toMatch(/^# app st$/m) + }) + + test('returns schemas for root, group, leaf, and schemaless commands', async () => { + const { discover } = createFixture() + const rootSchema = await data(await discover({ resource: 'schema' })) + + expect((rootSchema as { commands: any[] }).commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + await expect(discover({ resource: 'schema', command: 'project' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { commands: [{ name: 'project empty' }, { name: 'project list' }] }, + }) + await expect(discover({ resource: 'schema', command: 'status' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + await expect(discover({ resource: 'schema', command: 'project empty' })).resolves.toEqual({ + contentType: 'application/json', + data: {}, + }) + }) + + test('returns help for root, group, and leaf command scopes', async () => { + const { discover } = createFixture() + + expect(await body(await discover({ resource: 'help' }))).toContain('Commands:') + expect(await body(await discover({ resource: 'help', command: 'project' }))).toContain('list') + const help = await body(await discover({ resource: 'help', command: 'status' })) + expect(help).toContain('Usage: status [options]') + expect(help).toContain('--verbose') + expect(help).toContain('TOKEN') + }) + + test('returns OpenAPI JSON and YAML with CLI metadata', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { + '/': { post: expect.any(Object) }, + '/status/{id}': { get: expect.any(Object) }, + '/project/list/{org}': { get: expect.any(Object) }, + }, + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'openapi', format: 'yaml' }))) + expect(yaml).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }) + + test('returns skills index, individual skill markdown, and MCP tools', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'skillsIndex' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + skills: expect.arrayContaining([ + { + description: 'App CLI. Run `app --help` for usage details.', + files: ['SKILL.md'], + name: 'app', + }, + { + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + name: 'status', + }, + ]), + }, + }) + + const rootSkill = await body(await discover({ resource: 'skill', name: 'app' })) + expect(rootSkill).toContain('# app') + expect(rootSkill).toContain('## Arguments') + expect(rootSkill).toContain('## Output') + + const statusSkill = await body(await discover({ resource: 'skill', name: 'status' })) + expect(statusSkill).toContain('# app status') + expect(statusSkill).toContain('## Arguments') + expect(statusSkill).toContain('## Options') + + const tools = (await data(await discover({ resource: 'mcpTools' }))) as { tools: any[] } + expect(tools.tools.map((tool) => tool.name)).toEqual([ + 'api', + 'project_empty', + 'project_list', + 'status', + ]) + expect(tools.tools.find((tool) => tool.name === 'status')).toMatchObject({ + description: 'Show status', + inputSchema: { + properties: { + id: { type: 'string' }, + verbose: { default: false, type: 'boolean' }, + }, + }, + outputSchema: { + properties: { + id: { type: 'string' }, + verbose: { type: 'boolean' }, + }, + }, + }) + }) + + test('ResourcesError exposes stable metadata', () => { + const error = new ResourcesError('NOPE', 'Nope.', 418) + expect(error).toMatchObject({ + code: 'NOPE', + message: 'Nope.', + name: 'Incur.ResourcesError', + status: 418, + }) + }) +}) diff --git a/src/internal/handlers/rpc.test.ts b/src/internal/handlers/rpc.test.ts index 601831e..c60d025 100644 --- a/src/internal/handlers/rpc.test.ts +++ b/src/internal/handlers/rpc.test.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import * as Cli from '../../Cli.js' import * as Formatter from '../../Formatter.js' import * as RuntimeContext from '../runtime-context.js' -import { createRpcHandler } from './rpc.js' +import { createRpcHandler, getRpcStatus } from './rpc.js' function createFixture() { const order: string[] = [] @@ -50,6 +50,21 @@ function createFixture() { return c.error({ code: 'STREAM_FAILED', message: 'nope', retryable: true }) }, }) + router.command('denied', { + run(c) { + return c.error({ + code: 'DENIED', + cta: { commands: ['project list'] }, + message: 'Denied.', + retryable: true, + }) + }, + }) + router.command('throw', { + run() { + throw new Error('boom') + }, + }) const cli = Cli.create('root', { vars: z.object({ root: z.string().default('unset') }), @@ -120,7 +135,36 @@ describe('createRpcHandler', () => { ]) }) - test('rejects invalid RPC shape, unknown commands, groups, aliases, and raw fetch gateways', async () => { + test('rejects malformed RPC requests with field errors', async () => { + const { ctx } = createFixture() + const { request } = createRpcHandler(ctx) + const cases = [ + null, + {}, + { command: 1 }, + { command: 'project list', args: [] }, + { command: 'project list', options: [] }, + { command: 'project list', outputFormat: 'xml' }, + { command: 'project list', outputTokenLimit: -1 }, + { command: 'project list', outputTokenOffset: 1.5 }, + { command: 'project list', selection: [] }, + ] + + for (const item of cases) { + const response = await request(item) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'INVALID_RPC_REQUEST', + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ message: expect.any(String) }), + ]), + }, + }) + } + }) + + test('rejects unknown commands, groups, aliases, and raw fetch gateways', async () => { const { ctx } = createFixture() const { request } = createRpcHandler(ctx) await expect(request({ command: '' })).resolves.toMatchObject({ @@ -177,6 +221,36 @@ describe('createRpcHandler', () => { ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) }) + test('returns command error envelopes with retryable and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project denied', + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'DENIED', message: 'Denied.', retryable: true }, + meta: { + command: 'project denied', + cta: { + commands: [{ command: 'root project list' }], + description: 'Suggested command:', + }, + }, + }) + }) + + test('returns thrown errors as unknown command failures', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project throw' }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'UNKNOWN', message: 'boom' }, + meta: { command: 'project throw' }, + }) + }) + test('applies selection, formatting, token metadata, and CTA metadata', async () => { const { ctx } = createFixture() const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ @@ -332,4 +406,13 @@ describe('createRpcHandler', () => { await iterator.return(undefined as any) expect(order).toContain('stream:return') }) + + test('maps RPC error codes to HTTP statuses', () => { + expect(getRpcStatus('COMMAND_NOT_FOUND')).toBe(404) + expect(getRpcStatus('VALIDATION_ERROR')).toBe(400) + expect(getRpcStatus('INVALID_RPC_REQUEST')).toBe(400) + expect(getRpcStatus('COMMAND_GROUP')).toBe(400) + expect(getRpcStatus('FETCH_GATEWAY')).toBe(400) + expect(getRpcStatus('UNKNOWN')).toBe(500) + }) }) From 3c9e469f78edcb898c84b109b4b0e240721798eb Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:02:13 +0200 Subject: [PATCH 32/55] refactor(client): rename local methods type --- src/client/Local.ts | 4 ++-- src/client/transports/MemoryTransport.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/Local.ts b/src/client/Local.ts index de8405e..265ead2 100644 --- a/src/client/Local.ts +++ b/src/client/Local.ts @@ -37,8 +37,8 @@ export type SkillsList = { /** MCP registration result. */ export type McpRegistration = SyncMcp.register.Result -/** Memory-only local operations exposed by the memory transport. */ -export type Handler = { +/** Memory-only local methods exposed by memory transports and clients. */ +export type Methods = { /** Skill setup actions. */ skills: { /** Sync generated skill files. */ diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts index 1fae7a0..b2090c8 100644 --- a/src/client/transports/MemoryTransport.ts +++ b/src/client/transports/MemoryTransport.ts @@ -15,7 +15,7 @@ export type MemoryTransport = Transport.Factory< { request(request: Rpc.Request): Promise discover(request: Resources.Request): Promise - local: Local.Handler + local: Local.Methods } > From c5d2832a32c554aff990004705be4d628ce99b5c Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:25:16 +0200 Subject: [PATCH 33/55] refactor(client): expose rpc error type --- src/client/ClientError.ts | 8 ++++---- src/client/Rpc.ts | 3 +++ src/client/transports/HttpTransport.ts | 4 +--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/client/ClientError.ts b/src/client/ClientError.ts index 58addcb..3765181 100644 --- a/src/client/ClientError.ts +++ b/src/client/ClientError.ts @@ -9,9 +9,9 @@ export class ClientError extends BaseError { /** Full error envelope or diagnostic payload. */ data: unknown | undefined /** RPC error object. */ - error: Extract['error'] | undefined + error: Rpc.Error | undefined /** Field validation errors. */ - fieldErrors: Extract['error']['fieldErrors'] | undefined + fieldErrors: Rpc.Error['fieldErrors'] | undefined /** Response metadata. */ meta: Rpc.Meta | undefined /** Whether the operation can be retried. */ @@ -39,9 +39,9 @@ export declare namespace ClientError { /** Full error envelope or diagnostic payload. */ data?: unknown | undefined /** RPC error object. */ - error?: Extract['error'] | undefined + error?: Rpc.Error | undefined /** Field validation errors. */ - fieldErrors?: Extract['error']['fieldErrors'] | undefined + fieldErrors?: Rpc.Error['fieldErrors'] | undefined /** Response metadata. */ meta?: Rpc.Meta | undefined /** Whether the operation can be retried. */ diff --git a/src/client/Rpc.ts b/src/client/Rpc.ts index 5b2477d..2d376a9 100644 --- a/src/client/Rpc.ts +++ b/src/client/Rpc.ts @@ -70,6 +70,9 @@ export type Envelope = status?: number | undefined } +/** RPC error object. */ +export type Error = Extract['error'] + /** Non-streaming RPC response. */ export type Response = Envelope diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts index c136c30..16853a4 100644 --- a/src/client/transports/HttpTransport.ts +++ b/src/client/transports/HttpTransport.ts @@ -229,9 +229,7 @@ function isRecord(value: unknown): value is Rpc.StreamRecord { ) } -function isErrorPayload( - value: unknown, -): value is { error: Extract['error'] } { +function isErrorPayload(value: unknown): value is { error: Rpc.Error } { return ( typeof value === 'object' && value !== null && From 20fc91357b055faf878830e98572eb334416654f Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Thu, 28 May 2026 01:37:50 +0200 Subject: [PATCH 34/55] chore(client): add client path alias --- tsconfig.base.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index c796343..6a77c1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "target": "esnext", "paths": { "incur": ["./src/index.ts"], + "incur/client": ["./src/client/index.ts"], "incur/register": ["./src/Register.ts"] }, 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 35/55] 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 36/55] 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 37/55] 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 38/55] 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 39/55] 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 40/55] 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 41/55] 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 42/55] 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 43/55] 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 44/55] 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 45/55] 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 46/55] 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 47/55] 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 48/55] 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 49/55] 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 50/55] 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 51/55] 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 52/55] 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 53/55] 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 54/55] 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 55/55] 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', {