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 1/2] 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 2/2] 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.