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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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) + }) })