From c05f05e0349b614f8fc823d74c89695cf89627ae Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 6 Apr 2026 17:00:03 -0400 Subject: [PATCH 1/2] feat: add skills list subcommand --- .changeset/skills-list.md | 5 +++ src/Cli.test.ts | 25 +++++++++++-- src/Cli.ts | 50 ++++++++++++++++++++++++- src/Help.test.ts | 2 +- src/Skill.ts | 3 +- src/SyncSkills.test.ts | 63 +++++++++++++++++++++++++++++++ src/SyncSkills.ts | 78 +++++++++++++++++++++++++++++++++++++++ src/e2e.test.ts | 4 +- src/internal/command.ts | 4 ++ 9 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 .changeset/skills-list.md diff --git a/.changeset/skills-list.md b/.changeset/skills-list.md new file mode 100644 index 0000000..78e2262 --- /dev/null +++ b/.changeset/skills-list.md @@ -0,0 +1,5 @@ +--- +"incur": patch +--- + +Added `skills list` subcommand that shows all skills a CLI defines with install status. Fixed double-period in generated skill descriptions when command descriptions ended with a period. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 17b2e3c..22b043f 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -2030,7 +2030,7 @@ describe('help', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) @@ -2068,7 +2068,7 @@ describe('help', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) @@ -2231,7 +2231,7 @@ describe('help', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) @@ -2602,6 +2602,25 @@ describe('built-in commands', () => { expect(output).toContain('--depth') expect(output).toContain('--no-global') }) + + test('skills list --help shows description', async () => { + const cli = Cli.create('test') + cli.command('ping', { run: () => ({ pong: true }) }) + const { output } = await serve(cli, ['skills', 'list', '--help']) + expect(output).toContain('test skills list') + expect(output).toContain('List skills') + }) + + test('skills list shows skills with install status', async () => { + const cli = Cli.create('test') + cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) }) + cli.command('greet', { description: 'Say hello', run: () => ({ hi: true }) }) + const { output } = await serve(cli, ['skills', 'list']) + expect(output).toContain('✗') + expect(output).toContain('test-ping') + expect(output).toContain('test-greet') + expect(output).toContain('installed') + }) }) describe('skills staleness', () => { diff --git a/src/Cli.ts b/src/Cli.ts index 58f6267..97725e3 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -656,8 +656,8 @@ async function serveImpl( filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1 if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') { const skillsSub = filtered[skillsIdx + 1] - if (skillsSub && skillsSub !== 'add') { - const suggestion = suggest(skillsSub, ['add']) + if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') { + const suggestion = suggest(skillsSub, ['add', 'list']) const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : '' const message = `'${skillsSub}' is not a command for '${name} skills'.${didYouMean}` const ctaCommands: FormattedCta[] = [] @@ -685,6 +685,52 @@ async function serveImpl( writeln(formatBuiltinHelp(name, b)) return } + if (skillsSub === 'list') { + if (help) { + const b = builtinCommands.find((c) => c.name === 'skills')! + writeln(formatBuiltinSubcommandHelp(name, b, 'list')) + return + } + try { + const result = await SyncSkills.list(name, commands, { + cwd: options.sync?.cwd, + depth: options.sync?.depth ?? 1, + description: options.description, + include: options.sync?.include, + }) + if (result.length === 0) { + writeln('No skills found.') + return + } + const lines: string[] = [] + const maxLen = Math.max(...result.map((s) => s.name.length)) + for (const s of result) { + const icon = s.installed ? '✓' : '✗' + const padding = s.description + ? `${' '.repeat(maxLen - s.name.length)} ${s.description}` + : '' + lines.push(` ${icon} ${s.name}${padding}`) + } + const installedCount = result.filter((s) => s.installed).length + lines.push('') + lines.push( + `${result.length} skill${result.length === 1 ? '' : 's'} (${installedCount} installed)`, + ) + writeln(lines.join('\n')) + } catch (err) { + writeln( + Formatter.format( + { + code: 'LIST_SKILLS_FAILED', + message: err instanceof Error ? err.message : String(err), + }, + formatExplicit ? formatFlag : 'toon', + ), + ) + exit(1) + } + return + } if (help) { const b = builtinCommands.find((c) => c.name === 'skills')! writeln(formatBuiltinSubcommandHelp(name, b, 'add')) diff --git a/src/Help.test.ts b/src/Help.test.ts index c759eb6..324bb1e 100644 --- a/src/Help.test.ts +++ b/src/Help.test.ts @@ -344,7 +344,7 @@ describe('formatRoot', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --config Load JSON option defaults from a file diff --git a/src/Skill.ts b/src/Skill.ts index 5c93190..f2ae5a3 100644 --- a/src/Skill.ts +++ b/src/Skill.ts @@ -133,7 +133,8 @@ function renderGroup( const childDescs = cmds.map((c) => c.description).filter(Boolean) as string[] const descParts: string[] = [] if (groupDesc) descParts.push(groupDesc.replace(/\.$/, '')) - if (childDescs.length > 0) descParts.push(childDescs.join(', ')) + if (childDescs.length > 0) + descParts.push(childDescs.map((d) => d.replace(/\.$/, '')).join(', ')) const description = descParts.length > 0 ? `${descParts.join('. ')}. Run \`${title} --help\` for usage details.` diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 28541cc..2bc8585 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -125,3 +125,66 @@ test('installed SKILL.md contains frontmatter', async () => { rmSync(tmp, { recursive: true, force: true }) }) + +test('list returns skills from command map', async () => { + const cli = Cli.create('test', { description: 'A test CLI' }) + cli.command('ping', { description: 'Health check', run: () => ({}) }) + cli.command('greet', { description: 'Say hello', run: () => ({}) }) + + const commands = Cli.toCommands.get(cli)! + const result = await SyncSkills.list('test', commands) + + expect(result.length).toBeGreaterThan(0) + const names = result.map((s) => s.name) + expect(names).toContain('test-ping') + expect(names).toContain('test-greet') + for (const s of result) { + expect(s.installed).toBe(false) + expect(s.description).toBeDefined() + } +}) + +test('list shows installed status after sync', async () => { + const tmp = join(tmpdir(), `clac-list-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + process.env.XDG_DATA_HOME = tmp + + const cli = Cli.create('test') + cli.command('ping', { description: 'Ping', run: () => ({}) }) + + const commands = Cli.toCommands.get(cli)! + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + + // Sync first to install + await SyncSkills.sync('test', commands, { + global: false, + cwd: installDir, + }) + + // Now list should show installed + const result = await SyncSkills.list('test', commands) + expect(result.length).toBeGreaterThan(0) + for (const s of result) expect(s.installed).toBe(true) + + rmSync(tmp, { recursive: true, force: true }) +}) + +test('list returns empty for CLI with no commands', async () => { + const cli = Cli.create('empty') + const commands = Cli.toCommands.get(cli)! + const result = await SyncSkills.list('empty', commands) + expect(result).toHaveLength(0) +}) + +test('list results are sorted alphabetically', async () => { + const cli = Cli.create('test') + cli.command('zebra', { description: 'Z command', run: () => ({}) }) + cli.command('alpha', { description: 'A command', run: () => ({}) }) + cli.command('middle', { description: 'M command', run: () => ({}) }) + + const commands = Cli.toCommands.get(cli)! + const result = await SyncSkills.list('test', commands) + const names = result.map((s) => s.name) + expect(names).toEqual([...names].sort()) +}) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index a5b0106..abf79d0 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -125,6 +125,84 @@ export declare namespace sync { } } +/** Lists skills derived from a CLI's command map with install status. */ +export async function list( + name: string, + commands: Map, + options: list.Options = {}, +): Promise { + const { depth = 1, description } = options + const cwd = options.cwd ?? process.cwd() + + const groups = new Map() + if (description) groups.set(name, description) + const entries = collectEntries(commands, [], groups) + const files = Skill.split(name, entries, depth, groups) + + const skills: list.Skill[] = [] + const meta = readMeta(name) + const installed = new Set(meta?.skills) + + for (const file of files) { + const nameMatch = file.content.match(/^name:\s*(.+)$/m) + const descMatch = file.content.match(/^description:\s*(.+)$/m) + const skillName = nameMatch?.[1] ?? (file.dir || name) + skills.push({ + name: skillName, + description: descMatch?.[1], + installed: installed.has(skillName), + }) + } + + // Include additional SKILL.md files matched by glob patterns + if (options.include) { + for (const pattern of options.include) { + const globPattern = pattern === '_root' ? 'SKILL.md' : path.join(pattern, 'SKILL.md') + for await (const match of fs.glob(globPattern, { cwd })) { + try { + const content = await fs.readFile(path.resolve(cwd, match), 'utf8') + const nameMatch = content.match(/^name:\s*(.+)$/m) + const skillName = + pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match)) + if (!skills.some((s) => s.name === skillName)) { + const descMatch = content.match(/^description:\s*(.+)$/m) + skills.push({ + name: skillName, + description: descMatch?.[1], + installed: installed.has(skillName), + }) + } + } catch {} + } + } + } + + return skills.sort((a, b) => a.name.localeCompare(b.name)) +} + +export declare namespace list { + /** Options for listing skills. */ + type Options = { + /** Working directory for resolving `include` globs. Defaults to `process.cwd()`. */ + cwd?: string | undefined + /** Grouping depth for skill files. Defaults to `1`. */ + depth?: number | undefined + /** CLI description, used as the top-level group description. */ + description?: string | undefined + /** Glob patterns for directories containing SKILL.md files to include. */ + include?: string[] | undefined + } + /** A skill entry with install status. */ + type Skill = { + /** Description extracted from the skill frontmatter. */ + description?: string | undefined + /** Whether this skill is currently installed. */ + installed: boolean + /** Skill name. */ + name: string + } +} + /** Recursively collects leaf commands as `Skill.CommandInfo`. */ function collectEntries( commands: Map, diff --git a/src/e2e.test.ts b/src/e2e.test.ts index b8a6a84..c37a33c 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -967,7 +967,7 @@ describe('help', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) @@ -1742,7 +1742,7 @@ describe('root command with subcommands', () => { Integrations: completions Generate shell completion script mcp add Register as MCP server - skills add Sync skill files to agents + skills Sync skill files to agents (add, list) Global Options: --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) diff --git a/src/internal/command.ts b/src/internal/command.ts index d6e7147..e3bff84 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -422,6 +422,10 @@ export const builtinCommands = [ noGlobal: z.boolean().optional().describe('Install to project instead of globally'), }), }), + subcommand({ + name: 'list', + description: 'List skills', + }), ], }, ] satisfies { From a5bc06c024aafa79a692b9ac607f0ee76b4c355c Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 6 Apr 2026 17:02:21 -0400 Subject: [PATCH 2/2] fix: resolve tsc -b type error for builtin subcommand alias union --- .changeset/skills-list.md | 2 +- src/Help.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/skills-list.md b/.changeset/skills-list.md index 78e2262..1729361 100644 --- a/.changeset/skills-list.md +++ b/.changeset/skills-list.md @@ -2,4 +2,4 @@ "incur": patch --- -Added `skills list` subcommand that shows all skills a CLI defines with install status. Fixed double-period in generated skill descriptions when command descriptions ended with a period. +Added `skills list` subcommand that shows all skills a CLI defines with install status. diff --git a/src/Help.ts b/src/Help.ts index b2a1102..4828a83 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -56,7 +56,7 @@ export declare namespace formatRoot { export declare namespace formatCommand { type Options = { /** Map of option names to single-char aliases. */ - alias?: Record | undefined + alias?: Partial> | undefined /** Alternative binary names for this CLI. */ aliases?: string[] | undefined /** Zod schema for positional arguments. */ @@ -261,7 +261,7 @@ function envEntries(schema: z.ZodObject) { } /** Extracts option entries from a Zod object schema. */ -function optionEntries(schema: z.ZodObject, alias?: Record | undefined) { +function optionEntries(schema: z.ZodObject, alias?: Partial> | undefined) { const entries: { flag: string description: string