diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md new file mode 100644 index 0000000..7d5598c --- /dev/null +++ b/.changeset/quiet-walls-share.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs. diff --git a/.changeset/sour-dingos-shine.md b/.changeset/sour-dingos-shine.md new file mode 100644 index 0000000..9fefa90 --- /dev/null +++ b/.changeset/sour-dingos-shine.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed streaming command terminal records so HTTP NDJSON responses preserve returned `c.ok()` CTA metadata, represent returned or yielded `c.error()` values as terminal errors, include terminal duration metadata, and unwind generators on response cancellation. + +Also preserves `IncurError.retryable` metadata in streaming machine-format errors. diff --git a/.changeset/tame-pillows-accept.md b/.changeset/tame-pillows-accept.md new file mode 100644 index 0000000..84bce73 --- /dev/null +++ b/.changeset/tame-pillows-accept.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed generated and synced skills to use the same command projection as CLI skill output. + +`Skillgen` and `SyncSkills` now avoid generating duplicate skills for command aliases, preserve output schemas and examples consistently, and include the fetch gateway skill hint for fetch-based commands. diff --git a/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.test.ts b/src/Cli.test.ts index bd55fc6..5dc5d17 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import * as Command from './internal/command.js' + const originalIsTTY = process.stdout.isTTY beforeAll(() => { ;(process.stdout as any).isTTY = false @@ -3654,6 +3656,57 @@ test('streaming: generator throws in buffered mode', async () => { expect(output).toContain('generator exploded') }) +test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => { + const cli = Cli.create('test') + cli.command('limited', { + async *run() { + yield { step: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + + const jsonl = await serve(cli, ['limited', '--format', 'jsonl']) + const jsonlLines = jsonl.output + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(jsonl.exitCode).toBe(1) + expect(jsonlLines[1]).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "ok": false, + "type": "error", + } + `) + + const json = await serve(cli, ['limited', '--full-output', '--format', 'json']) + const body = JSON.parse(json.output) + body.meta.duration = '' + expect(json.exitCode).toBe(1) + expect(body).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "limited", + "duration": "", + }, + "ok": false, + } + `) +}) + test('streaming: generator returns error in buffered mode', async () => { const cli = Cli.create('test') cli.command('fail', { @@ -4051,13 +4104,96 @@ describe('--filter-output', () => { }) }) +describe('Command.execute', () => { + test.each([ + { + name: 'split', + command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { name: 123 }, + path: 'name', + parseMode: 'split' as const, + }, + { + name: 'flat', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { id: 123 }, + path: 'id', + parseMode: 'flat' as const, + }, + ])('$name mode returns validation fieldErrors for invalid command input', async (c) => { + const result = await Command.execute(c.command, { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: c.inputOptions, + name: 'test', + parseMode: c.parseMode, + path: 'users', + version: undefined, + }) + + expect(result).toMatchObject({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + missing: false, + path: c.path, + }, + ], + }, + }) + }) + + test('does not normalize handler-thrown Zod errors as command input', async () => { + const result = await Command.execute( + { + run() { + z.object({ name: z.string() }).parse({ name: 123 }) + }, + }, + { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: {}, + name: 'test', + path: 'users', + version: undefined, + }, + ) + + expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } }) + expect(result).not.toHaveProperty('error.fieldErrors') + }) +}) + async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() + expect(body.meta.duration).toMatch(/^\d+ms$/) body.meta.duration = '' return { status: res.status, body } } +async function fetchNdjson(cli: Cli.Cli, req: Request) { + const res = await cli.fetch(req) + const lines = (await res.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + for (const line of lines) + if (line.meta?.duration) { + expect(line.meta.duration).toMatch(/^\d+ms$/) + line.meta.duration = '' + } + return { status: res.status, contentType: res.headers.get('content-type'), lines } +} + describe('fetch', () => { test('GET /health → 200', async () => { const cli = Cli.create('test') @@ -4108,6 +4244,179 @@ describe('fetch', () => { expect(res.body.error.message).toContain("Did you mean 'health'?") }) + test('RPC route maps protocol failures to HTTP statuses', async () => { + const cli = Cli.create('app').command( + Cli.create('group').command('leaf', { + run() { + return null + }, + }), + ) + cli.command('raw', { fetch: () => new Response('{}') }) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: '' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "INVALID_RPC_REQUEST", + "message": "RPC command is required.", + }, + "meta": { + "command": "", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'group' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_GROUP", + "message": "'group' is a command group. Specify a subcommand.", + }, + "meta": { + "command": "group", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'raw' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "FETCH_GATEWAY", + "message": "'raw' is a raw fetch gateway and cannot be called with structured RPC.", + }, + "meta": { + "command": "raw", + "duration": "", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect( + await fetchJson( + cli, + new Request('http://localhost/_incur/rpc', { + method: 'POST', + body: JSON.stringify({ command: 'missing' }), + }), + ), + ).toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "'missing' is not a command for 'app'.", + }, + "meta": { + "command": "missing", + "duration": "", + }, + "ok": false, + }, + "status": 404, + } + `) + }) + + test('discovery routes map failures to envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/help?command=missing'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "Unknown command 'missing'.", + }, + "meta": { + "duration": "", + "resource": "help", + }, + "ok": false, + }, + "status": 404, + } + `) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=../x'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "INVALID_SKILL_NAME", + "message": "Unsafe skill name.", + }, + "meta": { + "duration": "", + "resource": "skill", + }, + "ok": false, + }, + "status": 400, + } + `) + + expect(await fetchJson(cli, new Request('http://localhost/_incur/skill?name=missing'))) + .toMatchInlineSnapshot(` + { + "body": { + "error": { + "code": "SKILL_NOT_FOUND", + "message": "Unknown skill 'missing'.", + }, + "meta": { + "duration": "", + "resource": "skill", + }, + "ok": false, + }, + "status": 404, + } + `) + }) + test('GET / with root command → 200', async () => { const cli = Cli.create('test', { run: () => ({ root: true }) }) expect(await fetchJson(cli, new Request('http://localhost/'))).toMatchInlineSnapshot(` @@ -4292,36 +4601,356 @@ describe('fetch', () => { return { done: true } }, }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "data": { + "progress": 2, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response preserves returned ok CTA through middleware', async () => { + const cli = Cli.create('test') + cli.use(async (_c, next) => { + await next() + }) + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "cta": { + "commands": [ + { + "command": "test next", + }, + ], + "description": "Next steps:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response handles terminal-only sentinel returns through middleware', async () => { + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (c, next) => { + order.push(`before:${c.command}`) + await next() + order.push(`after:${c.command}`) + }) + const sub = Cli.create('ops') + sub.command('ok', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.ok( + { ignored: true }, + { cta: { commands: [{ command: 'next', description: 'Continue' }] } }, + ) + }, + }) + sub.command('fail', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.error({ + code: 'EMPTY_FAIL', + cta: { commands: ['retry'], description: 'Recover with:' }, + message: 'failed before chunks', + retryable: true, + }) + }, + }) + cli.command(sub) + + const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok')) + expect(ok).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "meta": { + "command": "ops ok", + "cta": { + "commands": [ + { + "command": "test next", + "description": "Continue", + }, + ], + "description": "Suggested command:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + expect(ok.lines[0]).not.toHaveProperty('data') + + expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "error": { + "code": "EMPTY_FAIL", + "message": "failed before chunks", + "retryable": true, + }, + "meta": { + "command": "ops fail", + "cta": { + "commands": [ + { + "command": "test retry", + }, + ], + "description": "Recover with:", + }, + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail']) + }) + + test('streaming response represents returned error as terminal error', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed late", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response represents yielded error as terminal error', async () => { + let closed = false + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + try { + yield { progress: 1 } + yield c.error({ code: 'STREAM_FAIL', message: 'failed now' }) + yield { progress: 2 } + } finally { + closed = true + } + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed now", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(closed).toBe(true) + }) + + test('streaming response cancellation unwinds generator and middleware', async () => { + let resolveAfter = () => {} + const after = new Promise((resolve) => { + resolveAfter = resolve + }) + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (_c, next) => { + order.push('mw:before') + await next() + order.push('mw:after') + resolveAfter() + }) + cli.command('stream', { + async *run() { + try { + order.push('stream:yield') + yield { progress: 1 } + while (true) yield { progress: 2 } + } finally { + order.push('stream:finally') + } + }, + }) const res = await cli.fetch(new Request('http://localhost/stream')) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/x-ndjson') - const text = await res.text() - const lines = text - .trim() - .split('\n') - .map((l) => JSON.parse(l)) - expect(lines).toMatchInlineSnapshot(` - [ - { - "data": { - "progress": 1, + const reader = res.body!.getReader() + await reader.read() + await reader.cancel() + await after + expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after']) + }) + + test('streaming response thrown error includes terminal duration metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Error('boom') + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "type": "chunk", - }, - { - "data": { - "progress": 2, + { + "error": { + "code": "UNKNOWN", + "message": "boom", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", }, - "type": "chunk", - }, - { - "meta": { - "command": "stream", + ], + "status": 200, + } + `) + }) + + test('streaming response thrown IncurError preserves code and retryable metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "ok": true, - "type": "done", - }, - ] + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } `) }) diff --git a/src/Cli.ts b/src/Cli.ts index d8efef9..3318c5d 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -21,8 +21,11 @@ import { shells, } from './internal/command.js' import * as Command from './internal/command.js' +import { createResourcesHandler, ResourcesError } from './internal/handlers/resources.js' +import { createRpcHandler, getRpcStatus } from './internal/handlers/rpc.js' import { isRecord, suggest, toKebab } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' +import * as RuntimeContext from './internal/runtime-context.js' import type { OneOf } from './internal/types.js' import * as Mcp from './Mcp.js' import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from './middleware.js' @@ -75,17 +78,17 @@ export type Cli< env > /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */ - ( + ( name: name, definition: { basePath?: string | undefined description?: string | undefined fetch: FetchSource - openapi?: Openapi.OpenAPISource | undefined + openapi?: spec | undefined openapiConfig?: Openapi.Config | undefined outputPolicy?: OutputPolicy | undefined }, - ): Cli + ): Cli, vars, env> } /** A short description of the CLI. */ description?: string | undefined @@ -217,15 +220,22 @@ export function create( const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0') if (def.openapi && rootFetch) { - pending.push( - (async () => { - const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl }) - const generated = await Openapi.generateCommands(spec, rootFetch, { - config: def.openapiConfig, - }) - for (const [name, command] of generated) commands.set(name, command) - })(), - ) + if (isResolvedOpenapi(def.openapi)) { + const generated = Openapi.generateCommandsSync(def.openapi, rootFetch, { + config: def.openapiConfig, + }) + for (const [name, command] of generated) commands.set(name, command) + } else { + pending.push( + (async () => { + const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl }) + const generated = await Openapi.generateCommands(spec, rootFetch, { + config: def.openapiConfig, + }) + for (const [name, command] of generated) commands.set(name, command) + })(), + ) + } } const cli: Cli = { @@ -240,23 +250,35 @@ export function create( const fetch = resolveFetch(def.fetch) // OpenAPI + fetch → generate typed command group (async, resolved before serve) if (def.openapi) { - pending.push( - (async () => { - const spec = await Openapi.resolve(def.openapi, { - baseUrl: fetchBaseUrl(def.fetch), - }) - const generated = await Openapi.generateCommands(spec, fetch, { + const setOpenapiGroup = (generated: Map) => { + commands.set(nameOrCli, { + _group: true, + description: def.description, + commands: generated as Map, + ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), + } as InternalGroup) + } + if (isResolvedOpenapi(def.openapi)) { + setOpenapiGroup( + Openapi.generateCommandsSync(def.openapi, fetch, { basePath: def.basePath, config: def.openapiConfig, - }) - commands.set(nameOrCli, { - _group: true, - description: def.description, - commands: generated as Map, - ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), - } as InternalGroup) - })(), - ) + }), + ) + } else + pending.push( + (async () => { + const spec = await Openapi.resolve(def.openapi, { + baseUrl: fetchBaseUrl(def.fetch), + }) + setOpenapiGroup( + await Openapi.generateCommands(spec, fetch, { + basePath: def.basePath, + config: def.openapiConfig, + }), + ) + })(), + ) return cli } commands.set(nameOrCli, { @@ -339,7 +361,10 @@ export function create( if (rootDef && def.aliases) toRootAliases.set(cli as unknown as Root, def.aliases) if (def.options) toRootOptions.set(cli, def.options) if (def.config !== undefined) toConfigEnabled.set(cli, true) + if (def.mcp) toMcpOptions.set(cli, def.mcp) if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy) + if (def.sync) toSyncOptions.set(cli, def.sync) + if (def.version !== undefined) toVersion.set(cli, def.version) toMiddlewares.set(cli, middlewares) toCommands.set(cli, commands) return cli @@ -510,7 +535,7 @@ async function serveImpl( } catch (error) { const message = error instanceof Error ? error.message : String(error) if (human) writeln(formatHumanError({ code: 'UNKNOWN', message })) - else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon')) + else writeln(Formatter.format({ code: 'UNKNOWN', message }, Formatter.defaultFormat)) exit(1) return } @@ -713,7 +738,10 @@ async function serveImpl( if (human) { writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message })) writeln(formatHumanCta(cta)) - } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon')) + } else + writeln( + Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat), + ) exit(1) return } @@ -760,7 +788,7 @@ async function serveImpl( code: 'LIST_SKILLS_FAILED', message: err instanceof Error ? err.message : String(err), }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -816,13 +844,13 @@ async function serveImpl( if (fullOutput || formatExplicit) { const output: Record = { skills: result.paths } if (fullOutput && result.agents.length > 0) output.agents = result.agents - writeln(Formatter.format(output, formatExplicit ? formatFlag : 'toon')) + writeln(Formatter.format(output, formatExplicit ? formatFlag : Formatter.defaultFormat)) } } catch (err) { writeln( Formatter.format( { code: 'SYNC_SKILLS_FAILED', message: err instanceof Error ? err.message : String(err) }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -851,7 +879,10 @@ async function serveImpl( if (human) { writeln(formatHumanError({ code: 'COMMAND_NOT_FOUND', message })) writeln(formatHumanCta(cta)) - } else writeln(Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, 'toon')) + } else + writeln( + Formatter.format({ code: 'COMMAND_NOT_FOUND', message, cta }, Formatter.defaultFormat), + ) exit(1) return } @@ -900,14 +931,14 @@ async function serveImpl( writeln( Formatter.format( { name, command: result.command, agents: result.agents }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) } catch (err) { writeln( Formatter.format( { code: 'MCP_ADD_FAILED', message: err instanceof Error ? err.message : String(err) }, - formatExplicit ? formatFlag : 'toon', + formatExplicit ? formatFlag : Formatter.defaultFormat, ), ) exit(1) @@ -1097,14 +1128,8 @@ async function serveImpl( exit(1) return } - const cmd = resolved.command - const format = formatExplicit ? formatFlag : 'toon' - const result: Record = {} - if (cmd.args) result.args = Schema.toJsonSchema(cmd.args) - if (cmd.env) result.env = Schema.toJsonSchema(cmd.env) - if (cmd.options) result.options = Schema.toJsonSchema(cmd.options) - if (cmd.output) result.output = Schema.toJsonSchema(cmd.output) - writeln(Formatter.format(result, format)) + const format = formatExplicit ? formatFlag : Formatter.defaultFormat + writeln(Formatter.format(buildCommandSchema(resolved.command) ?? {}, format)) return } @@ -1121,9 +1146,11 @@ async function serveImpl( const start = performance.now() - // Resolve effective format: explicit --format/--json → command default → CLI default → toon + // Resolve effective format: explicit --format/--json → command default → CLI default → Formatter.defaultFormat const resolvedFormat = 'command' in resolved && (resolved as any).command.format - const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon' + const format = formatExplicit + ? formatFlag + : resolvedFormat || options.format || Formatter.defaultFormat // Fall back to root fetch/command when no subcommand matches, // but only if the token doesn't look like a typo of a known command. @@ -1673,6 +1700,112 @@ async function fetchImpl( const url = new URL(req.url) const segments = url.pathname.split('/').filter(Boolean) + if (segments[0] === '_incur') { + const ctx: RuntimeContext.RuntimeCliContext = { + commands, + ...(options.description ? { description: options.description } : undefined), + ...(options.envSchema ? { env: options.envSchema } : undefined), + middlewares: options.middlewares ?? [], + name, + ...(options.rootCommand ? { rootCommand: options.rootCommand as any } : undefined), + ...(options.vars ? { vars: options.vars } : undefined), + ...(options.version ? { version: options.version } : undefined), + } + + if (segments[1] === 'rpc' && segments.length === 2 && req.method === 'POST') { + const client = createRpcHandler(ctx) + let body: unknown + try { + body = await req.json() + } catch { + const response = await client.request({}) + return new Response(JSON.stringify(response), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + } + const response = await client.request(body) + if ('stream' in response) { + const records = response.records() + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + try { + for await (const record of records) + controller.enqueue(encoder.encode(`${JSON.stringify(record)}\n`)) + } finally { + controller.close() + } + }, + async cancel() { + await records.return(undefined as any) + }, + }) + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }) + } + return new Response(JSON.stringify(response), { + status: response.ok ? 200 : getRpcStatus(response.error.code), + headers: { 'content-type': 'application/json' }, + }) + } + + if (req.method === 'GET') { + const resource = (() => { + if (segments[1] === 'llms') return 'llms' + if (segments[1] === 'llms-full') return 'llmsFull' + if (segments[1] === 'schema') return 'schema' + if (segments[1] === 'help') return 'help' + if (segments[1] === 'openapi') return 'openapi' + if (segments[1] === 'skills') return 'skillsIndex' + if (segments[1] === 'skill') return 'skill' + if (segments[1] === 'mcp' && segments[2] === 'tools') return 'mcpTools' + return undefined + })() + if (resource) { + try { + const client = createResourcesHandler(ctx) + const discovery = await client.discover({ + resource, + ...(url.searchParams.get('command') + ? { command: url.searchParams.get('command')! } + : undefined), + ...(url.searchParams.get('format') + ? { format: url.searchParams.get('format')! } + : undefined), + ...(url.searchParams.get('name') ? { name: url.searchParams.get('name')! } : undefined), + }) + return new Response( + 'body' in discovery ? discovery.body : JSON.stringify(discovery.data), + { + status: 200, + headers: { 'content-type': discovery.contentType }, + }, + ) + } catch (error) { + const status = error instanceof ResourcesError ? error.status : 500 + const code = error instanceof ResourcesError ? error.code : 'DISCOVERY_ERROR' + return new Response( + JSON.stringify({ + ok: false, + error: { + code, + message: error instanceof Error ? error.message : String(error), + }, + meta: { + resource, + duration: `${Math.round(performance.now() - start)}ms`, + }, + }), + { status, headers: { 'content-type': 'application/json' } }, + ) + } + } + } + } + // OpenAPI discovery: route /openapi.json, /openapi.yml, /openapi.yaml, and /.well-known/openapi.json if (req.method === 'GET' && isOpenapiRoute(segments)) { const spec = generatedOpenapi(name, commands, options) @@ -1708,8 +1841,7 @@ async function fetchImpl( if (segments[2] === 'index.json' && segments.length === 3) { const files = Skill.split(name, cmds, 1, groups) const skills = files.map((f) => { - const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/) - const meta = fmMatch ? (yamlParse(fmMatch[1]!) as Record) : {} + const meta = parseSkillFrontmatter(f.content) return { name: f.dir || name, description: meta.description ?? '', @@ -1854,24 +1986,61 @@ async function executeCommand( // Streaming path — async generator → NDJSON response if ('stream' in result) { + const iterator = result.stream + const encoder = new TextEncoder() + const meta = (cta?: FormattedCtaBlock | undefined) => ({ + command: path, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + }) + const errorRecord = (err: ErrorResult) => ({ + type: 'error', + ok: false, + error: { + code: err.code, + message: err.message, + ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined), + }, + meta: meta(formatCtaBlock(options.name ?? path, err.cta)), + }) const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() + async cancel() { + await iterator.return(undefined) + }, + async pull(controller) { try { - for await (const value of result.stream) { + const { value, done } = await iterator.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + controller.close() + return + } + const cta = + isSentinel(value) && value[sentinel] === 'ok' + ? formatCtaBlock(options.name ?? path, value.cta) + : undefined controller.enqueue( - encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'), + encoder.encode( + JSON.stringify({ + type: 'done', + ok: true, + meta: meta(cta), + }) + '\n', + ), ) + controller.close() + return } - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'done', - ok: true, - meta: { command: path }, - }) + '\n', - ), - ) + + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + await iterator.return(undefined) + controller.close() + return + } + + controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n')) } catch (error) { controller.enqueue( encoder.encode( @@ -1879,14 +2048,16 @@ async function executeCommand( type: 'error', ok: false, error: { - code: 'UNKNOWN', + code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, + meta: meta(), }) + '\n', ), ) + controller.close() } - controller.close() }, }) return new Response(stream, { @@ -2146,7 +2317,7 @@ function extractBuiltinFlags(argv: string[], options: extractBuiltinFlags.Option let help = false let version = false let schema = false - let format: Formatter.Format = 'toon' + let format: Formatter.Format = Formatter.defaultFormat let formatExplicit = false let configPath: string | undefined let configDisabled = false @@ -2447,8 +2618,8 @@ export type CommandsMap = Record< > /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ -type CommandEntry = - | CommandDefinition +export type CommandEntry = + | CommandDefinition | InternalGroup | InternalFetchGateway | InternalAlias @@ -2463,7 +2634,7 @@ export type FetchHandler = Fetch.Handler export type FetchSource = Fetch.Source /** @internal A command group's internal storage. */ -type InternalGroup = { +export type InternalGroup = { _group: true description?: string | undefined middlewares?: MiddlewareHandler[] | undefined @@ -2472,7 +2643,7 @@ type InternalGroup = { } /** @internal A fetch gateway entry. */ -type InternalFetchGateway = { +export type InternalFetchGateway = { _fetch: true basePath?: string | undefined description?: string | undefined @@ -2497,30 +2668,34 @@ function fetchBaseUrl(source: FetchSource) { return typeof source === 'function' ? undefined : source.url } +function isResolvedOpenapi(source: Openapi.OpenAPISource): source is Openapi.OpenAPISpec { + return typeof source !== 'string' && !(source instanceof URL) +} + /** @internal Type guard for command groups. */ -function isGroup(entry: CommandEntry): entry is InternalGroup { +export function isGroup(entry: CommandEntry): entry is InternalGroup { return '_group' in entry } /** @internal Type guard for fetch gateways. */ -function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { +export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { return '_fetch' in entry } /** @internal An alias entry that points to another command by name. */ -type InternalAlias = { +export type InternalAlias = { _alias: true /** The canonical command name this alias resolves to. */ target: string } /** @internal Type guard for alias entries. */ -function isAlias(entry: CommandEntry): entry is InternalAlias { +export function isAlias(entry: CommandEntry): entry is InternalAlias { return '_alias' in entry } /** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */ -function resolveAlias( +export function resolveAlias( commands: Map, entry: CommandEntry, ): Exclude { @@ -2532,7 +2707,7 @@ function resolveAlias( export const toCommands = new WeakMap>() /** @internal Maps CLI instances to their middleware arrays. */ -const toMiddlewares = new WeakMap() +export const toMiddlewares = new WeakMap() /** @internal Maps root CLI instances to their command definitions. */ export const toRootDefinition = new WeakMap>() @@ -2546,6 +2721,26 @@ export const toConfigEnabled = new WeakMap() /** @internal Maps CLI instances to their output policy. */ const toOutputPolicy = new WeakMap() +/** @internal Maps CLI instances to MCP setup options. */ +export const toMcpOptions = new WeakMap< + Cli, + { agents?: string[] | undefined; command?: string | undefined } +>() + +/** @internal Maps CLI instances to skill sync options. */ +export const toSyncOptions = new WeakMap< + Cli, + { + cwd?: string | undefined + depth?: number | undefined + include?: string[] | undefined + suggestions?: string[] | undefined + } +>() + +/** @internal Maps CLI instances to their version strings. */ +export const toVersion = new WeakMap() + /** @internal Maps root CLI instances to their command aliases. */ const toRootAliases = new WeakMap() @@ -2633,7 +2828,7 @@ async function handleStreaming( // Incremental: no explicit format (default toon), or explicit jsonl // Buffered: explicit json/yaml/toon/md const useJsonl = ctx.format === 'jsonl' - const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === 'toon') + const incremental = useJsonl || (!ctx.formatExplicit && ctx.format === Formatter.defaultFormat) if (incremental) { // Incremental output: write each chunk as it arrives @@ -2719,6 +2914,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, }), ) @@ -2802,6 +2998,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, meta: { command: ctx.path, @@ -2839,7 +3036,7 @@ function formatCta(name: string, cta: Cta): FormattedCta { } /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */ -function buildIndexManifest(commands: Map, prefix: string[] = []) { +export function buildIndexManifest(commands: Map, prefix: string[] = []) { return { version: 'incur.v1', commands: collectIndexCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), @@ -2869,7 +3066,7 @@ function collectIndexCommands( } /** @internal Builds the `--llms` manifest from the command tree. */ -function buildManifest(commands: Map, prefix: string[] = []) { +export function buildManifest(commands: Map, prefix: string[] = []) { return { version: 'incur.v1', commands: collectCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), @@ -2900,14 +3097,13 @@ function collectCommands( const cmd: (typeof result)[number] = { name: path.join(' ') } if (entry.description) cmd.description = entry.description - const inputSchema = buildInputSchema(entry.args, entry.env, entry.options) - const outputSchema = entry.output ? Schema.toJsonSchema(entry.output) : undefined - if (inputSchema || outputSchema) { + const schema = buildCommandSchema(entry) + if (schema) { cmd.schema = {} - if (inputSchema?.args) cmd.schema.args = inputSchema.args - if (inputSchema?.env) cmd.schema.env = inputSchema.env - if (inputSchema?.options) cmd.schema.options = inputSchema.options - if (outputSchema) cmd.schema.output = outputSchema + if (schema.args) cmd.schema.args = schema.args + if (schema.env) cmd.schema.env = schema.env + if (schema.options) cmd.schema.options = schema.options + if (schema.output) cmd.schema.output = schema.output } const examples = formatExamples(entry.examples) @@ -2925,11 +3121,11 @@ function collectCommands( } /** @internal Recursively collects leaf commands as `Skill.CommandInfo` for `--llms --format md`. */ -function collectSkillCommands( +export function collectSkillCommands( commands: Map, prefix: string[], groups: Map, - rootCommand?: CommandDefinition | undefined, + rootCommand?: SkillCommandSource | undefined, ): Skill.CommandInfo[] { const result: Skill.CommandInfo[] = [] if (rootCommand) { @@ -2977,6 +3173,11 @@ function collectSkillCommands( return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) } +type SkillCommandSource = Pick< + CommandDefinition, + 'args' | 'description' | 'env' | 'examples' | 'hint' | 'options' | 'output' +> + /** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */ export function formatExamples( examples: Example[] | undefined, @@ -2993,32 +3194,49 @@ export function formatExamples( }) } -/** @internal Builds separate args, env, and options JSON Schemas. */ -function buildInputSchema( - args: z.ZodObject | undefined, - env: z.ZodObject | undefined, - options: z.ZodObject | undefined, +/** @internal Parses YAML frontmatter from generated skill Markdown. */ +export function parseSkillFrontmatter(content: string): { + description?: string | undefined + name?: string | undefined +} { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return {} + const meta = yamlParse(match[1]!) + if (!meta || typeof meta !== 'object') return {} + return meta as { description?: string | undefined; name?: string | undefined } +} + +/** @internal Builds separate command JSON Schemas. */ +export function buildCommandSchema( + command: Pick< + CommandDefinition, + 'args' | 'env' | 'options' | 'output' + >, ): | { args?: Record | undefined env?: Record | undefined options?: Record | undefined + output?: Record | undefined } | undefined { - if (!args && !env && !options) return undefined + const { args, env, options, output } = command + if (!args && !env && !options && !output) return undefined const result: { args?: Record | undefined env?: Record | undefined options?: Record | undefined + output?: Record | undefined } = {} if (args) result.args = Schema.toJsonSchema(args) if (env) result.env = Schema.toJsonSchema(env) if (options) result.options = Schema.toJsonSchema(options) + if (output) result.output = Schema.toJsonSchema(output) return result } /** @internal A usage example for a command, typed against its args and options schemas. */ -type Example< +export type Example< args extends z.ZodObject | undefined, options extends z.ZodObject | undefined, > = { @@ -3031,7 +3249,7 @@ type Example< } /** @internal A usage pattern shown in help output. */ -type Usage< +export type Usage< args extends z.ZodObject | undefined, options extends z.ZodObject | undefined, > = { @@ -3107,7 +3325,7 @@ declare namespace Output { } /** @internal Defines a command's schema, handler, and metadata. */ -type CommandDefinition< +export type CommandDefinition< args extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, options extends z.ZodObject | undefined = undefined, diff --git a/src/Formatter.ts b/src/Formatter.ts index 21bfbdd..2685d8f 100644 --- a/src/Formatter.ts +++ b/src/Formatter.ts @@ -4,8 +4,11 @@ import { stringify as yamlStringify } from 'yaml' /** Supported output formats. */ export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl' +/** Default rendered output format. */ +export const defaultFormat = 'toon' satisfies Format + /** Serializes a value to the specified format. Defaults to TOON. */ -export function format(value: unknown, fmt: Format = 'toon'): string { +export function format(value: unknown, fmt: Format = defaultFormat): string { if (value == null) return '' if (fmt === 'json') { if (typeof value === 'string') { diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 9bdf996..6b9a732 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -162,6 +162,36 @@ describe('generateCommands', () => { expect(limitSchema.description).toBe('Max results') }) + test('infers output from JSON response schemas', async () => { + const commands = await Openapi.generateCommands( + { + paths: { + '/users/posts': { + get: { + operationId: 'listPosts', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + () => new Response(JSON.stringify({ ok: true })), + ) + const command = commands.get('listPosts')! + if ('_group' in command) throw new Error('expected listPosts command') + expect(command.output).toBeDefined() + }) + test('generates namespace command groups from paths', async () => { const commands = await Openapi.generateCommands(spec, app.fetch, { config: { mode: 'namespace' }, diff --git a/src/Openapi.ts b/src/Openapi.ts index 0a862d8..44c593f 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -26,6 +26,44 @@ export type Config = { mode?: Mode | undefined } +/** Inferred command map for operation commands generated from a literal OpenAPI spec. */ +export type Commands< + name extends string, + spec extends OpenAPISource | undefined, +> = spec extends OpenAPISpec + ? { + [path in keyof NonNullable & string as OperationCommandName< + name, + NonNullable[path] + >]: { + args: Record + options: Record + output: unknown + } + } + : {} + +type OperationCommandName = item extends object + ? { + [method in keyof item & string]: method extends OperationMethod + ? item[method] extends { operationId: infer id extends string } + ? `${name} ${id}` + : `${name} ${method} ${string}` + : never + }[keyof item & string] + : never + +type OperationMethod = + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'query' + | 'trace' + /** Options for generating an OpenAPI document from an incur CLI. */ export type GenerateOptions = { /** API description. Defaults to the CLI description. */ @@ -96,6 +134,7 @@ type GeneratedCommand = { args?: z.ZodObject | undefined description?: string | undefined options?: z.ZodObject | undefined + output?: z.ZodType | undefined run: (context: any) => any } @@ -337,6 +376,15 @@ export async function generateCommands( fetch: FetchHandler, options: generateCommands.Options = {}, ): Promise> { + return generateCommandsSync(spec, fetch, options) +} + +/** Synchronously generates incur command entries from an already-loaded OpenAPI spec. */ +export function generateCommandsSync( + spec: OpenAPISpec, + fetch: FetchHandler, + options: generateCommands.Options = {}, +): Map { const resolved = dereference(structuredClone(spec)) as OpenAPISpec const commands = new Map() const paths = (resolved.paths ?? {}) as Record> @@ -360,6 +408,7 @@ export async function generateCommands( const bodySchema = op.requestBody?.content?.['application/json']?.schema const bodyProps = (bodySchema?.properties ?? {}) as Record> const bodyRequired = new Set((bodySchema?.required as string[]) ?? []) + const outputSchema = responseSchema(op.responses) // Build args Zod schema from path params let argsSchema: z.ZodObject | undefined @@ -393,6 +442,7 @@ export async function generateCommands( description: op.summary ?? op.description, args: argsSchema, options: optionsSchema, + ...(outputSchema ? { output: toZod(outputSchema) } : undefined), run: createHandler({ basePath: options.basePath, fetch, @@ -658,3 +708,15 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType { const desc = (schema as any).description ?? (inner as any).description return desc ? coerced.describe(desc) : coerced } + +function responseSchema(responses: Record | undefined) { + if (!responses) return undefined + const entries = Object.entries(responses) + const preferred = + entries.find(([status]) => status === '200') ?? + entries.find(([status]) => /^2\d\d$/.test(status)) + const response = preferred?.[1] as + | { content?: Record | undefined }> | undefined } + | undefined + return response?.content?.['application/json']?.schema +} diff --git a/src/Parser.ts b/src/Parser.ts index ea21a75..3a601ad 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -257,7 +257,7 @@ function setOption( } /** Wraps zod schema.parse(), converting ZodError to ValidationError. */ -function zodParse(schema: z.ZodObject, data: Record) { +export function zodParse(schema: z.ZodObject, data: Record) { try { return schema.parse(data) } catch (err: any) { diff --git a/src/Skillgen.test.ts b/src/Skillgen.test.ts index 49ae419..6e1df8f 100644 --- a/src/Skillgen.test.ts +++ b/src/Skillgen.test.ts @@ -60,13 +60,20 @@ test('collects group descriptions', async () => { test('includes args, options, and examples in output', async () => { const cli = Cli.create('tool', { description: 'A tool', - }).command('greet', { - description: 'Greet someone', - args: z.object({ name: z.string().describe('Name to greet') }), - options: z.object({ loud: z.boolean().default(false).describe('Shout') }), - examples: [{ args: { name: 'world' }, description: 'Greet the world' }], - run: () => ({}), }) + .command('greet', { + description: 'Greet someone', + aliases: ['hi'], + args: z.object({ name: z.string().describe('Name to greet') }), + options: z.object({ loud: z.boolean().default(false).describe('Shout') }), + output: z.object({ message: z.string() }), + examples: [{ args: { name: 'world' }, description: 'Greet the world' }], + run: () => ({ message: 'hi' }), + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) vi.mocked(importCli).mockResolvedValue(cli) const files = await generate('fake-input', tmp, 0) @@ -74,4 +81,7 @@ test('includes args, options, and examples in output', async () => { expect(content).toContain('Name to greet') expect(content).toContain('Shout') expect(content).toContain('Greet the world') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toContain('# tool hi') }) diff --git a/src/Skillgen.ts b/src/Skillgen.ts index 844e52c..3dd2e73 100644 --- a/src/Skillgen.ts +++ b/src/Skillgen.ts @@ -13,7 +13,12 @@ export async function generate(input: string, output: string, depth = 1): Promis const groups = new Map() if (cli.description) groups.set(cli.name, cli.description) - const entries = collectEntries(commands, [], groups) + const entries = Cli.collectSkillCommands( + commands, + [], + groups, + Cli.toRootDefinition.get(cli as unknown as Cli.Root), + ) const files = Skill.split(cli.name, entries, depth, groups) if (depth > 0) await fs.rm(output, { recursive: true, force: true }) @@ -30,37 +35,3 @@ export async function generate(input: string, output: string, depth = 1): Promis return written } - -/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(path.join(' '), entry.description) - result.push(...collectEntries(entry.commands, path, groups)) - } else { - const cmd: Skill.CommandInfo = { name: path.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = Cli.formatExamples(entry.examples) - if (examples) { - const cmdName = path.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 530be61..8f64d23 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -1,4 +1,4 @@ -import { Cli, SyncSkills } from 'incur' +import { Cli, SyncSkills, z } from 'incur' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -288,6 +288,44 @@ test('list includes root command skill', async () => { expect(names).toContain('test-ping') }) +test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => { + const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const cli = Cli.create('tool') + .command('real', { + description: 'Real command', + aliases: ['r'], + options: z.object({ dryRun: z.boolean().default(false) }), + output: z.object({ value: z.string() }), + examples: [{ options: { dryRun: true }, description: 'Preview' }], + run: () => ({ value: 'ok' }), + }) + .command('api', { description: 'Raw API', fetch: () => new Response('{}') }) + + const commands = Cli.toCommands.get(cli)! + const listed = await SyncSkills.list('tool', commands) + const names = listed.map((skill) => skill.name) + expect(names).toContain('tool-api') + expect(names).toContain('tool-real') + expect(names).not.toContain('tool-r') + + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + const synced = await SyncSkills.sync('tool', commands, { + depth: 0, + global: false, + cwd: installDir, + }) + const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8') + expect(content).toContain('Preview') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toMatch(/^# tool r$/m) + + rmSync(tmp, { recursive: true, force: true }) +}) + test('list results are sorted alphabetically', async () => { const cli = Cli.create('test') cli.command('zebra', { description: 'Z command', run: () => ({}) }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 037c350..3317c26 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -2,9 +2,8 @@ import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { parse as yamlParse } from 'yaml' -import { formatExamples } from './Cli.js' +import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js' import * as Agents from './internal/agents.js' import * as Skill from './Skill.js' @@ -19,7 +18,7 @@ export async function sync( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`)) @@ -31,7 +30,7 @@ export async function sync( : path.join(tmpDir, 'SKILL.md') await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${file.content}\n`) - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) skills.push({ name: meta.name ?? (file.dir || name), description: meta.description }) } @@ -42,7 +41,7 @@ export async function sync( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) const dest = path.join(tmpDir, skillName, 'SKILL.md') @@ -68,7 +67,7 @@ export async function sync( } // Write skills hash + names for staleness detection - const hashEntries = collectEntries(commands, [], undefined, options.rootCommand) + const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand) writeMeta( name, Skill.hash(hashEntries), @@ -139,14 +138,14 @@ export async function list( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const skills: list.Skill[] = [] const installed = readInstalledSkills(name, { cwd }) for (const file of files) { - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) const skillName = meta.name ?? (file.dir || name) skills.push({ name: skillName, @@ -162,7 +161,7 @@ export async function list( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) if (!skills.some((s) => s.name === skillName)) { @@ -223,75 +222,6 @@ export declare namespace list { } } -/** Recursively collects leaf commands as `Skill.CommandInfo`. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), - rootCommand?: - | { - description?: string | undefined - args?: any - env?: any - hint?: string | undefined - options?: any - output?: any - examples?: any[] | undefined - } - | undefined, -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - if (rootCommand) { - const cmd: Skill.CommandInfo = {} - if (rootCommand.description) cmd.description = rootCommand.description - if (rootCommand.args) cmd.args = rootCommand.args - if (rootCommand.env) cmd.env = rootCommand.env - if (rootCommand.hint) cmd.hint = rootCommand.hint - if (rootCommand.options) cmd.options = rootCommand.options - if (rootCommand.output) cmd.output = rootCommand.output - const examples = formatExamples(rootCommand.examples) - if (examples) cmd.examples = examples - result.push(cmd) - } - for (const [name, entry] of commands) { - const entryPath = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(entryPath.join(' '), entry.description) - result.push(...collectEntries(entry.commands, entryPath, groups)) - } else { - const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = formatExamples(entry.examples) - if (examples) { - const cmdName = entryPath.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} - -function parseFrontmatter(content: string): { - description?: string | undefined - name?: string | undefined -} { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match) return {} - - const meta = yamlParse(match[1]!) - if (!meta || typeof meta !== 'object') return {} - return meta as { description?: string | undefined; name?: string | undefined } -} - /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */ function resolvePackageRoot(): string { const bin = process.argv[1] diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..466cd59 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: {} } } } } @@ -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: () => ({}) }) @@ -125,7 +157,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 +201,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 +212,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,12 +226,41 @@ describe('fromCli', () => { "declare module 'incur' { interface Register { commands: { - 'ping': { args: {}; options: {} } - 'pr list': { args: {}; options: {} } + ping: { args: {}; options: {} } + "pr list": { args: {}; options: {} } } } } " `) }) + + 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'") + }) + + test('escapes command and property keys', () => { + const cli = Cli.create('test').command('bad key "quoted"', { + options: z.object({ + 'bad-key': z.string().optional(), + 'quote"key': z.number(), + nested: z.object({ 'child-key': z.string().optional() }), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"bad key \\"quoted\\""') + expect(output).toContain('"bad-key"?: string | undefined') + expect(output).toContain('"quote\\"key": number') + expect(output).toContain('nested: { "child-key"?: string | undefined }') + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..3d92af7 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import { z } from 'zod' import * as Cli from './Cli.js' +import * as RuntimeContext from './internal/runtime-context.js' import { importCli } from './internal/utils.js' /** Imports a CLI from `input` (must `export default` a `Cli`), generates the `.d.ts`, and writes it to `output`. */ @@ -12,48 +13,30 @@ 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)} }`, + ` ${propertyKey(id)}: { args: ${objectSchemaToType(command.args)}; options: ${objectSchemaToType(command.options)}${command.output ? `; output: ${schemaToType(command.output)}` : ''}${isStream(command) ? '; stream: true' : ''} }`, ) 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 { +function objectSchemaToType(schema: z.ZodObject | undefined): string { if (!schema) return '{}' + return schemaToType(schema) +} + +/** Converts a Zod schema to a TypeScript type string. */ +function schemaToType(schema: z.ZodType): string { const json = z.toJSONSchema(schema) as Record const defs = (json.$defs ?? {}) as Record> - const properties = json.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' - const required = new Set((json.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) - return `{ ${entries.join('; ')} }` + return resolveType(json, defs) } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ @@ -98,12 +81,22 @@ function resolveType( const properties = schema.properties as Record> | undefined if (!properties || Object.keys(properties).length === 0) return '{}' const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) + const entries = Object.entries(properties).map(([key, value]) => { + const type = resolveType(value, defs) + if (required.has(key)) return `${propertyKey(key)}: ${type}` + return `${propertyKey(key)}?: ${type} | undefined` + }) return `{ ${entries.join('; ')} }` } default: return 'unknown' } } + +function propertyKey(key: string) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +} + +function isStream(command: Cli.CommandDefinition) { + return command.run.constructor.name === 'AsyncGeneratorFunction' +} diff --git a/src/client/ClientError.ts b/src/client/ClientError.ts new file mode 100644 index 0000000..58addcb --- /dev/null +++ b/src/client/ClientError.ts @@ -0,0 +1,52 @@ +import { BaseError } from '../Errors.js' +import type * as Rpc from './Rpc.js' + +/** Error thrown by client transports. */ +export class ClientError extends BaseError { + override name = 'Incur.ClientError' + /** Machine-readable error code. */ + code: string | undefined + /** Full error envelope or diagnostic payload. */ + data: unknown | undefined + /** RPC error object. */ + error: Extract['error'] | undefined + /** Field validation errors. */ + fieldErrors: Extract['error']['fieldErrors'] | undefined + /** Response metadata. */ + meta: Rpc.Meta | undefined + /** Whether the operation can be retried. */ + retryable: boolean | undefined + /** HTTP status when available. */ + status: number | undefined + + constructor(message: string, options: ClientError.Options = {}) { + super(message, options.cause ? { cause: options.cause } : undefined) + this.code = options.code + this.data = options.data + this.error = options.error + this.fieldErrors = options.fieldErrors + this.meta = options.meta + this.retryable = options.retryable + this.status = options.status + } +} + +export declare namespace ClientError { + /** Client error constructor options. */ + type Options = BaseError.Options & { + /** Machine-readable error code. */ + code?: string | undefined + /** Full error envelope or diagnostic payload. */ + data?: unknown | undefined + /** RPC error object. */ + error?: Extract['error'] | undefined + /** Field validation errors. */ + fieldErrors?: Extract['error']['fieldErrors'] | undefined + /** Response metadata. */ + meta?: Rpc.Meta | undefined + /** Whether the operation can be retried. */ + retryable?: boolean | undefined + /** HTTP status when available. */ + status?: number | undefined + } +} diff --git a/src/client/Local.ts b/src/client/Local.ts new file mode 100644 index 0000000..de8405e --- /dev/null +++ b/src/client/Local.ts @@ -0,0 +1,54 @@ +import type * as SyncMcp from '../SyncMcp.js' +import type * as SyncSkills from '../SyncSkills.js' + +/** Options for `local.skills.add()`. */ +export type SkillsAddOptions = { + /** Grouping depth. */ + depth?: number | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Options for `local.skills.list()`. */ +export type SkillsListOptions = { + /** Grouping depth. */ + depth?: number | undefined +} + +/** Options for `local.mcp.add()`. */ +export type McpAddOptions = { + /** Target agents. */ + agents?: string[] | undefined + /** Command agents should run. */ + command?: string | undefined + /** Install globally instead of project-local. */ + global?: boolean | undefined +} + +/** Synced skills result. */ +export type SyncedSkills = SyncSkills.sync.Result + +/** Skills list result. */ +export type SkillsList = { + /** Listed skills. */ + skills: SyncSkills.list.Skill[] +} + +/** MCP registration result. */ +export type McpRegistration = SyncMcp.register.Result + +/** Memory-only local operations exposed by the memory transport. */ +export type Handler = { + /** 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/Resources.ts b/src/client/Resources.ts new file mode 100644 index 0000000..62fc641 --- /dev/null +++ b/src/client/Resources.ts @@ -0,0 +1,17 @@ +import type * as Formatter from '../Formatter.js' + +/** Resource request accepted by `transport.discover()`. */ +export type Request = + | { resource: 'llms'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'llmsFull'; command?: string | undefined; format?: Formatter.Format | undefined } + | { resource: 'schema'; command?: string | undefined } + | { resource: 'help'; command?: string | undefined } + | { resource: 'openapi'; format?: 'json' | 'yaml' | undefined } + | { resource: 'skillsIndex' } + | { resource: 'skill'; name: string } + | { resource: 'mcpTools' } + +/** Resource response returned by `transport.discover()`. */ +export type Response = + | { contentType: string; body: string } + | { contentType: string; data: unknown } diff --git a/src/client/Rpc.ts b/src/client/Rpc.ts new file mode 100644 index 0000000..5b2477d --- /dev/null +++ b/src/client/Rpc.ts @@ -0,0 +1,86 @@ +import type { FieldError } from '../Errors.js' +import type * as Formatter from '../Formatter.js' + +/** RPC request accepted by `transport.request()`. */ +export type Request = { + /** Canonical command ID. */ + command: string + /** Structured positional arguments. */ + args?: Record | undefined + /** Structured named options. */ + options?: Record | undefined + /** Output format for rendered text. */ + outputFormat?: Formatter.Format | undefined + /** Output selection paths. */ + selection?: string[] | undefined + /** Whether token metadata should be included. */ + outputTokenCount?: boolean | undefined + /** Maximum rendered output tokens to return. */ + outputTokenLimit?: number | undefined + /** Rendered output token offset. */ + outputTokenOffset?: number | undefined +} + +/** Rendered output payload. */ +export type Output = { + /** Rendered output text. */ + text: string + /** Rendered format. */ + format?: Formatter.Format | undefined + /** Offset to request for the next token window. */ + nextOffset?: number | undefined + /** Rendered token count before truncation. */ + tokenCount?: number | undefined + /** Requested token limit. */ + tokenLimit?: number | undefined + /** Requested token offset. */ + tokenOffset?: number | undefined + /** Whether text was truncated by token controls. */ + truncated?: boolean | undefined +} + +/** RPC response metadata. */ +export type Meta = { + /** Canonical command ID. */ + command: string + /** Suggested next commands. */ + cta?: unknown | undefined + /** Wall-clock duration. */ + duration: string +} + +/** Full RPC success/error envelope. */ +export type Envelope = + | { + ok: true + data: unknown + output?: Output | undefined + meta: Meta + } + | { + ok: false + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + } + meta: Meta + /** HTTP status when the response came from an HTTP transport. */ + status?: number | undefined + } + +/** Non-streaming RPC response. */ +export type Response = Envelope + +/** Streaming RPC record. */ +export type StreamRecord = + | { type: 'chunk'; data: unknown } + | ({ type: 'done' } & Extract) + | ({ type: 'error' } & Extract) + +/** Streaming RPC response. */ +export type StreamResponse = { + stream: true + records(): AsyncGenerator +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..577800e --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,7 @@ +export { ClientError } from './ClientError.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 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 new file mode 100644 index 0000000..cbe9a56 --- /dev/null +++ b/src/client/transports/HttpTransport.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, test, vi } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import * as HttpTransport from './HttpTransport.js' + +function resolve(fetch: typeof globalThis.fetch) { + return HttpTransport.create({ baseUrl: 'https://example.com/api/', fetch })() +} + +function connect(cli: Cli.Cli, options: Partial = {}) { + const requests: { input: RequestInfo | URL; init: RequestInit | undefined }[] = [] + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ input, init }) + return cli.fetch(new Request(input, init)) + } + return { + requests, + transport: HttpTransport.create({ + baseUrl: 'https://example.com/', + ...options, + fetch, + })(), + } +} + +function ndjson(lines: string[], options: { cancel?: () => void } = {}) { + const encoder = new TextEncoder() + const source: UnderlyingDefaultSource = { + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line)) + controller.close() + }, + } + if (options.cancel) source.cancel = options.cancel + return new Response(new ReadableStream(source), { + headers: { 'content-type': 'application/x-ndjson; charset=utf-8' }, + }) +} + +describe('HttpTransport', () => { + test('requests commands through the CLI HTTP route', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { requests, transport } = connect(cli, { headers: { 'x-custom': 'yes' } }) + + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { ok: true }, + }) + + const request = requests[0]! + expect(String(request.input)).toBe('https://example.com/_incur/rpc') + expect(request.init?.method).toBe('POST') + const headers = new Headers(request.init?.headers) + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('accept')).toBe('application/json, application/x-ndjson') + expect(headers.get('x-custom')).toBe('yes') + expect(JSON.parse(String(request.init?.body))).toEqual({ + command: 'status', + args: {}, + options: {}, + }) + }) + + test('sends args and options to the CLI HTTP route', async () => { + const cli = Cli.create('app').command('sum', { + args: z.object({ left: z.number(), right: z.number() }), + options: z.object({ label: z.string() }), + run(c) { + return { label: c.options.label, total: c.args.left + c.args.right } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'sum', + args: { left: 2, right: 3 }, + options: { label: 'result' }, + }), + ).resolves.toMatchObject({ + ok: true, + data: { label: 'result', total: 5 }, + }) + }) + + test('preserves rendered output metadata from JSON envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const { transport } = connect(cli) + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + + test('preserves HTTP status on failed RPC envelopes', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.request({ command: 'missing' })).resolves.toMatchObject({ + ok: false, + status: 404, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + }) + + test('wraps fetch rejection and rejects malformed JSON envelopes', async () => { + const failing = vi.fn(async () => { + throw new Error('offline') + }) as unknown as typeof globalThis.fetch + await expect(resolve(failing).request({ command: 'status' })).rejects.toThrow(ClientError) + + const invalidJson = vi.fn( + async () => new Response('nope', { headers: { 'content-type': 'application/json' } }), + ) as typeof globalThis.fetch + await expect(resolve(invalidJson).request({ command: 'status' })).rejects.toThrow( + 'Invalid RPC JSON', + ) + + const malformed = vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof globalThis.fetch + await expect(resolve(malformed).request({ command: 'status' })).rejects.toThrow( + 'Malformed RPC envelope', + ) + }) + + test('wraps discovery route errors with response metadata', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const { transport } = connect(cli) + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + code: 'SKILL_NOT_FOUND', + data: { + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, + ok: false, + }, + error: { code: 'SKILL_NOT_FOUND', message: "Unknown skill 'missing'." }, + message: expect.stringContaining("Unknown skill 'missing'."), + status: 404, + }) + }) + + test('preserves structured discovery error details', async () => { + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string', + path: 'command', + received: 'number', + }, + ], + message: 'Invalid discovery request.', + retryable: false, + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ), + ) as typeof globalThis.fetch + const transport = resolve(fetch) + + await expect(transport.discover({ resource: 'help' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + error: { code: 'VALIDATION_ERROR', message: 'Invalid discovery request.' }, + fieldErrors: [expect.objectContaining({ path: 'command' })], + retryable: false, + status: 400, + }) + }) + + test('streams records from the CLI HTTP route', async () => { + const cli = Cli.create('app').command('stream', { + async *run() { + yield { step: 1 } + yield { step: 2 } + }, + }) + const { transport } = connect(cli) + + const response = await transport.request({ command: 'stream' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { + type: 'done', + ok: true, + data: undefined, + meta: expect.objectContaining({ command: 'stream' }), + }, + ]) + }) + + test('parses split NDJSON records and rejects truncated streams', async () => { + const fetch = vi.fn(async () => + ndjson([ + '{"type":"chunk","data":{"a":', + '1}}\n\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}', + ]), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toEqual([ + { type: 'chunk', data: { a: 1 } }, + { type: 'done', ok: true, data: null, meta: { command: 'status', duration: '1ms' } }, + ]) + + const truncated = vi.fn(async () => + ndjson(['{"type":"chunk","data":1}\n']), + ) as typeof globalThis.fetch + const truncatedResponse = await resolve(truncated).request({ command: 'status' }) + if (!('stream' in truncatedResponse)) throw new Error('expected stream') + await expect(async () => { + for await (const _ of truncatedResponse.records()) { + } + }).rejects.toThrow('terminal record') + }) + + test('cancels the HTTP reader when the consumer stops early', async () => { + const cancel = vi.fn() + const fetch = vi.fn(async () => + ndjson( + [ + '{"type":"chunk","data":1}\n', + '{"type":"done","ok":true,"data":null,"meta":{"command":"status","duration":"1ms"}}\n', + ], + { cancel }, + ), + ) as typeof globalThis.fetch + const response = await resolve(fetch).request({ command: 'status' }) + if (!('stream' in response)) throw new Error('expected stream') + const iterator = response.records() + await iterator.next() + await iterator.return(undefined as any) + expect(cancel).toHaveBeenCalled() + }) + + test('discovers every resource through the CLI HTTP route', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, + }) + const { requests, transport } = connect(cli) + + const cases: { + request: Resources.Request + url: string + assert(response: Awaited>): void + }[] = [ + { + request: { resource: 'llms' }, + url: 'https://example.com/_incur/llms', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', command: 'status' }, + url: 'https://example.com/_incur/llms?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + url: 'https://example.com/_incur/llms?format=yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + url: 'https://example.com/_incur/llms-full', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + url: 'https://example.com/_incur/llms-full?command=status&format=json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, + url: 'https://example.com/_incur/llms-full?command=status&format=jsonl', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + url: 'https://example.com/_incur/schema', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + url: 'https://example.com/_incur/schema?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + url: 'https://example.com/_incur/help', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + url: 'https://example.com/_incur/help?command=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + url: 'https://example.com/openapi.json', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + url: 'https://example.com/openapi.yaml', + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + url: 'https://example.com/_incur/skills', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + url: 'https://example.com/_incur/skill?name=status', + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + url: 'https://example.com/_incur/mcp/tools', + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] + + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } + + expect(requests.map((request) => String(request.input))).toEqual(cases.map((item) => item.url)) + }) +}) diff --git a/src/client/transports/HttpTransport.ts b/src/client/transports/HttpTransport.ts new file mode 100644 index 0000000..c136c30 --- /dev/null +++ b/src/client/transports/HttpTransport.ts @@ -0,0 +1,241 @@ +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' +import type * as Transport from './Transport.js' + +/** HTTP transport factory. */ +export type HttpTransport = Transport.Factory< + 'http', + { + baseUrl: URL + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + } +> + +/** HTTP transport options. */ +export type Options = { + /** Base URL for the served CLI. */ + baseUrl: string | URL + /** Fetch implementation. Defaults to globalThis.fetch. */ + fetch?: typeof globalThis.fetch | undefined + /** Headers merged into every request. */ + headers?: HeadersInit | undefined +} + +/** Creates an HTTP transport. */ +export function create(options: Options): HttpTransport { + const fetcher = options.fetch ?? globalThis.fetch + if (!fetcher) throw new ClientError('No fetch implementation is available.') + const baseUrl = new URL(options.baseUrl) + + return () => ({ + config: { key: 'http', name: 'HTTP', type: 'http' }, + baseUrl, + async request(request) { + const response = await requestFetch(fetcher, url(baseUrl, '_incur/rpc'), { + method: 'POST', + headers: headers(options.headers, { + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }), + body: JSON.stringify({ + ...request, + args: request.args ?? {}, + options: request.options ?? {}, + }), + }) + return parseRpcResponse(response) + }, + async discover(request) { + const response = await requestFetch(fetcher, discoveryUrl(baseUrl, request), { + method: 'GET', + headers: headers(options.headers, { + accept: 'application/json, text/plain, text/markdown', + }), + }) + return parseDiscoverResponse(response) + }, + }) +} + +async function requestFetch(fetcher: typeof globalThis.fetch, input: URL, init: RequestInit) { + try { + return await fetcher(input, init) + } catch (error) { + throw new ClientError('RPC request failed', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } +} + +async function parseRpcResponse(response: Response): Promise { + const contentType = essence(response.headers.get('content-type') ?? '') + if (contentType === 'application/x-ndjson') { + if (!response.body) throw new ClientError('Streaming RPC response is missing a body.') + return streamResponse(response.body) + } + if (contentType !== 'application/json') throw new ClientError('RPC response was not JSON.') + const value = await parseJson(response) + if (!isEnvelope(value)) throw new ClientError('Malformed RPC envelope.') + if (!value.ok) return { ...value, status: response.status } + return value +} + +function streamResponse(body: ReadableStream): Rpc.StreamResponse { + return { + stream: true, + async *records() { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let terminal: Rpc.StreamRecord | undefined + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + for (const record of drainRecords(buffer)) { + buffer = record.rest + const parsed = parseRecord(record.line) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + } + const rest = buffer.trim() + if (rest) { + const parsed = parseRecord(rest) + terminal = parsed.type === 'done' || parsed.type === 'error' ? parsed : terminal + yield parsed + } + if (!terminal) throw new ClientError('RPC stream ended before a terminal record.') + return terminal + } finally { + await reader.cancel().catch(() => undefined) + } + }, + } +} + +function* drainRecords(buffer: string): Generator<{ line: string; rest: string }> { + let current = buffer + while (true) { + const index = current.indexOf('\n') + if (index === -1) return + const line = current.slice(0, index).trim() + current = current.slice(index + 1) + if (line) yield { line, rest: current } + } +} + +function parseRecord(line: string): Rpc.StreamRecord { + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + throw new ClientError('Invalid RPC stream JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } + if (!isRecord(value)) throw new ClientError('Malformed RPC stream record.') + return value +} + +async function parseJson(response: Response) { + try { + return JSON.parse(await response.text()) + } catch (error) { + throw new ClientError('Invalid RPC JSON.', { + cause: error instanceof Error ? error : undefined, + }) + } +} + +async function parseDiscoverResponse(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!response.ok) { + const data = contentType.includes('application/json') + ? await parseJson(response).catch(() => undefined) + : await response.text().catch(() => undefined) + const error = isErrorPayload(data) ? data.error : undefined + throw new ClientError(error?.message ?? 'Discover request failed.', { + code: error?.code, + data, + error, + fieldErrors: error?.fieldErrors, + retryable: error?.retryable, + status: response.status, + }) + } + if (contentType.includes('application/json')) + return { contentType: essence(contentType), data: await parseJson(response) } + return { contentType: essence(contentType), body: await response.text() } +} + +function discoveryUrl(baseUrl: URL, request: Resources.Request) { + const path = (() => { + if (request.resource === 'llms') return '_incur/llms' + if (request.resource === 'llmsFull') return '_incur/llms-full' + if (request.resource === 'schema') return '_incur/schema' + if (request.resource === 'help') return '_incur/help' + if (request.resource === 'mcpTools') return '_incur/mcp/tools' + if (request.resource === 'skillsIndex') return '_incur/skills' + if (request.resource === 'skill') return '_incur/skill' + if (request.resource === 'openapi' && request.format === 'yaml') return 'openapi.yaml' + return 'openapi.json' + })() + const target = url(baseUrl, path) + if ('command' in request && request.command) target.searchParams.set('command', request.command) + if ('format' in request && request.format && request.resource !== 'openapi') + target.searchParams.set('format', request.format) + if (request.resource === 'skill') target.searchParams.set('name', request.name) + return target +} + +function url(baseUrl: URL, path: string) { + const pathname = `${baseUrl.pathname.replace(/\/$/, '')}/${path}` + const target = new URL(baseUrl) + target.pathname = pathname + target.search = '' + return target +} + +function headers(custom: HeadersInit | undefined, required: Record) { + const result = new Headers(required) + if (custom) new Headers(custom).forEach((value, key) => result.set(key, value)) + return result +} + +function essence(value: string) { + return value.split(';', 1)[0]!.trim().toLowerCase() +} + +function isEnvelope(value: unknown): value is Rpc.Response { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { ok?: unknown }).ok === 'boolean' && + typeof (value as { meta?: { command?: unknown } }).meta?.command === 'string' + ) +} + +function isRecord(value: unknown): value is Rpc.StreamRecord { + return ( + typeof value === 'object' && + value !== null && + ((value as { type?: unknown }).type === 'chunk' || + ((value as { type?: unknown }).type === 'done' && isEnvelope(value)) || + ((value as { type?: unknown }).type === 'error' && isEnvelope(value))) + ) +} + +function isErrorPayload( + value: unknown, +): value is { error: Extract['error'] } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { error?: unknown }).error === 'object' && + (value as { error?: unknown }).error !== null + ) +} diff --git a/src/client/transports/MemoryTransport.test.ts b/src/client/transports/MemoryTransport.test.ts new file mode 100644 index 0000000..b0e3300 --- /dev/null +++ b/src/client/transports/MemoryTransport.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import { ResourcesError } from '../../internal/handlers/resources.js' +import { ClientError } from '../ClientError.js' +import type * as Resources from '../Resources.js' +import * as MemoryTransport from './MemoryTransport.js' + +describe('MemoryTransport', () => { + test('executes through shared runtime without calling cli.fetch and uses explicit env', async () => { + const cli = Cli.create('app', { + env: z.object({ TOKEN: z.string() }), + }).command('status', { + env: z.object({ TOKEN: z.string() }), + run(c) { + return { token: c.env.TOKEN } + }, + }) + cli.fetch = async () => { + throw new Error('fetch should not be called') + } + + const transport = MemoryTransport.create(cli, { env: { TOKEN: 'secret' } })() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { token: 'secret' }, + }) + }) + + test('does not load config defaults for in-process requests', async () => { + const cli = Cli.create('app', { config: {} }).command('status', { + options: z.object({ name: z.string().default('runtime') }), + run(c) { + return c.options + }, + }) + const transport = MemoryTransport.create(cli)() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { name: 'runtime' }, + }) + }) + + test('preserves CLI version for in-process execution', async () => { + const cli = Cli.create('app', { version: '1.2.3' }).command('status', { + run(c) { + return { version: c.version } + }, + }) + const transport = MemoryTransport.create(cli)() + await expect(transport.request({ command: 'status' })).resolves.toMatchObject({ + ok: true, + data: { version: '1.2.3' }, + }) + }) + + test('preserves rendered output metadata for in-process execution', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect( + transport.request({ + command: 'status', + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 1, + outputTokenOffset: 1, + }), + ).resolves.toMatchObject({ + ok: true, + output: { + format: 'json', + nextOffset: expect.any(Number), + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 1, + truncated: true, + }, + }) + }) + + test('discovers every resource in process', async () => { + const cli = Cli.create('app', { description: 'App', version: '1.2.3' }).command('status', { + description: 'Show status', + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose, version: c.version } + }, + }) + const transport = MemoryTransport.create(cli)() + const cases: { + request: Resources.Request + assert(response: Awaited>): void + }[] = [ + { + request: { resource: 'llms' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + }, + }, + { + request: { resource: 'llms', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(yamlParse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [{ name: 'status', description: 'Show status' }], + }) + }, + }, + { + request: { resource: 'llmsFull' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('## Arguments'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('`id`') }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'json' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'llmsFull', command: 'status', format: 'jsonl' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('text/plain') + expect(JSON.parse(response.body)).toMatchObject({ + version: 'incur.v1', + commands: [ + { + name: 'status', + description: 'Show status', + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }, + ], + }) + }, + }, + { + request: { resource: 'schema' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: [ + { + name: 'status', + schema: { + args: { properties: { id: { type: 'string' } } }, + options: { properties: { verbose: { default: false, type: 'boolean' } } }, + }, + }, + ], + }, + }) + }, + }, + { + request: { resource: 'schema', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + }, + }) + }, + }, + { + request: { resource: 'help' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Commands:'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('status') }) + }, + }, + { + request: { resource: 'help', command: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/plain', + body: expect.stringContaining('Usage: status [options]'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('--verbose') }) + }, + }, + { + request: { resource: 'openapi' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }, + }) + }, + }, + { + request: { resource: 'openapi', format: 'yaml' }, + assert(response) { + if (!('body' in response)) throw new Error('expected body') + expect(response.contentType).toBe('application/yaml') + expect(yamlParse(response.body)).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }, + }, + { + request: { resource: 'skillsIndex' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + skills: [ + { + name: 'status', + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + }, + ], + }, + }) + }, + }, + { + request: { resource: 'skill', name: 'status' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('# app status'), + }) + expect(response).toMatchObject({ body: expect.stringContaining('## Arguments') }) + expect(response).toMatchObject({ body: expect.stringContaining('## Options') }) + }, + }, + { + request: { resource: 'mcpTools' }, + assert(response) { + expect(response).toMatchObject({ + contentType: 'application/json', + data: { + tools: [ + { + name: 'status', + description: 'Show status', + inputSchema: { + properties: { + id: expect.any(Object), + verbose: expect.any(Object), + }, + }, + }, + ], + }, + }) + }, + }, + ] + + for (const item of cases) { + const response = await transport.discover(item.request) + item.assert(response) + } + }) + + test('discovery reuses CLI manifest and skill projection behavior', async () => { + const cli = Cli.create('app', { description: 'App' }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string() }), + examples: [ + { + args: { id: '123' }, + options: { verbose: true }, + description: 'Verbose status', + }, + ], + run(c) { + return { id: c.args.id } + }, + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { name: 'status', description: 'Show status' }, + ], + }, + }) + + const full = await transport.discover({ resource: 'llmsFull', format: 'json' }) + expect(full).toMatchObject({ + contentType: 'application/json', + data: { + commands: [ + { name: 'api', description: 'Proxy API' }, + { + name: 'status', + description: 'Show status', + examples: [ + { + command: 'status 123 --verbose true', + description: 'Verbose status', + }, + ], + schema: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + ], + }, + }) + + const schema = await transport.discover({ resource: 'schema', command: 'status' }) + expect(schema).toMatchObject({ + data: { + output: { properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }) + + const markdown = await transport.discover({ resource: 'llmsFull' }) + if (!('body' in markdown)) throw new Error('expected markdown body') + expect(markdown.body).toContain('Verbose status') + expect(markdown.body).toContain('## Output') + expect(markdown.body).toContain('Fetch gateway. Pass path segments') + expect(markdown.body).not.toMatch(/^# app st$/m) + }) + + test('wraps discovery failures as client errors with internal cause', async () => { + const cli = Cli.create('app').command('status', { + run() { + return { ok: true } + }, + }) + const transport = MemoryTransport.create(cli)() + + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toMatchObject({ + cause: expect.any(ResourcesError), + code: 'SKILL_NOT_FOUND', + message: expect.stringContaining('Discover request failed.'), + status: 404, + }) + await expect(transport.discover({ resource: 'skill', name: 'missing' })).rejects.toThrow( + ClientError, + ) + }) + + test('exposes memory-only local capability', async () => { + const cli = Cli.create('app', { description: 'App' }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const transport = MemoryTransport.create(cli)() + expect(Object.keys(transport.local)).toEqual(['skills', 'mcp']) + expect(typeof transport.local.skills.add).toBe('function') + expect(typeof transport.local.skills.list).toBe('function') + expect(typeof transport.local.mcp.add).toBe('function') + await expect(transport.local.skills.list()).resolves.toEqual({ + skills: [expect.objectContaining({ installed: false, name: 'app-status' })], + }) + }) +}) diff --git a/src/client/transports/MemoryTransport.ts b/src/client/transports/MemoryTransport.ts new file mode 100644 index 0000000..1fae7a0 --- /dev/null +++ b/src/client/transports/MemoryTransport.ts @@ -0,0 +1,84 @@ +import * as Cli from '../../Cli.js' +import { createLocalHandler } from '../../internal/handlers/local.js' +import { createResourcesHandler } from '../../internal/handlers/resources.js' +import { createRpcHandler } from '../../internal/handlers/rpc.js' +import * as RuntimeContext from '../../internal/runtime-context.js' +import { ClientError } from '../ClientError.js' +import type * as Local from '../Local.js' +import type * as Resources from '../Resources.js' +import type * as Rpc from '../Rpc.js' +import type * as Transport from './Transport.js' + +/** Memory transport factory. */ +export type MemoryTransport = Transport.Factory< + 'memory', + { + request(request: Rpc.Request): Promise + discover(request: Resources.Request): Promise + local: Local.Handler + } +> + +/** Memory transport options. */ +export type Options = { + /** Explicit environment source. */ + env?: Record | undefined +} + +/** Creates an in-process memory transport. */ +export function create(cli: Cli.Cli, options: Options = {}): MemoryTransport { + return () => { + const ctx = RuntimeContext.fromCli(cli) + const { request } = createRpcHandler(ctx, { env: options.env }) + const { discover } = createResourcesHandler(ctx) + const { local } = createLocalHandler(ctx) + return { + config: { key: 'memory', name: 'Memory', type: 'memory' }, + request, + async discover(request) { + try { + return await discover(request) + } catch (error) { + throw toClientError('Discover request failed.', error) + } + }, + local: { + skills: { + async add(options) { + try { + return await local.skills.add(options) + } catch (error) { + throw toClientError('Local skills sync failed.', error) + } + }, + async list(options) { + try { + return await local.skills.list(options) + } catch (error) { + throw toClientError('Local skills list failed.', error) + } + }, + }, + mcp: { + async add(options) { + try { + return await local.mcp.add(options) + } catch (error) { + throw toClientError('Local MCP registration failed.', error) + } + }, + }, + }, + } + } +} + +function toClientError(message: string, error: unknown) { + if (error instanceof ClientError) return error + const cause = error instanceof Error ? error : new Error(String(error)) + return new ClientError(message, { + cause, + code: 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined, + status: 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined, + }) +} diff --git a/src/client/transports/Transport.ts b/src/client/transports/Transport.ts new file mode 100644 index 0000000..f91a989 --- /dev/null +++ b/src/client/transports/Transport.ts @@ -0,0 +1,20 @@ +/** Transport type names. */ +export type TransportType = 'http' | 'memory' + +/** Transport configuration. */ +export type Config = { + /** Stable transport key. */ + key: string + /** Human-readable transport name. */ + name: string + /** Transport type. */ + type: type +} + +/** Transport capabilities exposed by a resolved transport. */ +export type Capabilities = Record + +/** Transport factory. */ +export type Factory = () => { + config: Config +} & capabilities diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 61bbb4d..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: {} } } } } @@ -2833,6 +2832,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 +2851,7 @@ describe('fetch api', () => { { "meta": { "command": "stream", + "duration": "", }, "ok": true, "type": "done", diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..795f481 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -81,12 +81,20 @@ export async function execute(command: any, options: execute.Options): Promise + options?: Record + } + args = command.args ? Parser.zodParse(command.args, input.args ?? {}) : {} + parsedOptions = command.options ? Parser.zodParse(command.options, input.options ?? {}) : {} } // Parse env @@ -128,7 +136,7 @@ export async function execute(command: any, options: execute.Options): Promise + return yield* raw as AsyncGenerator } finally { resolveStreamConsumed!() } @@ -296,8 +304,9 @@ export declare namespace execute { * - `'argv'` (default): parse both args and options from argv tokens (CLI mode) * - `'split'`: args from argv, options from inputOptions (HTTP mode) * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode) + * - `'structured'`: inputOptions contains separate args/options objects (RPC mode) */ - parseMode?: 'argv' | 'split' | 'flat' | undefined + parseMode?: 'argv' | 'split' | 'flat' | 'structured' | undefined /** The resolved command path. */ path: string /** Vars schema for middleware variables. */ diff --git a/src/internal/handlers/local.test.ts b/src/internal/handlers/local.test.ts new file mode 100644 index 0000000..a62cc7d --- /dev/null +++ b/src/internal/handlers/local.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import * as Cli from '../../Cli.js' +import * as RuntimeContext from '../runtime-context.js' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + register: vi.fn(), + sync: vi.fn(), +})) + +vi.mock('../../SyncSkills.js', () => ({ + list: mocks.list, + sync: mocks.sync, +})) + +vi.mock('../../SyncMcp.js', () => ({ + register: mocks.register, +})) + +import { createLocalHandler, LocalError } from './local.js' + +function createFixture() { + const cli = Cli.create('app', { + description: 'App CLI', + mcp: { agents: ['claude-code'], command: 'pnpm app --mcp' }, + sync: { + cwd: '/workspace/app', + depth: 2, + include: ['skills/*'], + suggestions: ['Run app status'], + }, + }).command('status', { + description: 'Show status', + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + return { ctx, local: createLocalHandler(ctx).local } +} + +beforeEach(() => { + mocks.list.mockReset() + mocks.register.mockReset() + mocks.sync.mockReset() +}) + +describe('createLocalHandler', () => { + test('skills.add delegates to sync with context defaults', async () => { + const { ctx, local } = createFixture() + const result = { + agents: [{ agent: 'codex', path: '/agents/codex/app' }], + paths: ['/skills/app'], + skills: [{ description: 'App CLI', name: 'app' }], + } + mocks.sync.mockResolvedValueOnce(result) + + await expect(local.skills.add()).resolves.toBe(result) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + global: true, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add options override sync defaults', async () => { + const { ctx, local } = createFixture() + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add({ depth: 4, global: false }) + expect(mocks.sync).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 4, + description: 'App CLI', + global: false, + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.add defaults depth to 1 and global to true when context has no sync defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const ctx = RuntimeContext.fromCli(cli) + const { local } = createLocalHandler(ctx) + mocks.sync.mockResolvedValueOnce({ agents: [], paths: [], skills: [] }) + + await local.skills.add() + expect(mocks.sync).toHaveBeenCalledWith('bare', ctx.commands, { + cwd: undefined, + depth: 1, + description: undefined, + global: true, + include: undefined, + rootCommand: undefined, + }) + }) + + test('skills.add wraps sync failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('disk full') + mocks.sync.mockRejectedValueOnce(cause) + + try { + await local.skills.add() + throw new Error('expected local.skills.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'disk full', + name: 'Incur.LocalError', + shortMessage: 'Failed to sync local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('skills.list delegates to list and wraps the array result', async () => { + const { ctx, local } = createFixture() + const skills = [{ description: 'Show status', installed: true, name: 'app-status' }] + mocks.list.mockResolvedValueOnce(skills) + + await expect(local.skills.list()).resolves.toEqual({ skills }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 2, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list option depth overrides context depth', async () => { + const { ctx, local } = createFixture() + mocks.list.mockResolvedValueOnce([]) + + await local.skills.list({ depth: 5 }) + expect(mocks.list).toHaveBeenCalledWith('app', ctx.commands, { + cwd: '/workspace/app', + depth: 5, + description: 'App CLI', + include: ['skills/*'], + rootCommand: undefined, + }) + }) + + test('skills.list wraps list failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('bad glob') + mocks.list.mockRejectedValueOnce(cause) + + try { + await local.skills.list() + throw new Error('expected local.skills.list to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'bad glob', + name: 'Incur.LocalError', + shortMessage: 'Failed to list local skills.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('mcp.add delegates to register with context defaults', async () => { + const { local } = createFixture() + const result = { agents: ['Claude Code'], command: 'pnpm app --mcp' } + mocks.register.mockResolvedValueOnce(result) + + await expect(local.mcp.add()).resolves.toBe(result) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['claude-code'], + command: 'pnpm app --mcp', + global: true, + }) + }) + + test('mcp.add options override context defaults', async () => { + const { local } = createFixture() + mocks.register.mockResolvedValueOnce({ agents: ['Cursor'], command: 'node app.js --mcp' }) + + await local.mcp.add({ + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + expect(mocks.register).toHaveBeenCalledWith('app', { + agents: ['cursor'], + command: 'node app.js --mcp', + global: false, + }) + }) + + test('mcp.add defaults global to true without context defaults', async () => { + const cli = Cli.create('bare').command('status', { + run() { + return { ok: true } + }, + }) + const { local } = createLocalHandler(RuntimeContext.fromCli(cli)) + mocks.register.mockResolvedValueOnce({ agents: [], command: 'pnpm bare --mcp' }) + + await local.mcp.add() + expect(mocks.register).toHaveBeenCalledWith('bare', { + agents: undefined, + command: undefined, + global: true, + }) + }) + + test('mcp.add wraps register failures in LocalError', async () => { + const { local } = createFixture() + const cause = new Error('missing runner') + mocks.register.mockRejectedValueOnce(cause) + + try { + await local.mcp.add() + throw new Error('expected local.mcp.add to fail') + } catch (error) { + expect(error).toBeInstanceOf(LocalError) + expect(error).toMatchObject({ + details: 'missing runner', + name: 'Incur.LocalError', + shortMessage: 'Failed to register local MCP server.', + }) + expect((error as Error).cause).toBe(cause) + } + }) + + test('LocalError exposes a stable name', () => { + expect(new LocalError('Nope')).toMatchObject({ + message: 'Nope', + name: 'Incur.LocalError', + }) + }) +}) diff --git a/src/internal/handlers/local.ts b/src/internal/handlers/local.ts new file mode 100644 index 0000000..d9c769f --- /dev/null +++ b/src/internal/handlers/local.ts @@ -0,0 +1,67 @@ +import type * as Local from '../../client/Local.js' +import { BaseError } from '../../Errors.js' +import * as SyncMcp from '../../SyncMcp.js' +import * as SyncSkills from '../../SyncSkills.js' +import type * as RuntimeContext from '../runtime-context.js' + +/** Local setup/admin failure. */ +export class LocalError extends BaseError { + override name = 'Incur.LocalError' +} + +/** Creates the shared in-process local handler. */ +export function createLocalHandler(ctx: RuntimeContext.RuntimeCliContext) { + return { + local: { + skills: { + async add(options: Local.SkillsAddOptions = {}) { + try { + return await SyncSkills.sync(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + global: options.global ?? true, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + } catch (error) { + throw new LocalError('Failed to sync local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + async list(options: Local.SkillsListOptions = {}) { + try { + const skills = await SyncSkills.list(ctx.name, ctx.commands, { + cwd: ctx.sync?.cwd, + depth: options.depth ?? ctx.sync?.depth ?? 1, + description: ctx.description, + include: ctx.sync?.include, + rootCommand: ctx.rootCommand, + }) + return { skills } + } catch (error) { + throw new LocalError('Failed to list local skills.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + }, + mcp: { + async add(options: Local.McpAddOptions = {}) { + try { + return await SyncMcp.register(ctx.name, { + agents: options.agents ?? ctx.mcp?.agents, + command: options.command ?? ctx.mcp?.command, + global: options.global ?? true, + }) + } catch (error) { + throw new LocalError('Failed to register local MCP server.', { + cause: error instanceof Error ? error : new Error(String(error)), + }) + } + }, + }, + }, + } +} diff --git a/src/internal/handlers/resources.test.ts b/src/internal/handlers/resources.test.ts new file mode 100644 index 0000000..ed10c2f --- /dev/null +++ b/src/internal/handlers/resources.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, test } from 'vitest' +import { parse as yamlParse } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import * as RuntimeContext from '../runtime-context.js' +import { createResourcesHandler, ResourcesError } from './resources.js' + +function createFixture() { + const project = Cli.create('project', { description: 'Project commands' }) + .command('list', { + description: 'List projects', + args: z.object({ org: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ projects: z.array(z.object({ id: z.string() })) }), + run() { + return { projects: [{ id: 'p1' }] } + }, + }) + .command('empty', { + description: 'Empty schema command', + run() { + return { ok: true } + }, + }) + + const cli = Cli.create('app', { + description: 'App CLI', + version: '1.2.3', + args: z.object({ workspace: z.string().optional() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ ok: z.boolean() }), + run() { + return { ok: true } + }, + }) + .command('status', { + description: 'Show status', + aliases: ['st'], + args: z.object({ id: z.string() }), + options: z.object({ verbose: z.boolean().default(false) }), + output: z.object({ id: z.string(), verbose: z.boolean() }), + examples: [ + { + args: { id: '123' }, + description: 'Verbose status', + options: { verbose: true }, + }, + ], + hint: 'Use status wisely', + env: z.object({ TOKEN: z.string().optional() }), + run(c) { + return { id: c.args.id, verbose: c.options.verbose } + }, + }) + .command(project) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) + + return createResourcesHandler(RuntimeContext.fromCli(cli)) +} + +async function body(response: Resources.Response) { + if (!('body' in response)) throw new Error('expected body response') + return response.body +} + +async function data(response: Resources.Response) { + if (!('data' in response)) throw new Error('expected data response') + return response.data +} + +describe('createResourcesHandler', () => { + test('rejects invalid requests, unknown scopes, fetch scopes, and unsafe skill names', async () => { + const { discover } = createFixture() + const cases: { + request: unknown + code: string + status: number + }[] = [ + { request: {}, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 1 }, code: 'VALIDATION_ERROR', status: 400 }, + { request: { resource: 'help', command: 'missing' }, code: 'COMMAND_NOT_FOUND', status: 404 }, + { request: { resource: 'schema', command: 'api' }, code: 'FETCH_GATEWAY', status: 400 }, + { + request: { resource: 'skill', name: '../status' }, + code: 'INVALID_SKILL_NAME', + status: 400, + }, + { request: { resource: 'skill', name: 'missing' }, code: 'SKILL_NOT_FOUND', status: 404 }, + ] + + for (const item of cases) + await expect(discover(item.request)).rejects.toMatchObject({ + code: item.code, + name: 'Incur.ResourcesError', + status: item.status, + }) + }) + + test('returns llms resources across root, group, leaf, and non-markdown formats', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'llms' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + await expect(discover({ resource: 'llms', command: 'project' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app project project list ` | List projects |'), + }) + await expect(discover({ resource: 'llms', command: 'status' })).resolves.toMatchObject({ + contentType: 'text/markdown', + body: expect.stringContaining('| `app status ` | Show status |'), + }) + + await expect(discover({ resource: 'llms', format: 'json' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api', description: 'Proxy API' }), + expect.objectContaining({ name: 'project list', description: 'List projects' }), + expect.objectContaining({ + name: 'project empty', + description: 'Empty schema command', + }), + expect.objectContaining({ name: 'status', description: 'Show status' }), + ]), + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'llms', format: 'yaml' }))) + expect(yaml).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + + const jsonl = JSON.parse(await body(await discover({ resource: 'llms', format: 'jsonl' }))) + expect(jsonl).toMatchObject({ + version: 'incur.v1', + commands: expect.arrayContaining([ + expect.objectContaining({ name: 'api' }), + expect.objectContaining({ name: 'project list' }), + expect.objectContaining({ name: 'project empty' }), + expect.objectContaining({ name: 'status' }), + ]), + }) + }) + + test('returns full manifests with schemas, examples, output, and fetch gateway guidance', async () => { + const { discover } = createFixture() + const full = await discover({ resource: 'llmsFull', format: 'json' }) + const manifest = await data(full) + const commands = (manifest as { commands: any[] }).commands + + expect(full).toMatchObject({ + contentType: 'application/json', + data: { version: 'incur.v1' }, + }) + expect(commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + expect(commands.find((command) => command.name === 'api')).toMatchObject({ + description: 'Proxy API', + }) + expect(commands.find((command) => command.name === 'project list')).toMatchObject({ + schema: { + args: { properties: { org: { type: 'string' } }, required: ['org'] }, + output: { properties: { projects: { type: 'array' } }, required: ['projects'] }, + }, + }) + expect(commands.find((command) => command.name === 'project empty')).toMatchObject({ + description: 'Empty schema command', + }) + expect(commands.find((command) => command.name === 'status')).toMatchObject({ + examples: [{ command: 'status 123 --verbose true', description: 'Verbose status' }], + schema: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { + properties: { verbose: { default: false, type: 'boolean' } }, + required: ['verbose'], + }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + + const markdown = await body(await discover({ resource: 'llmsFull' })) + expect(markdown).toContain('Verbose status') + expect(markdown).toContain('## Output') + expect(markdown).toContain('Fetch gateway. Pass path segments') + expect(markdown).not.toMatch(/^# app st$/m) + }) + + test('returns schemas for root, group, leaf, and schemaless commands', async () => { + const { discover } = createFixture() + const rootSchema = await data(await discover({ resource: 'schema' })) + + expect((rootSchema as { commands: any[] }).commands.map((command) => command.name)).toEqual([ + 'api', + 'project empty', + 'project list', + 'status', + ]) + await expect(discover({ resource: 'schema', command: 'project' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { commands: [{ name: 'project empty' }, { name: 'project list' }] }, + }) + await expect(discover({ resource: 'schema', command: 'status' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + args: { properties: { id: { type: 'string' } }, required: ['id'] }, + output: { + properties: { id: { type: 'string' }, verbose: { type: 'boolean' } }, + required: ['id', 'verbose'], + }, + }, + }) + await expect(discover({ resource: 'schema', command: 'project empty' })).resolves.toEqual({ + contentType: 'application/json', + data: {}, + }) + }) + + test('returns help for root, group, and leaf command scopes', async () => { + const { discover } = createFixture() + + expect(await body(await discover({ resource: 'help' }))).toContain('Commands:') + expect(await body(await discover({ resource: 'help', command: 'project' }))).toContain('list') + const help = await body(await discover({ resource: 'help', command: 'status' })) + expect(help).toContain('Usage: status [options]') + expect(help).toContain('--verbose') + expect(help).toContain('TOKEN') + }) + + test('returns OpenAPI JSON and YAML with CLI metadata', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'openapi' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { + '/': { post: expect.any(Object) }, + '/status/{id}': { get: expect.any(Object) }, + '/project/list/{org}': { get: expect.any(Object) }, + }, + }, + }) + + const yaml = yamlParse(await body(await discover({ resource: 'openapi', format: 'yaml' }))) + expect(yaml).toMatchObject({ + openapi: '3.2.0', + info: { title: 'app', version: '1.2.3' }, + paths: { '/status/{id}': { get: expect.any(Object) } }, + }) + }) + + test('returns skills index, individual skill markdown, and MCP tools', async () => { + const { discover } = createFixture() + + await expect(discover({ resource: 'skillsIndex' })).resolves.toMatchObject({ + contentType: 'application/json', + data: { + skills: expect.arrayContaining([ + { + description: 'App CLI. Run `app --help` for usage details.', + files: ['SKILL.md'], + name: 'app', + }, + { + description: 'Show status. Run `app status --help` for usage details.', + files: ['SKILL.md'], + name: 'status', + }, + ]), + }, + }) + + const rootSkill = await body(await discover({ resource: 'skill', name: 'app' })) + expect(rootSkill).toContain('# app') + expect(rootSkill).toContain('## Arguments') + expect(rootSkill).toContain('## Output') + + const statusSkill = await body(await discover({ resource: 'skill', name: 'status' })) + expect(statusSkill).toContain('# app status') + expect(statusSkill).toContain('## Arguments') + expect(statusSkill).toContain('## Options') + + const tools = (await data(await discover({ resource: 'mcpTools' }))) as { tools: any[] } + expect(tools.tools.map((tool) => tool.name)).toEqual([ + 'api', + 'project_empty', + 'project_list', + 'status', + ]) + expect(tools.tools.find((tool) => tool.name === 'status')).toMatchObject({ + description: 'Show status', + inputSchema: { + properties: { + id: { type: 'string' }, + verbose: { default: false, type: 'boolean' }, + }, + }, + outputSchema: { + properties: { + id: { type: 'string' }, + verbose: { type: 'boolean' }, + }, + }, + }) + }) + + test('ResourcesError exposes stable metadata', () => { + const error = new ResourcesError('NOPE', 'Nope.', 418) + expect(error).toMatchObject({ + code: 'NOPE', + message: 'Nope.', + name: 'Incur.ResourcesError', + status: 418, + }) + }) +}) diff --git a/src/internal/handlers/resources.ts b/src/internal/handlers/resources.ts new file mode 100644 index 0000000..4885acb --- /dev/null +++ b/src/internal/handlers/resources.ts @@ -0,0 +1,214 @@ +import { stringify as yamlStringify } from 'yaml' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import type * as Resources from '../../client/Resources.js' +import { BaseError } from '../../Errors.js' +import * as Formatter from '../../Formatter.js' +import * as Help from '../../Help.js' +import * as Mcp from '../../Mcp.js' +import * as Openapi from '../../Openapi.js' +import * as Skill from '../../Skill.js' +import * as RuntimeContext from '../runtime-context.js' + +/** Resources failure with protocol code and HTTP status metadata. */ +export class ResourcesError extends BaseError { + override name = 'Incur.ResourcesError' + /** Machine-readable error code. */ + code: string + /** HTTP status for discovery routes. */ + status: number + + constructor(code: string, message: string, status: number) { + super(message) + this.code = code + this.status = status + } +} + +const requestSchema = z.discriminatedUnion('resource', [ + z.object({ + resource: z.literal('llms'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ + resource: z.literal('llmsFull'), + command: z.string().optional(), + format: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + }), + z.object({ resource: z.literal('schema'), command: z.string().optional() }), + z.object({ resource: z.literal('help'), command: z.string().optional() }), + z.object({ resource: z.literal('openapi'), format: z.enum(['json', 'yaml']).optional() }), + z.object({ resource: z.literal('skillsIndex') }), + z.object({ resource: z.literal('skill'), name: z.string() }), + z.object({ resource: z.literal('mcpTools') }), +]) + +/** Creates the shared in-process resources handler. */ +export function createResourcesHandler(ctx: RuntimeContext.RuntimeCliContext) { + return { + async discover(request: unknown): Promise { + const parsedRequest = requestSchema.safeParse(request) + if (!parsedRequest.success) + throw new ResourcesError('VALIDATION_ERROR', 'Invalid discovery request.', 400) + const parsed = parsedRequest.data + if (parsed.resource === 'openapi') { + const spec = openapi(ctx) + if (parsed.format === 'yaml') + return { contentType: 'application/yaml', body: yamlStringify(spec) } + return { contentType: 'application/json', data: spec } + } + if (parsed.resource === 'mcpTools') + return { + contentType: 'application/json', + data: { tools: Mcp.collectTools(ctx.commands, []) }, + } + + if (parsed.resource === 'skillsIndex' || parsed.resource === 'skill') { + const { files } = skills(ctx) + if (parsed.resource === 'skillsIndex') { + return { + contentType: 'application/json', + data: { + skills: files.map((file) => { + const meta = Cli.parseSkillFrontmatter(file.content) + return { + name: file.dir || ctx.name, + description: meta.description ?? '', + files: ['SKILL.md'], + } + }), + }, + } + } + if (!safeSkillName(parsed.name)) + throw new ResourcesError('INVALID_SKILL_NAME', 'Unsafe skill name.', 400) + const file = files.find((value) => (value.dir || ctx.name) === parsed.name) + if (!file) + throw new ResourcesError('SKILL_NOT_FOUND', `Unknown skill '${parsed.name}'.`, 404) + return { contentType: 'text/markdown', body: file.content } + } + + const scoped = scope(ctx, parsed.command) + if (parsed.resource === 'help') { + if (scoped.type === 'command') + return { + contentType: 'text/plain', + body: Help.formatCommand(scoped.id, { + alias: scoped.command.alias, + args: scoped.command.args, + description: scoped.command.description, + env: scoped.command.env, + examples: [], + hint: scoped.command.hint, + options: scoped.command.options, + usage: [], + }), + } + return { + contentType: 'text/plain', + body: Help.formatRoot(scoped.id, { + description: scoped.description, + commands: Cli.buildIndexManifest(scoped.commands, []).commands.map( + ({ name, description }) => ({ + name, + ...(description ? { description } : undefined), + }), + ), + }), + } + } + + if (parsed.resource === 'schema') { + if (scoped.type === 'command') { + const schema = Cli.buildCommandSchema(scoped.command) + return { contentType: 'application/json', data: schema ?? {} } + } + return { + contentType: 'application/json', + data: Cli.buildManifest(scoped.commands, scoped.prefix), + } + } + + const full = parsed.resource === 'llmsFull' + const format = parsed.format ?? 'md' + const data = full + ? Cli.buildManifest(scoped.commands, scoped.prefix) + : Cli.buildIndexManifest(scoped.commands, scoped.prefix) + if (format === 'json') return { contentType: 'application/json', data } + if (format === 'md') { + const groups = new Map() + const entries = Cli.collectSkillCommands( + scoped.commands, + scoped.prefix, + groups, + scoped.rootCommand, + ) + const name = scoped.prefix.length > 0 ? `${ctx.name} ${scoped.prefix.join(' ')}` : ctx.name + const body = full + ? Skill.generate(name, entries, groups) + : Skill.index(name, entries, scoped.description) + return { contentType: 'text/markdown', body } + } + return { + contentType: 'text/plain', + body: Formatter.format(data, format), + } + }, + } +} + +function scope(ctx: RuntimeContext.RuntimeCliContext, command: string | undefined) { + if (!command) + return { + type: 'group' as const, + id: ctx.name, + commands: ctx.commands, + prefix: [] as string[], + rootCommand: ctx.rootCommand, + description: ctx.description, + } + const resolved = RuntimeContext.resolveCanonical(ctx, command) + if ('error' in resolved) + throw new ResourcesError('COMMAND_NOT_FOUND', `Unknown command '${command}'.`, 404) + if ('gateway' in resolved) + throw new ResourcesError('FETCH_GATEWAY', `'${command}' is a raw fetch gateway.`, 400) + if ('commands' in resolved) + return { + type: 'group' as const, + id: resolved.id, + commands: resolved.commands, + prefix: resolved.id.split(' '), + rootCommand: undefined, + description: resolved.description, + } + return { + type: 'command' as const, + id: resolved.id, + command: resolved.command, + commands: new Map([[resolved.id.split(' ').at(-1)!, resolved.command]]), + prefix: resolved.id.split(' ').slice(0, -1), + rootCommand: undefined, + description: resolved.command.description, + } +} + +function openapi(ctx: RuntimeContext.RuntimeCliContext) { + const cli = { name: ctx.name, description: ctx.description } as any + Cli.toCommands.set(cli, ctx.commands as any) + if (ctx.rootCommand) Cli.toRootDefinition.set(cli as Cli.Root, ctx.rootCommand as any) + return Openapi.fromCli(Object.assign(cli, { env: ctx.env, vars: ctx.vars }), { + ...(ctx.version !== undefined ? { version: ctx.version } : undefined), + }) +} + +function skills(ctx: RuntimeContext.RuntimeCliContext) { + const groups = new Map() + const entries = Cli.collectSkillCommands(ctx.commands, [], groups, ctx.rootCommand) + return { files: Skill.split(ctx.name, entries, 1, groups) } +} + +function safeSkillName(name: string) { + return name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '..' +} diff --git a/src/internal/handlers/rpc.test.ts b/src/internal/handlers/rpc.test.ts new file mode 100644 index 0000000..c60d025 --- /dev/null +++ b/src/internal/handlers/rpc.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../../Cli.js' +import * as Formatter from '../../Formatter.js' +import * as RuntimeContext from '../runtime-context.js' +import { createRpcHandler, getRpcStatus } from './rpc.js' + +function createFixture() { + const order: string[] = [] + const child = Cli.create('child', { + args: z.object({ id: z.string() }), + options: z.object({ loud: z.boolean().default(false) }), + run(c) { + order.push(`child:${c.agent}:${c.args.id}:${c.options.loud}:${c.env.TOKEN}`) + return c.ok({ id: c.args.id, loud: c.options.loud }, { cta: { commands: ['next'] } }) + }, + env: z.object({ TOKEN: z.string() }), + }) + + const router = Cli.create('project') + router.use(async (_, next) => { + order.push('group:before') + await next() + order.push('group:after') + }) + router.command('list', { + args: z.object({ projectId: z.string() }), + options: z.object({ limit: z.number().default(10) }), + output: z.object({ items: z.array(z.object({ id: z.string() })) }), + run(c) { + order.push(`run:${c.args.projectId}:${c.options.limit}:${(c.var as { root: string }).root}`) + return { items: [{ id: 'a' }, { id: 'b' }] } + }, + }) + router.command('stream', { + async *run(c) { + try { + yield { step: 1 } + yield { step: 2 } + return c.ok({ done: true }, { cta: { commands: ['project list'] } }) + } finally { + order.push('stream:return') + } + }, + }) + router.command('fail-stream', { + async *run(c) { + yield { step: 1 } + return c.error({ code: 'STREAM_FAILED', message: 'nope', retryable: true }) + }, + }) + router.command('denied', { + run(c) { + return c.error({ + code: 'DENIED', + cta: { commands: ['project list'] }, + message: 'Denied.', + retryable: true, + }) + }, + }) + router.command('throw', { + run() { + throw new Error('boom') + }, + }) + + const cli = Cli.create('root', { + vars: z.object({ root: z.string().default('unset') }), + env: z.object({ API_KEY: z.string() }), + run() { + return { root: true } + }, + }) + cli.use(async (c, next) => { + order.push(`root:before:${c.env.API_KEY}`) + c.set('root', 'set') + await next() + order.push('root:after') + }) + cli.command('alias-target', { + aliases: ['alias'], + run() { + return { ok: true } + }, + }) + cli.command(child) + cli.command(router) + cli.command('raw', { fetch: () => new Response('{}') }) + return { cli, order, ctx: RuntimeContext.fromCli(cli) } +} + +describe('createRpcHandler', () => { + test('executes root, mounted root, and mounted router commands by canonical ID', async () => { + const { ctx, order } = createFixture() + + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: ' root ', + args: {}, + options: {}, + }), + ).resolves.toMatchObject({ ok: true, data: { root: true }, meta: { command: 'root' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k', TOKEN: 't' } }).request({ + command: 'child', + args: { id: 'c1' }, + options: { loud: true }, + }), + ).resolves.toMatchObject({ ok: true, data: { id: 'c1', loud: true } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: { limit: 1 }, + }), + ).resolves.toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }, { id: 'b' }] }, + meta: { command: 'project list' }, + }) + + expect(order).toEqual([ + 'root:before:k', + 'root:after', + 'root:before:k', + 'child:true:c1:true:t', + 'root:after', + 'root:before:k', + 'group:before', + 'run:p1:1:set', + 'group:after', + 'root:after', + ]) + }) + + test('rejects malformed RPC requests with field errors', async () => { + const { ctx } = createFixture() + const { request } = createRpcHandler(ctx) + const cases = [ + null, + {}, + { command: 1 }, + { command: 'project list', args: [] }, + { command: 'project list', options: [] }, + { command: 'project list', outputFormat: 'xml' }, + { command: 'project list', outputTokenLimit: -1 }, + { command: 'project list', outputTokenOffset: 1.5 }, + { command: 'project list', selection: [] }, + ] + + for (const item of cases) { + const response = await request(item) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'INVALID_RPC_REQUEST', + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ message: expect.any(String) }), + ]), + }, + }) + } + }) + + test('rejects unknown commands, groups, aliases, and raw fetch gateways', async () => { + const { ctx } = createFixture() + const { request } = createRpcHandler(ctx) + await expect(request({ command: '' })).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) + await expect(request({ command: 'missing' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(request({ command: 'project' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_GROUP' }, + }) + await expect(request({ command: 'alias' })).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_NOT_FOUND' }, + }) + await expect(request({ command: 'raw' })).resolves.toMatchObject({ + ok: false, + error: { code: 'FETCH_GATEWAY' }, + }) + }) + + test('validates structured args, options, CLI env, and command env independently', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: {}, + options: { limit: 1 }, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p' }, + options: { limit: 'bad' }, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx).request({ + command: 'project list', + args: { projectId: 'p' }, + options: {}, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'child', + args: { id: 'c' }, + options: {}, + }), + ).resolves.toMatchObject({ ok: false, error: { code: 'VALIDATION_ERROR' } }) + }) + + test('returns command error envelopes with retryable and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project denied', + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'DENIED', message: 'Denied.', retryable: true }, + meta: { + command: 'project denied', + cta: { + commands: [{ command: 'root project list' }], + description: 'Suggested command:', + }, + }, + }) + }) + + test('returns thrown errors as unknown command failures', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ command: 'project throw' }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'UNKNOWN', message: 'boom' }, + meta: { command: 'project throw' }, + }) + }) + + test('applies selection, formatting, token metadata, and CTA metadata', async () => { + const { ctx } = createFixture() + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputFormat: 'json', + outputTokenCount: true, + outputTokenLimit: 4, + selection: ['items[0,1]'], + }) + expect(response).toMatchObject({ + ok: true, + data: { items: [{ id: 'a' }] }, + meta: { command: 'project list' }, + output: { + format: 'json', + nextOffset: 4, + tokenCount: expect.any(Number), + tokenLimit: 4, + tokenOffset: 0, + truncated: true, + }, + }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.meta).not.toHaveProperty('nextOffset') + expect(response.meta).not.toHaveProperty('outputTokenCount') + }) + + test('rejects empty selections and omits token count unless requested', async () => { + const { ctx } = createFixture() + await expect( + createRpcHandler(ctx).request({ command: 'project list', selection: [] }), + ).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_RPC_REQUEST' }, + }) + const response = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + }) + if ('stream' in response || !response.ok || !response.output) + throw new Error('expected success') + expect(response.output).toMatchObject({ format: Formatter.defaultFormat }) + expect(response.output).not.toHaveProperty('tokenCount') + expect(response.output).not.toHaveProperty('tokenLimit') + expect(response.output).not.toHaveProperty('tokenOffset') + expect(response.output).not.toHaveProperty('nextOffset') + + const counted = await createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenCount: true, + }) + expect(counted).toMatchObject({ + ok: true, + output: { format: Formatter.defaultFormat, tokenCount: expect.any(Number) }, + }) + if ('stream' in counted || !counted.ok || !counted.output) throw new Error('expected success') + expect(counted.output).not.toHaveProperty('tokenLimit') + expect(counted.output).not.toHaveProperty('tokenOffset') + expect(counted.output).not.toHaveProperty('nextOffset') + expect(counted.output).not.toHaveProperty('truncated') + }) + + test('keeps token metadata on output for non-truncated and offset-only requests', async () => { + const { ctx } = createFixture() + const request = createRpcHandler(ctx, { env: { API_KEY: 'k' } }).request + const limited = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenLimit: 100, + }) + expect(limited).toMatchObject({ + ok: true, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenLimit: 100, + tokenOffset: 0, + }, + }) + if ('stream' in limited || !limited.ok || !limited.output) throw new Error('expected success') + expect(limited.output).not.toHaveProperty('nextOffset') + expect(limited.output).not.toHaveProperty('truncated') + + const offset = await request({ + command: 'project list', + args: { projectId: 'p1' }, + options: {}, + outputTokenOffset: 1, + }) + expect(offset).toMatchObject({ + ok: true, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenOffset: 1, + truncated: true, + }, + }) + if ('stream' in offset || !offset.ok || !offset.output) throw new Error('expected success') + expect(offset.output).not.toHaveProperty('nextOffset') + }) + + test('streams chunks, terminal metadata, terminal errors, and cancellation', async () => { + const { ctx, order } = createFixture() + const { request } = createRpcHandler(ctx, { env: { API_KEY: 'k' } }) + const response = await request({ + command: 'project stream', + outputTokenCount: true, + outputTokenLimit: 1, + }) + if (!('stream' in response)) throw new Error('expected stream') + const records: unknown[] = [] + for await (const record of response.records()) records.push(record) + expect(records).toMatchObject([ + { type: 'chunk', data: { step: 1 } }, + { type: 'chunk', data: { step: 2 } }, + { + type: 'done', + ok: true, + meta: { command: 'project stream', cta: expect.any(Object) }, + output: { + format: Formatter.defaultFormat, + tokenCount: expect.any(Number), + tokenLimit: 1, + tokenOffset: 0, + truncated: true, + }, + }, + ]) + + const failed = await request({ command: 'project fail-stream' }) + if (!('stream' in failed)) throw new Error('expected stream') + const failedRecords: unknown[] = [] + for await (const record of failed.records()) failedRecords.push(record) + expect(failedRecords.at(-1)).toMatchObject({ + type: 'error', + ok: false, + error: { code: 'STREAM_FAILED', retryable: true }, + meta: { command: 'project fail-stream' }, + }) + + const cancelled = await request({ command: 'project stream' }) + if (!('stream' in cancelled)) throw new Error('expected stream') + const iterator = cancelled.records() + await iterator.next() + await iterator.return(undefined as any) + expect(order).toContain('stream:return') + }) + + test('maps RPC error codes to HTTP statuses', () => { + expect(getRpcStatus('COMMAND_NOT_FOUND')).toBe(404) + expect(getRpcStatus('VALIDATION_ERROR')).toBe(400) + expect(getRpcStatus('INVALID_RPC_REQUEST')).toBe(400) + expect(getRpcStatus('COMMAND_GROUP')).toBe(400) + expect(getRpcStatus('FETCH_GATEWAY')).toBe(400) + expect(getRpcStatus('UNKNOWN')).toBe(500) + }) +}) diff --git a/src/internal/handlers/rpc.ts b/src/internal/handlers/rpc.ts new file mode 100644 index 0000000..3e62394 --- /dev/null +++ b/src/internal/handlers/rpc.ts @@ -0,0 +1,322 @@ +import { estimateTokenCount, sliceByTokens } from 'tokenx' +import { z } from 'zod' + +import type * as Rpc from '../../client/Rpc.js' +import type { FieldError } from '../../Errors.js' +import * as Filter from '../../Filter.js' +import * as Formatter from '../../Formatter.js' +import * as Command from '../command.js' +import * as RuntimeContext from '../runtime-context.js' + +const requestSchema = z.object({ + command: z.string().transform((value) => value.trim().replace(/\s+/g, ' ')), + args: z.record(z.string(), z.unknown()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + outputFormat: z.enum(['toon', 'json', 'yaml', 'md', 'jsonl']).optional(), + selection: z.array(z.string().min(1)).nonempty().optional(), + outputTokenCount: z.boolean().optional(), + outputTokenLimit: z.number().int().nonnegative().optional(), + outputTokenOffset: z.number().int().nonnegative().optional(), +}) +const sentinel = Symbol.for('incur.sentinel') + +/** Returns the HTTP status for an RPC error code. */ +export function getRpcStatus(code: string) { + if (code === 'COMMAND_NOT_FOUND') return 404 + if (code === 'VALIDATION_ERROR' || code === 'INVALID_RPC_REQUEST') return 400 + if (code === 'COMMAND_GROUP' || code === 'FETCH_GATEWAY') return 400 + return 500 +} + +/** Creates the shared in-process RPC handler. */ +export function createRpcHandler( + ctx: RuntimeContext.RuntimeCliContext, + options: createRpcHandler.Options = {}, +) { + return { + async request(request: unknown): Promise { + const start = performance.now() + const parsed = requestSchema.safeParse(request) + if (!parsed.success) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'Invalid RPC request.', + fieldErrors: parsed.error.issues.map((issue) => ({ + code: issue.code, + expected: 'valid RPC request', + received: 'invalid', + message: issue.message, + path: issue.path.join('.'), + })), + }) + + const rpc = parsed.data + if (!rpc.command) + return errorEnvelope('', start, { + code: 'INVALID_RPC_REQUEST', + message: 'RPC command is required.', + }) + + const resolved = RuntimeContext.resolveCanonical(ctx, rpc.command) + if ('error' in resolved) + return errorEnvelope(rpc.command, start, { + code: resolved.error === 'empty' ? 'INVALID_RPC_REQUEST' : 'COMMAND_NOT_FOUND', + message: + resolved.error === 'empty' + ? 'RPC command is required.' + : `'${resolved.token}' is not a command for '${resolved.parent}'.`, + }) + if ('commands' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'COMMAND_GROUP', + message: `'${resolved.id}' is a command group. Specify a subcommand.`, + }) + if ('gateway' in resolved) + return errorEnvelope(rpc.command, start, { + code: 'FETCH_GATEWAY', + message: `'${resolved.id}' is a raw fetch gateway and cannot be called with structured RPC.`, + }) + + const result = await Command.execute(resolved.command, { + agent: true, + argv: [], + env: ctx.env, + envSource: options.env, + format: rpc.outputFormat ?? Formatter.defaultFormat, + formatExplicit: true, + inputOptions: { args: rpc.args ?? {}, options: rpc.options ?? {} }, + middlewares: resolved.middlewares, + name: ctx.name, + parseMode: 'structured', + path: resolved.id, + vars: ctx.vars, + version: ctx.version, + }) + + if ('stream' in result) return streamResponse(result.stream, resolved.id, start, rpc) + if (!result.ok) + return errorEnvelope(resolved.id, start, result.error, formatCta(ctx.name, result.cta)) + return successEnvelope(resolved.id, start, result.data, formatCta(ctx.name, result.cta), rpc) + }, + } +} + +export declare namespace createRpcHandler { + /** Execution options. */ + type Options = { + /** Explicit environment source. */ + env?: Record | undefined + } +} + +function streamResponse( + stream: AsyncGenerator, + command: string, + start: number, + request: Rpc.Request, +): Rpc.StreamResponse { + return { + stream: true, + async *records() { + let terminal: Rpc.StreamRecord + try { + while (true) { + const { value, done } = await stream.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) + } else { + const data = isSentinel(value) ? value.data : undefined + terminal = { + type: 'done', + ...successEnvelope( + command, + start, + data, + formatCta('', isSentinel(value) ? value.cta : undefined), + request, + ), + } + } + yield terminal + return terminal + } + if (isSentinel(value) && value[sentinel] === 'error') { + terminal = errorRecord(command, start, sentinelError(value), formatCta('', value.cta)) + yield terminal + return terminal + } + yield { type: 'chunk', data: value } + } + } catch (error) { + terminal = errorRecord( + command, + start, + { + code: 'UNKNOWN', + message: error instanceof Error ? error.message : String(error), + }, + undefined, + ) + yield terminal + return terminal + } finally { + await stream.return(undefined).catch(() => undefined) + } + }, + } +} + +function successEnvelope( + command: string, + start: number, + data: unknown, + cta?: unknown | undefined, + request: Rpc.Request = { command }, +): Extract { + const selected = applySelection(data, request.selection) + const output = renderOutput(selected, request) + const payload = outputPayload(output, request) + return { + ok: true, + data: selected, + ...(payload ? { output: payload } : undefined), + meta: meta(command, start, cta), + } +} + +function errorEnvelope( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta?: unknown | undefined, +): Extract { + return { + ok: false, + error, + meta: meta(command, start, cta), + } +} + +function errorRecord( + command: string, + start: number, + error: { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined + }, + cta: unknown | undefined, +): Extract { + return { type: 'error', ...errorEnvelope(command, start, error, cta) } +} + +function applySelection(data: unknown, selection: string[] | undefined) { + if (!selection?.length) return data + return Filter.apply( + data, + selection.flatMap((value) => Filter.parse(value)), + ) +} + +function renderOutput(data: unknown, request: Rpc.Request) { + const format = request.outputFormat ?? Formatter.defaultFormat + const text = Formatter.format(data, format) + const count = estimateTokenCount(text) + const offset = request.outputTokenOffset ?? 0 + if (request.outputTokenLimit === undefined && request.outputTokenOffset === undefined) + return { text, format, count, offset, truncated: false } + const end = request.outputTokenLimit === undefined ? count : offset + request.outputTokenLimit + const sliced = sliceByTokens(text, offset, end) + return { + text: sliced, + format, + count, + offset, + truncated: offset > 0 || end < count, + nextOffset: end < count ? end : undefined, + } +} + +function outputPayload( + output: ReturnType, + request: Rpc.Request, +): Rpc.Output | undefined { + if (!output.text && !includeTokenMetadata(request)) return undefined + return { + text: output.text, + format: output.format, + ...(output.nextOffset !== undefined ? { nextOffset: output.nextOffset } : undefined), + ...(includeTokenMetadata(request) ? { tokenCount: output.count } : undefined), + ...(request.outputTokenLimit !== undefined + ? { tokenLimit: request.outputTokenLimit } + : undefined), + ...(request.outputTokenLimit !== undefined || request.outputTokenOffset !== undefined + ? { tokenOffset: output.offset } + : undefined), + ...(output.truncated ? { truncated: true } : undefined), + } +} + +function includeTokenMetadata(request: Rpc.Request) { + return ( + request.outputTokenCount || + request.outputTokenLimit !== undefined || + request.outputTokenOffset !== undefined + ) +} + +function meta(command: string, start: number, cta: unknown | undefined): Rpc.Meta { + return { + command, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + } +} + +function formatCta(name: string, block: unknown | undefined) { + if (!block || typeof block !== 'object' || !('commands' in block)) return undefined + const commands = (block as { commands: unknown[]; description?: string | undefined }).commands + if (commands.length === 0) return undefined + return { + description: + (block as { description?: string | undefined }).description ?? + (commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'), + commands: commands.map((command) => { + if (typeof command === 'string') return { command: name ? `${name} ${command}` : command } + if (typeof command === 'object' && command !== null && 'command' in command) return command + return { command: String(command) } + }), + } +} + +type SentinelValue = { + [sentinel]: 'ok' | 'error' + code?: string | undefined + cta?: unknown | undefined + data?: unknown | undefined + message?: string | undefined + retryable?: boolean | undefined +} + +function isSentinel(value: unknown): value is SentinelValue { + return typeof value === 'object' && value !== null && sentinel in value +} + +function sentinelError(value: { + code?: string | undefined + message?: string | undefined + retryable?: boolean | undefined +}) { + return { + code: value.code ?? 'UNKNOWN', + message: value.message ?? 'Command failed', + ...(value.retryable !== undefined ? { retryable: value.retryable } : undefined), + } +} diff --git a/src/internal/runtime-context.test.ts b/src/internal/runtime-context.test.ts new file mode 100644 index 0000000..aedd2f6 --- /dev/null +++ b/src/internal/runtime-context.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest' +import { z } from 'zod' + +import * as Cli from '../Cli.js' +import * as RuntimeContext from './runtime-context.js' + +describe('runtime-context', () => { + test('collects canonical structured command IDs and excludes aliases/raw gateways', () => { + const root = Cli.create('root', { + run() { + return null + }, + }) + const mounted = Cli.create('mounted', { + run() { + return null + }, + }) + const nested = Cli.create('nested').command('leaf', { + run() { + return null + }, + }) + const router = Cli.create('project').command(nested) + root.command('target', { + aliases: ['alias'], + run() { + return null + }, + }) + root.command('raw', { fetch: () => new Response('{}') }) + root.command(mounted) + root.command(router) + + const ctx = RuntimeContext.fromCli(root) + expect(RuntimeContext.collectStructuredCommands(ctx).map((entry) => entry.id)).toEqual([ + 'mounted', + 'project nested leaf', + 'root', + 'target', + ]) + expect(RuntimeContext.resolveCanonical(ctx, 'alias')).toMatchObject({ error: 'unknown' }) + expect(RuntimeContext.resolveCanonical(ctx, 'raw')).toMatchObject({ + gateway: expect.any(Object), + }) + }) + + test('includes OpenAPI-mounted operations without serving first', () => { + const cli = Cli.create('app').command('api', { + fetch: (req) => + new Response(JSON.stringify({ id: new URL(req.url).pathname.split('/').pop() }), { + headers: { 'content-type': 'application/json' }, + }), + openapi: { + paths: { + '/users/{id}': { + get: { + operationId: 'getUser', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + const command = RuntimeContext.collectStructuredCommands(RuntimeContext.fromCli(cli))[0]! + expect(command.id).toBe('api getUser') + expect(command.command.args?.shape.id).toBeDefined() + expect(command.command.output).toBeDefined() + }) + + test('builds separate input schemas', () => { + const command = { + args: z.object({ id: z.string() }), + env: z.object({ TOKEN: z.string() }), + options: z.object({ limit: z.number().optional() }), + run() {}, + } + expect(RuntimeContext.buildInputSchema(command)).toMatchObject({ + args: { properties: { id: { type: 'string' } } }, + env: { properties: { TOKEN: { type: 'string' } } }, + options: { properties: { limit: { type: 'number' } } }, + }) + }) +}) diff --git a/src/internal/runtime-context.ts b/src/internal/runtime-context.ts new file mode 100644 index 0000000..c3efc30 --- /dev/null +++ b/src/internal/runtime-context.ts @@ -0,0 +1,208 @@ +import type { z } from 'zod' + +import * as Cli from '../Cli.js' +import type { + CommandDefinition, + CommandEntry, + InternalAlias, + InternalFetchGateway, + InternalGroup, +} from '../Cli.js' +import type { Handler as MiddlewareHandler } from '../middleware.js' +import * as Schema from '../Schema.js' + +/** Runtime metadata needed to execute and discover a CLI command tree. */ +export type RuntimeCliContext = { + /** Command map registered on the CLI. */ + commands: Map + /** CLI description. */ + description?: string | undefined + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Middleware handlers registered on the root CLI. */ + middlewares?: MiddlewareHandler[] | undefined + /** Local MCP setup defaults. */ + mcp?: { agents?: string[] | undefined; command?: string | undefined } | undefined + /** CLI name. */ + name: string + /** Root command definition, when the CLI itself is callable. */ + rootCommand?: CommandDefinition | undefined + /** Local skill sync defaults. */ + sync?: + | { + cwd?: string | undefined + depth?: number | undefined + include?: string[] | undefined + suggestions?: string[] | undefined + } + | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + /** CLI version string. */ + version?: string | undefined +} + +/** Resolved callable command. */ +export type ResolvedCommand = { + command: CommandDefinition + id: string + middlewares: MiddlewareHandler[] +} + +/** Resolved command group. */ +export type ResolvedGroup = { + commands: Map + description?: string | undefined + id: string +} + +/** Resolved raw fetch gateway. */ +export type ResolvedFetchGateway = { + gateway: InternalFetchGateway + id: string + middlewares: MiddlewareHandler[] +} + +/** Returns a runtime context for a CLI instance. */ +export function fromCli(cli: Cli.Cli): RuntimeCliContext { + const commands = Cli.toCommands.get(cli) + if (!commands) throw new Error('No commands registered on this CLI instance') + const version = Cli.toVersion.get(cli) + return { + commands: commands as Map, + ...(cli.description ? { description: cli.description } : undefined), + ...(cli.env ? { env: cli.env } : undefined), + middlewares: Cli.toMiddlewares.get(cli) ?? [], + ...(Cli.toMcpOptions.get(cli) ? { mcp: Cli.toMcpOptions.get(cli) } : undefined), + name: cli.name, + ...(Cli.toRootDefinition.get(cli as unknown as Cli.Root) + ? { + rootCommand: Cli.toRootDefinition.get(cli as unknown as Cli.Root) as CommandDefinition< + any, + any, + any, + any, + any, + any + >, + } + : undefined), + ...(Cli.toSyncOptions.get(cli) ? { sync: Cli.toSyncOptions.get(cli) } : undefined), + ...(cli.vars ? { vars: cli.vars } : undefined), + ...(version !== undefined ? { version } : undefined), + } +} + +/** Returns true when an entry is an alias. */ +export function isAlias(entry: CommandEntry): entry is InternalAlias { + return Cli.isAlias(entry) +} + +/** Returns true when an entry is a command group. */ +export function isGroup(entry: CommandEntry): entry is InternalGroup { + return Cli.isGroup(entry) +} + +/** Returns true when an entry is a raw fetch gateway. */ +export function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { + return Cli.isFetchGateway(entry) +} + +/** Resolves an alias entry within its owning command map. */ +export function resolveAlias( + commands: Map, + entry: CommandEntry, +): Exclude { + return Cli.resolveAlias(commands, entry) as Exclude +} + +/** Resolves a canonical command ID without accepting aliases. */ +export function resolveCanonical( + ctx: RuntimeCliContext, + command: string, +): + | ResolvedCommand + | ResolvedGroup + | ResolvedFetchGateway + | { error: 'empty' | 'unknown'; token?: string | undefined; parent: string } { + const id = command.trim().replace(/\s+/g, ' ') + if (!id) return { error: 'empty', parent: ctx.name } + if (ctx.rootCommand && id === ctx.name) + return { id, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] } + + let commands = ctx.commands + let entry: CommandEntry | undefined + let parent = ctx.name + const path: string[] = [] + const middlewares = [...(ctx.middlewares ?? [])] + + for (const token of id.split(' ')) { + entry = commands.get(token) + if (!entry || isAlias(entry)) return { error: 'unknown', token, parent } + path.push(token) + if (isGroup(entry)) { + middlewares.push(...(entry.middlewares ?? [])) + commands = entry.commands + parent = path.join(' ') + continue + } + if (path.join(' ') !== id) + return { error: 'unknown', token: id.split(' ')[path.length], parent } + } + + if (!entry) return { error: 'unknown', token: id, parent } + if (isGroup(entry)) return { id, commands: entry.commands, description: entry.description } + if (isFetchGateway(entry)) return { id, gateway: entry, middlewares } + if (isAlias(entry)) return { error: 'unknown', token: id, parent } + return { id, command: entry, middlewares: [...middlewares, ...(entry.middleware ?? [])] } +} + +/** Traverses structured command entries. Aliases and raw fetch gateways are excluded. */ +export function collectStructuredCommands(ctx: RuntimeCliContext): ResolvedCommand[] { + const result: ResolvedCommand[] = [] + if (ctx.rootCommand) + result.push({ id: ctx.name, command: ctx.rootCommand, middlewares: ctx.middlewares ?? [] }) + collect(ctx.commands, [], ctx.middlewares ?? [], result) + return result.sort((a, b) => a.id.localeCompare(b.id)) +} + +function collect( + commands: Map, + prefix: string[], + middlewares: MiddlewareHandler[], + result: ResolvedCommand[], +) { + for (const [name, entry] of commands) { + if (isAlias(entry) || isFetchGateway(entry)) continue + const path = [...prefix, name] + if (isGroup(entry)) { + collect(entry.commands, path, [...middlewares, ...(entry.middlewares ?? [])], result) + continue + } + result.push({ + id: path.join(' '), + command: entry, + middlewares: [...middlewares, ...(entry.middleware ?? [])], + }) + } +} + +/** Builds the structured input schema used by discovery payloads. */ +export function buildInputSchema(command: CommandDefinition): + | { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } + | undefined { + if (!command.args && !command.env && !command.options) return undefined + const result: { + args?: Record | undefined + env?: Record | undefined + options?: Record | undefined + } = {} + if (command.args) result.args = Schema.toJsonSchema(command.args) + if (command.env) result.env = Schema.toJsonSchema(command.env) + if (command.options) result.options = Schema.toJsonSchema(command.options) + return result +}