From 85d238a363baf6d6b3024c527f43e1f997c2f5e9 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Fri, 22 May 2026 15:39:08 +0200 Subject: [PATCH 1/5] fix: normalize object validation errors --- .changeset/quiet-walls-share.md | 5 +++ src/Cli.test.ts | 70 +++++++++++++++++++++++++++++++++ src/Parser.ts | 2 +- src/internal/command.ts | 6 +-- 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-walls-share.md diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md new file mode 100644 index 0000000..7d5598c --- /dev/null +++ b/.changeset/quiet-walls-share.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index bd55fc6..86cd794 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import * as Command from './internal/command.js' + const originalIsTTY = process.stdout.isTTY beforeAll(() => { ;(process.stdout as any).isTTY = false @@ -4051,6 +4053,74 @@ describe('--filter-output', () => { }) }) +describe('Command.execute', () => { + test.each([ + { + name: 'split', + command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { name: 123 }, + path: 'name', + parseMode: 'split' as const, + }, + { + name: 'flat', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { id: 123 }, + path: 'id', + parseMode: 'flat' as const, + }, + ])('$name mode returns validation fieldErrors for invalid command input', async (c) => { + const result = await Command.execute(c.command, { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: c.inputOptions, + name: 'test', + parseMode: c.parseMode, + path: 'users', + version: undefined, + }) + + expect(result).toMatchObject({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + missing: false, + path: c.path, + }, + ], + }, + }) + }) + + test('does not normalize handler-thrown Zod errors as command input', async () => { + const result = await Command.execute( + { + run() { + z.object({ name: z.string() }).parse({ name: 123 }) + }, + }, + { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: {}, + name: 'test', + path: 'users', + version: undefined, + }, + ) + + expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } }) + expect(result).not.toHaveProperty('error.fieldErrors') + }) +}) + async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() diff --git a/src/Parser.ts b/src/Parser.ts index ea21a75..3a601ad 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -257,7 +257,7 @@ function setOption( } /** Wraps zod schema.parse(), converting ZodError to ValidationError. */ -function zodParse(schema: z.ZodObject, data: Record) { +export function zodParse(schema: z.ZodObject, data: Record) { try { return schema.parse(data) } catch (err: any) { diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..c1103b9 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -81,12 +81,12 @@ export async function execute(command: any, options: execute.Options): Promise Date: Tue, 26 May 2026 13:35:27 +0200 Subject: [PATCH 2/5] fix(cli): preserve stream terminal records --- src/Cli.test.ts | 437 +++++++++++++++++++++++++++++++++++++--- src/Cli.ts | 71 +++++-- src/e2e.test.ts | 3 + src/internal/command.ts | 2 +- 4 files changed, 471 insertions(+), 42 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 86cd794..81fe966 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3656,6 +3656,57 @@ test('streaming: generator throws in buffered mode', async () => { expect(output).toContain('generator exploded') }) +test('streaming: thrown IncurError preserves retryable metadata in machine formats', async () => { + const cli = Cli.create('test') + cli.command('limited', { + async *run() { + yield { step: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + + const jsonl = await serve(cli, ['limited', '--format', 'jsonl']) + const jsonlLines = jsonl.output + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(jsonl.exitCode).toBe(1) + expect(jsonlLines[1]).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "ok": false, + "type": "error", + } + `) + + const json = await serve(cli, ['limited', '--full-output', '--format', 'json']) + const body = JSON.parse(json.output) + body.meta.duration = '' + expect(json.exitCode).toBe(1) + expect(body).toMatchInlineSnapshot(` + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "limited", + "duration": "", + }, + "ok": false, + } + `) +}) + test('streaming: generator returns error in buffered mode', async () => { const cli = Cli.create('test') cli.command('fail', { @@ -4128,6 +4179,20 @@ async function fetchJson(cli: Cli.Cli, req: Request) { return { status: res.status, body } } +async function fetchNdjson(cli: Cli.Cli, req: Request) { + const res = await cli.fetch(req) + const lines = (await res.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + for (const line of lines) + if (line.meta?.duration) { + expect(line.meta.duration).toMatch(/^\d+ms$/) + line.meta.duration = '' + } + return { status: res.status, contentType: res.headers.get('content-type'), lines } +} + describe('fetch', () => { test('GET /health → 200', async () => { const cli = Cli.create('test') @@ -4362,36 +4427,356 @@ describe('fetch', () => { return { done: true } }, }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "data": { + "progress": 2, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response preserves returned ok CTA through middleware', async () => { + const cli = Cli.create('test') + cli.use(async (_c, next) => { + await next() + }) + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.ok({ ignored: true }, { cta: { commands: ['next'], description: 'Next steps:' } }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "cta": { + "commands": [ + { + "command": "test next", + }, + ], + "description": "Next steps:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response handles terminal-only sentinel returns through middleware', async () => { + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (c, next) => { + order.push(`before:${c.command}`) + await next() + order.push(`after:${c.command}`) + }) + const sub = Cli.create('ops') + sub.command('ok', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.ok( + { ignored: true }, + { cta: { commands: [{ command: 'next', description: 'Continue' }] } }, + ) + }, + }) + sub.command('fail', { + // oxlint-disable-next-line require-yield -- exercises a stream that returns before yielding. + async *run(c) { + return c.error({ + code: 'EMPTY_FAIL', + cta: { commands: ['retry'], description: 'Recover with:' }, + message: 'failed before chunks', + retryable: true, + }) + }, + }) + cli.command(sub) + + const ok = await fetchNdjson(cli, new Request('http://localhost/ops/ok')) + expect(ok).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "meta": { + "command": "ops ok", + "cta": { + "commands": [ + { + "command": "test next", + "description": "Continue", + }, + ], + "description": "Suggested command:", + }, + "duration": "", + }, + "ok": true, + "type": "done", + }, + ], + "status": 200, + } + `) + expect(ok.lines[0]).not.toHaveProperty('data') + + expect(await fetchNdjson(cli, new Request('http://localhost/ops/fail'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "error": { + "code": "EMPTY_FAIL", + "message": "failed before chunks", + "retryable": true, + }, + "meta": { + "command": "ops fail", + "cta": { + "commands": [ + { + "command": "test retry", + }, + ], + "description": "Recover with:", + }, + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(order).toEqual(['before:ops ok', 'after:ops ok', 'before:ops fail', 'after:ops fail']) + }) + + test('streaming response represents returned error as terminal error', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.error({ code: 'STREAM_FAIL', message: 'failed late', retryable: true }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed late", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + }) + + test('streaming response represents yielded error as terminal error', async () => { + let closed = false + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + try { + yield { progress: 1 } + yield c.error({ code: 'STREAM_FAIL', message: 'failed now' }) + yield { progress: 2 } + } finally { + closed = true + } + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "failed now", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } + `) + expect(closed).toBe(true) + }) + + test('streaming response cancellation unwinds generator and middleware', async () => { + let resolveAfter = () => {} + const after = new Promise((resolve) => { + resolveAfter = resolve + }) + const order: string[] = [] + const cli = Cli.create('test') + cli.use(async (_c, next) => { + order.push('mw:before') + await next() + order.push('mw:after') + resolveAfter() + }) + cli.command('stream', { + async *run() { + try { + order.push('stream:yield') + yield { progress: 1 } + while (true) yield { progress: 2 } + } finally { + order.push('stream:finally') + } + }, + }) const res = await cli.fetch(new Request('http://localhost/stream')) - expect(res.status).toBe(200) - expect(res.headers.get('content-type')).toBe('application/x-ndjson') - const text = await res.text() - const lines = text - .trim() - .split('\n') - .map((l) => JSON.parse(l)) - expect(lines).toMatchInlineSnapshot(` - [ - { - "data": { - "progress": 1, + const reader = res.body!.getReader() + await reader.read() + await reader.cancel() + await after + expect(order).toEqual(['mw:before', 'stream:yield', 'stream:finally', 'mw:after']) + }) + + test('streaming response thrown error includes terminal duration metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Error('boom') + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "type": "chunk", - }, - { - "data": { - "progress": 2, + { + "error": { + "code": "UNKNOWN", + "message": "boom", + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", }, - "type": "chunk", - }, - { - "meta": { - "command": "stream", + ], + "status": 200, + } + `) + }) + + test('streaming response thrown IncurError preserves code and retryable metadata', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run() { + yield { progress: 1 } + throw new Errors.IncurError({ + code: 'RATE_LIMITED', + message: 'too fast', + retryable: true, + }) + }, + }) + expect(await fetchNdjson(cli, new Request('http://localhost/stream'))).toMatchInlineSnapshot(` + { + "contentType": "application/x-ndjson", + "lines": [ + { + "data": { + "progress": 1, + }, + "type": "chunk", }, - "ok": true, - "type": "done", - }, - ] + { + "error": { + "code": "RATE_LIMITED", + "message": "too fast", + "retryable": true, + }, + "meta": { + "command": "stream", + "duration": "", + }, + "ok": false, + "type": "error", + }, + ], + "status": 200, + } `) }) diff --git a/src/Cli.ts b/src/Cli.ts index d8efef9..ec314ac 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1854,24 +1854,61 @@ async function executeCommand( // Streaming path — async generator → NDJSON response if ('stream' in result) { + const iterator = result.stream + const encoder = new TextEncoder() + const meta = (cta?: FormattedCtaBlock | undefined) => ({ + command: path, + duration: `${Math.round(performance.now() - start)}ms`, + ...(cta ? { cta } : undefined), + }) + const errorRecord = (err: ErrorResult) => ({ + type: 'error', + ok: false, + error: { + code: err.code, + message: err.message, + ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined), + }, + meta: meta(formatCtaBlock(options.name ?? path, err.cta)), + }) const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() + async cancel() { + await iterator.return(undefined) + }, + async pull(controller) { try { - for await (const value of result.stream) { + const { value, done } = await iterator.next() + if (done) { + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + controller.close() + return + } + const cta = + isSentinel(value) && value[sentinel] === 'ok' + ? formatCtaBlock(options.name ?? path, value.cta) + : undefined controller.enqueue( - encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'), + encoder.encode( + JSON.stringify({ + type: 'done', + ok: true, + meta: meta(cta), + }) + '\n', + ), ) + controller.close() + return } - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'done', - ok: true, - meta: { command: path }, - }) + '\n', - ), - ) + + if (isSentinel(value) && value[sentinel] === 'error') { + controller.enqueue(encoder.encode(JSON.stringify(errorRecord(value)) + '\n')) + await iterator.return(undefined) + controller.close() + return + } + + controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n')) } catch (error) { controller.enqueue( encoder.encode( @@ -1879,14 +1916,16 @@ async function executeCommand( type: 'error', ok: false, error: { - code: 'UNKNOWN', + code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, + meta: meta(), }) + '\n', ), ) + controller.close() } - controller.close() }, }) return new Response(stream, { @@ -2719,6 +2758,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, }), ) @@ -2802,6 +2842,7 @@ async function handleStreaming( error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError ? { retryable: error.retryable } : undefined), }, meta: { command: ctx.path, diff --git a/src/e2e.test.ts b/src/e2e.test.ts index 61bbb4d..4cd126c 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -2833,6 +2833,8 @@ describe('fetch api', () => { .trim() .split('\n') .map((l) => JSON.parse(l)) + expect(lines[2].meta.duration).toMatch(/^\d+ms$/) + lines[2].meta.duration = '' expect(lines).toMatchInlineSnapshot(` [ { @@ -2850,6 +2852,7 @@ describe('fetch api', () => { { "meta": { "command": "stream", + "duration": "", }, "ok": true, "type": "done", diff --git a/src/internal/command.ts b/src/internal/command.ts index c1103b9..c8f7f08 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -128,7 +128,7 @@ export async function execute(command: any, options: execute.Options): Promise + return yield* raw as AsyncGenerator } finally { resolveStreamConsumed!() } From ea85131bbb7cd612e7715c644171aff0f3cbd4d4 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 19:23:27 +0200 Subject: [PATCH 3/5] fix: add stream terminal changeset --- .changeset/sour-dingos-shine.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/sour-dingos-shine.md diff --git a/.changeset/sour-dingos-shine.md b/.changeset/sour-dingos-shine.md new file mode 100644 index 0000000..9fefa90 --- /dev/null +++ b/.changeset/sour-dingos-shine.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed streaming command terminal records so HTTP NDJSON responses preserve returned `c.ok()` CTA metadata, represent returned or yielded `c.error()` values as terminal errors, include terminal duration metadata, and unwind generators on response cancellation. + +Also preserves `IncurError.retryable` metadata in streaming machine-format errors. From 3d8b4ca3b4c2471b75fad7a01302a654b793d256 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 18:46:48 +0200 Subject: [PATCH 4/5] fix: reuse cli skill projection --- src/Cli.ts | 21 ++++++++++- src/Skillgen.test.ts | 22 ++++++++--- src/Skillgen.ts | 41 +++----------------- src/SyncSkills.test.ts | 40 +++++++++++++++++++- src/SyncSkills.ts | 86 ++++-------------------------------------- 5 files changed, 88 insertions(+), 122 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index ec314ac..82a54d0 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -2966,11 +2966,11 @@ function collectCommands( } /** @internal Recursively collects leaf commands as `Skill.CommandInfo` for `--llms --format md`. */ -function collectSkillCommands( +export function collectSkillCommands( commands: Map, prefix: string[], groups: Map, - rootCommand?: CommandDefinition | undefined, + rootCommand?: SkillCommandSource | undefined, ): Skill.CommandInfo[] { const result: Skill.CommandInfo[] = [] if (rootCommand) { @@ -3018,6 +3018,11 @@ function collectSkillCommands( return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) } +type SkillCommandSource = Pick< + CommandDefinition, + 'args' | 'description' | 'env' | 'examples' | 'hint' | 'options' | 'output' +> + /** @internal Formats examples into `{ command, description }` objects. `command` is the args/options suffix only. */ export function formatExamples( examples: Example[] | undefined, @@ -3034,6 +3039,18 @@ export function formatExamples( }) } +/** @internal Parses YAML frontmatter from generated skill Markdown. */ +export function parseSkillFrontmatter(content: string): { + description?: string | undefined + name?: string | undefined +} { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return {} + const meta = yamlParse(match[1]!) + if (!meta || typeof meta !== 'object') return {} + return meta as { description?: string | undefined; name?: string | undefined } +} + /** @internal Builds separate args, env, and options JSON Schemas. */ function buildInputSchema( args: z.ZodObject | undefined, diff --git a/src/Skillgen.test.ts b/src/Skillgen.test.ts index 49ae419..6e1df8f 100644 --- a/src/Skillgen.test.ts +++ b/src/Skillgen.test.ts @@ -60,13 +60,20 @@ test('collects group descriptions', async () => { test('includes args, options, and examples in output', async () => { const cli = Cli.create('tool', { description: 'A tool', - }).command('greet', { - description: 'Greet someone', - args: z.object({ name: z.string().describe('Name to greet') }), - options: z.object({ loud: z.boolean().default(false).describe('Shout') }), - examples: [{ args: { name: 'world' }, description: 'Greet the world' }], - run: () => ({}), }) + .command('greet', { + description: 'Greet someone', + aliases: ['hi'], + args: z.object({ name: z.string().describe('Name to greet') }), + options: z.object({ loud: z.boolean().default(false).describe('Shout') }), + output: z.object({ message: z.string() }), + examples: [{ args: { name: 'world' }, description: 'Greet the world' }], + run: () => ({ message: 'hi' }), + }) + .command('api', { + description: 'Proxy API', + fetch: () => new Response('{}'), + }) vi.mocked(importCli).mockResolvedValue(cli) const files = await generate('fake-input', tmp, 0) @@ -74,4 +81,7 @@ test('includes args, options, and examples in output', async () => { expect(content).toContain('Name to greet') expect(content).toContain('Shout') expect(content).toContain('Greet the world') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toContain('# tool hi') }) diff --git a/src/Skillgen.ts b/src/Skillgen.ts index 844e52c..3dd2e73 100644 --- a/src/Skillgen.ts +++ b/src/Skillgen.ts @@ -13,7 +13,12 @@ export async function generate(input: string, output: string, depth = 1): Promis const groups = new Map() if (cli.description) groups.set(cli.name, cli.description) - const entries = collectEntries(commands, [], groups) + const entries = Cli.collectSkillCommands( + commands, + [], + groups, + Cli.toRootDefinition.get(cli as unknown as Cli.Root), + ) const files = Skill.split(cli.name, entries, depth, groups) if (depth > 0) await fs.rm(output, { recursive: true, force: true }) @@ -30,37 +35,3 @@ export async function generate(input: string, output: string, depth = 1): Promis return written } - -/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - for (const [name, entry] of commands) { - const path = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(path.join(' '), entry.description) - result.push(...collectEntries(entry.commands, path, groups)) - } else { - const cmd: Skill.CommandInfo = { name: path.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = Cli.formatExamples(entry.examples) - if (examples) { - const cmdName = path.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 530be61..8f64d23 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -1,4 +1,4 @@ -import { Cli, SyncSkills } from 'incur' +import { Cli, SyncSkills, z } from 'incur' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -288,6 +288,44 @@ test('list includes root command skill', async () => { expect(names).toContain('test-ping') }) +test('sync uses CLI skill projection for aliases, fetch gateways, examples, and output', async () => { + const tmp = join(tmpdir(), `clac-sync-drift-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const cli = Cli.create('tool') + .command('real', { + description: 'Real command', + aliases: ['r'], + options: z.object({ dryRun: z.boolean().default(false) }), + output: z.object({ value: z.string() }), + examples: [{ options: { dryRun: true }, description: 'Preview' }], + run: () => ({ value: 'ok' }), + }) + .command('api', { description: 'Raw API', fetch: () => new Response('{}') }) + + const commands = Cli.toCommands.get(cli)! + const listed = await SyncSkills.list('tool', commands) + const names = listed.map((skill) => skill.name) + expect(names).toContain('tool-api') + expect(names).toContain('tool-real') + expect(names).not.toContain('tool-r') + + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + const synced = await SyncSkills.sync('tool', commands, { + depth: 0, + global: false, + cwd: installDir, + }) + const content = readFileSync(join(synced.paths[0]!, 'SKILL.md'), 'utf8') + expect(content).toContain('Preview') + expect(content).toContain('## Output') + expect(content).toContain('Fetch gateway. Pass path segments') + expect(content).not.toMatch(/^# tool r$/m) + + rmSync(tmp, { recursive: true, force: true }) +}) + test('list results are sorted alphabetically', async () => { const cli = Cli.create('test') cli.command('zebra', { description: 'Z command', run: () => ({}) }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 037c350..3317c26 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -2,9 +2,8 @@ import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { parse as yamlParse } from 'yaml' -import { formatExamples } from './Cli.js' +import { collectSkillCommands, parseSkillFrontmatter } from './Cli.js' import * as Agents from './internal/agents.js' import * as Skill from './Skill.js' @@ -19,7 +18,7 @@ export async function sync( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`)) @@ -31,7 +30,7 @@ export async function sync( : path.join(tmpDir, 'SKILL.md') await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${file.content}\n`) - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) skills.push({ name: meta.name ?? (file.dir || name), description: meta.description }) } @@ -42,7 +41,7 @@ export async function sync( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) const dest = path.join(tmpDir, skillName, 'SKILL.md') @@ -68,7 +67,7 @@ export async function sync( } // Write skills hash + names for staleness detection - const hashEntries = collectEntries(commands, [], undefined, options.rootCommand) + const hashEntries = collectSkillCommands(commands, [], new Map(), options.rootCommand) writeMeta( name, Skill.hash(hashEntries), @@ -139,14 +138,14 @@ export async function list( const groups = new Map() if (description) groups.set(name, description) - const entries = collectEntries(commands, [], groups, options.rootCommand) + const entries = collectSkillCommands(commands, [], groups, options.rootCommand) const files = Skill.split(name, entries, depth, groups) const skills: list.Skill[] = [] const installed = readInstalledSkills(name, { cwd }) for (const file of files) { - const meta = parseFrontmatter(file.content) + const meta = parseSkillFrontmatter(file.content) const skillName = meta.name ?? (file.dir || name) skills.push({ name: skillName, @@ -162,7 +161,7 @@ export async function list( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const meta = parseFrontmatter(content) + const meta = parseSkillFrontmatter(content) const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) if (!skills.some((s) => s.name === skillName)) { @@ -223,75 +222,6 @@ export declare namespace list { } } -/** Recursively collects leaf commands as `Skill.CommandInfo`. */ -function collectEntries( - commands: Map, - prefix: string[], - groups: Map = new Map(), - rootCommand?: - | { - description?: string | undefined - args?: any - env?: any - hint?: string | undefined - options?: any - output?: any - examples?: any[] | undefined - } - | undefined, -): Skill.CommandInfo[] { - const result: Skill.CommandInfo[] = [] - if (rootCommand) { - const cmd: Skill.CommandInfo = {} - if (rootCommand.description) cmd.description = rootCommand.description - if (rootCommand.args) cmd.args = rootCommand.args - if (rootCommand.env) cmd.env = rootCommand.env - if (rootCommand.hint) cmd.hint = rootCommand.hint - if (rootCommand.options) cmd.options = rootCommand.options - if (rootCommand.output) cmd.output = rootCommand.output - const examples = formatExamples(rootCommand.examples) - if (examples) cmd.examples = examples - result.push(cmd) - } - for (const [name, entry] of commands) { - const entryPath = [...prefix, name] - if ('_group' in entry && entry._group) { - if (entry.description) groups.set(entryPath.join(' '), entry.description) - result.push(...collectEntries(entry.commands, entryPath, groups)) - } else { - const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } - if (entry.description) cmd.description = entry.description - if (entry.args) cmd.args = entry.args - if (entry.env) cmd.env = entry.env - if (entry.hint) cmd.hint = entry.hint - if (entry.options) cmd.options = entry.options - if (entry.output) cmd.output = entry.output - const examples = formatExamples(entry.examples) - if (examples) { - const cmdName = entryPath.join(' ') - cmd.examples = examples.map((e) => ({ - ...e, - command: e.command ? `${cmdName} ${e.command}` : cmdName, - })) - } - result.push(cmd) - } - } - return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) -} - -function parseFrontmatter(content: string): { - description?: string | undefined - name?: string | undefined -} { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match) return {} - - const meta = yamlParse(match[1]!) - if (!meta || typeof meta !== 'object') return {} - return meta as { description?: string | undefined; name?: string | undefined } -} - /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */ function resolvePackageRoot(): string { const bin = process.argv[1] From 375c0ebc4c82ee2847f3dc4be1724ffc28599f34 Mon Sep 17 00:00:00 2001 From: 0xpolarzero <0xpolarzero@gmail.com> Date: Wed, 27 May 2026 19:26:10 +0200 Subject: [PATCH 5/5] chore: add changeset for skill projection fix --- .changeset/tame-pillows-accept.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/tame-pillows-accept.md diff --git a/.changeset/tame-pillows-accept.md b/.changeset/tame-pillows-accept.md new file mode 100644 index 0000000..84bce73 --- /dev/null +++ b/.changeset/tame-pillows-accept.md @@ -0,0 +1,7 @@ +--- +'incur': patch +--- + +Fixed generated and synced skills to use the same command projection as CLI skill output. + +`Skillgen` and `SyncSkills` now avoid generating duplicate skills for command aliases, preserve output schemas and examples consistently, and include the fetch gateway skill hint for fetch-based commands.