Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tame-pillows-accept.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 19 additions & 2 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CommandEntry>,
prefix: string[],
groups: Map<string, string>,
rootCommand?: CommandDefinition<any, any, any> | undefined,
rootCommand?: SkillCommandSource | undefined,
): Skill.CommandInfo[] {
const result: Skill.CommandInfo[] = []
if (rootCommand) {
Expand Down Expand Up @@ -3018,6 +3018,11 @@ function collectSkillCommands(
return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
}

type SkillCommandSource = Pick<
CommandDefinition<any, any, any, any, any, any>,
'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<any, any>[] | undefined,
Expand All @@ -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<any> | undefined,
Expand Down
22 changes: 16 additions & 6 deletions src/Skillgen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,28 @@ 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)
const content = readFileSync(files[0]!, 'utf-8')
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')
})
41 changes: 6 additions & 35 deletions src/Skillgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export async function generate(input: string, output: string, depth = 1): Promis

const groups = new Map<string, string>()
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 })
Expand All @@ -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<string, any>,
prefix: string[],
groups: Map<string, string> = 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 ?? ''))
}
40 changes: 39 additions & 1 deletion src/SyncSkills.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: () => ({}) })
Expand Down
86 changes: 8 additions & 78 deletions src/SyncSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -19,7 +18,7 @@ export async function sync(

const groups = new Map<string, string>()
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}-`))
Expand All @@ -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 })
}

Expand All @@ -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')
Expand All @@ -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),
Expand Down Expand Up @@ -139,14 +138,14 @@ export async function list(

const groups = new Map<string, string>()
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,
Expand All @@ -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)) {
Expand Down Expand Up @@ -223,75 +222,6 @@ export declare namespace list {
}
}

/** Recursively collects leaf commands as `Skill.CommandInfo`. */
function collectEntries(
commands: Map<string, any>,
prefix: string[],
groups: Map<string, string> = 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]
Expand Down