import type { Command } from 'commander'; import { resolve } from 'path'; import { homedir } from 'os'; import type { Skill } from '../skills/index.js'; import { loadAllSkills } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; export interface SkillListRow { name: string; tier: Skill['manifest']['tier']; status: 'available' | 'unavailable'; reason?: string; } export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ name: skill.manifest.name, tier: skill.manifest.tier, status: (skill.available ? 'available' : 'unavailable') as SkillListRow['status'], reason: skill.unavailableReasons?.join('; '), })) .sort((a, b) => a.name.localeCompare(b.name)); } export function renderSkillsTable(rows: SkillListRow[]): string { if (rows.length === 0) { return 'No skills found.'; } const nameWidth = Math.max('NAME'.length, ...rows.map((row) => row.name.length)); const tierWidth = Math.max('TIER'.length, ...rows.map((row) => row.tier.length)); const statusWidth = Math.max('STATUS'.length, ...rows.map((row) => row.status.length)); const lines = [ `${'NAME'.padEnd(nameWidth)} ${'TIER'.padEnd(tierWidth)} ${'STATUS'.padEnd(statusWidth)} REASON`, `${'-'.repeat(nameWidth)} ${'-'.repeat(tierWidth)} ${'-'.repeat(statusWidth)} ------`, ]; for (const row of rows) { lines.push( `${row.name.padEnd(nameWidth)} ${row.tier.padEnd(tierWidth)} ${row.status.padEnd(statusWidth)} ${row.reason ?? ''}`, ); } return lines.join('\n'); } export function renderSkillInfo(skill: Skill): string { const lines: string[] = [ `Name: ${skill.manifest.name}`, `Description: ${skill.manifest.description}`, `Version: ${skill.manifest.version}`, `Tier: ${skill.manifest.tier}`, `Status: ${skill.available ? 'available' : 'unavailable'}`, `Directory: ${skill.directory}`, ]; if (skill.manifest.author) { lines.push(`Author: ${skill.manifest.author}`); } if (skill.manifest.tools && skill.manifest.tools.length > 0) { lines.push(`Tools: ${skill.manifest.tools.join(', ')}`); } if (skill.unavailableReasons && skill.unavailableReasons.length > 0) { lines.push(`Unavailable reasons: ${skill.unavailableReasons.join('; ')}`); } return lines.join('\n'); } function loadSkillsFromConfig(configPath?: string): { skills?: Skill[]; error?: string } { const loaded = loadConfigSafe(configPath); if (loaded.error || !loaded.config) { return { error: loaded.error ?? 'Failed to load config' }; } const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); const skills = loadAllSkills({ bundledDir: loaded.config.skills.bundled_dir, managedDir: loaded.config.skills.managed_dir ?? defaultManagedDir, workspaceDir: loaded.config.skills.workspace_dir, }); return { skills }; } export function registerSkillsCommand(program: Command): void { const skills = program .command('skills') .description('Manage Flynn skills') .action(() => { skills.outputHelp(); }); skills .command('list') .description('List discovered skills') .option('--json', 'Output as JSON') .option('-c, --config ', 'Config file path') .action((opts: { json?: boolean; config?: string }) => { const loaded = loadSkillsFromConfig(opts.config); if (loaded.error || !loaded.skills) { console.error(loaded.error ?? 'Failed to load skills'); process.exitCode = 1; return; } const rows = toSkillListRows(loaded.skills); if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; } console.log(renderSkillsTable(rows)); }); skills .command('info ') .description('Show details for a skill') .option('--json', 'Output as JSON') .option('-c, --config ', 'Config file path') .action((name: string, opts: { json?: boolean; config?: string }) => { const loaded = loadSkillsFromConfig(opts.config); if (loaded.error || !loaded.skills) { console.error(loaded.error ?? 'Failed to load skills'); process.exitCode = 1; return; } const skill = loaded.skills.find((item) => item.manifest.name === name); if (!skill) { console.error(`Skill '${name}' not found.`); process.exitCode = 1; return; } if (opts.json) { console.log(JSON.stringify(skill, null, 2)); return; } console.log(renderSkillInfo(skill)); }); }