Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/skills-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"incur": patch
---

Added `skills list` subcommand that shows all skills a CLI defines with install status.
25 changes: 22 additions & 3 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
Expand Down Expand Up @@ -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 <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
Expand Down Expand Up @@ -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 <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
Expand Down Expand Up @@ -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', () => {
Expand Down
50 changes: 48 additions & 2 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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'))
Expand Down
2 changes: 1 addition & 1 deletion src/Help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> Load JSON option defaults from a file
Expand Down
4 changes: 2 additions & 2 deletions src/Help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export declare namespace formatRoot {
export declare namespace formatCommand {
type Options = {
/** Map of option names to single-char aliases. */
alias?: Record<string, string> | undefined
alias?: Partial<Record<string, string>> | undefined
/** Alternative binary names for this CLI. */
aliases?: string[] | undefined
/** Zod schema for positional arguments. */
Expand Down Expand Up @@ -261,7 +261,7 @@ function envEntries(schema: z.ZodObject<any>) {
}

/** Extracts option entries from a Zod object schema. */
function optionEntries(schema: z.ZodObject<any>, alias?: Record<string, string> | undefined) {
function optionEntries(schema: z.ZodObject<any>, alias?: Partial<Record<string, string>> | undefined) {
const entries: {
flag: string
description: string
Expand Down
3 changes: 2 additions & 1 deletion src/Skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
63 changes: 63 additions & 0 deletions src/SyncSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
78 changes: 78 additions & 0 deletions src/SyncSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
options: list.Options = {},
): Promise<list.Skill[]> {
const { depth = 1, description } = options
const cwd = options.cwd ?? process.cwd()

const groups = new Map<string, string>()
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<string, any>,
Expand Down
4 changes: 2 additions & 2 deletions src/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
Expand Down Expand Up @@ -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 <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
Expand Down
4 changes: 4 additions & 0 deletions src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading