import type { Command } from 'commander'; import { resolve } from 'path'; import { homedir } from 'os'; import type { Skill } from '../skills/index.js'; import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; export interface SkillListRow { name: string; tier: Skill['manifest']['tier']; status: 'available' | 'unavailable'; reason?: string; } export interface SkillRefreshSummary { total: number; available: number; unavailable: number; tiers: Record; } export interface SkillInstallerPlanView { skill: { name: string; tier: Skill['manifest']['tier']; version: string; }; mode: 'dry-run'; steps: Array<{ installerType: string; command: string }>; skipped: Array<{ installerType: string; reason: string }>; } export interface SkillInstallPreflightView { sourcePath: string; skill: SkillInstallerPlanView['skill']; mode: 'dry-run'; steps: SkillInstallerPlanView['steps']; skipped: SkillInstallerPlanView['skipped']; } 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('; ')}`); } if (skill.manifest.installers && skill.manifest.installers.length > 0) { const plan = buildInstallerPlan(skill.manifest.installers); lines.push(`Installer plan mode: ${plan.mode}`); if (plan.steps.length > 0) { lines.push('Installer planned steps:'); for (const step of plan.steps) { lines.push(`- [${step.installerType}] ${step.command}`); } } if (plan.skipped.length > 0) { lines.push('Installer skipped steps:'); for (const skip of plan.skipped) { lines.push(`- [${skip.installerType}] ${skip.reason}`); } } } return lines.join('\n'); } export function toSkillInstallerPlanView(skill: Skill): SkillInstallerPlanView { const plan = buildInstallerPlan(skill.manifest.installers); return { skill: { name: skill.manifest.name, tier: skill.manifest.tier, version: skill.manifest.version, }, mode: plan.mode, steps: plan.steps, skipped: plan.skipped, }; } export function renderSkillInstallerPlan(view: SkillInstallerPlanView): string { const lines: string[] = [ `Installer plan for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`, `Mode: ${view.mode}`, ]; if (view.steps.length === 0) { lines.push('Planned steps: none'); } else { lines.push('Planned steps:'); for (const step of view.steps) { lines.push(`- [${step.installerType}] ${step.command}`); } } if (view.skipped.length === 0) { lines.push('Skipped steps: none'); } else { lines.push('Skipped steps:'); for (const skip of view.skipped) { lines.push(`- [${skip.installerType}] ${skip.reason}`); } } return lines.join('\n'); } export function toSkillInstallPreflightView(sourcePath: string): SkillInstallPreflightView | null { const sourceSkill = loadSkill(resolve(sourcePath), 'managed'); if (!sourceSkill) { return null; } const planView = toSkillInstallerPlanView(sourceSkill); return { sourcePath: resolve(sourcePath), skill: planView.skill, mode: planView.mode, steps: planView.steps, skipped: planView.skipped, }; } export function renderSkillInstallPreflight(view: SkillInstallPreflightView): string { const lines: string[] = [ `Install preflight for '${view.skill.name}' from ${view.sourcePath}`, `Mode: ${view.mode}`, ]; if (view.steps.length === 0) { lines.push('Planned installer steps: none'); } else { lines.push('Planned installer steps:'); for (const step of view.steps) { lines.push(`- [${step.installerType}] ${step.command}`); } } if (view.skipped.length > 0) { lines.push('Skipped installer steps:'); for (const skip of view.skipped) { lines.push(`- [${skip.installerType}] ${skip.reason}`); } } return lines.join('\n'); } export function summarizeSkillsRefresh(skills: Skill[]): SkillRefreshSummary { const summary: SkillRefreshSummary = { total: skills.length, available: 0, unavailable: 0, tiers: { bundled: 0, managed: 0, workspace: 0, }, }; for (const skill of skills) { if (skill.available) { summary.available += 1; } else { summary.unavailable += 1; } summary.tiers[skill.manifest.tier] += 1; } return summary; } export function renderSkillsRefreshSummary(summary: SkillRefreshSummary): string { return [ `Refreshed ${summary.total} skills (${summary.available} available, ${summary.unavailable} unavailable).`, `By tier: bundled=${summary.tiers.bundled}, managed=${summary.tiers.managed}, workspace=${summary.tiers.workspace}`, ].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 installSkillFromDirectory(installer: SkillInstaller, sourcePath: string): { skill?: Skill; error?: string } { const sourceDir = resolve(sourcePath); try { const skill = installer.install(sourceDir); if (!skill) { return { error: `Failed to load skill from '${sourceDir}' after installation.` }; } return { skill }; } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } } export function uninstallSkillByName( installer: SkillInstaller, name: string, opts: { confirm: boolean; discoveredSkill?: Skill }, ): { removed?: true; error?: string } { if (!opts.confirm) { return { error: 'Refusing to uninstall without --yes. Re-run with --yes to confirm.' }; } if (opts.discoveredSkill && opts.discoveredSkill.manifest.tier !== 'managed' && !installer.isInstalled(name)) { return { error: `Skill '${name}' is '${opts.discoveredSkill.manifest.tier}' and cannot be uninstalled from managed skills.`, }; } if (!installer.uninstall(name)) { return { error: `Managed skill '${name}' is not installed.` }; } return { removed: true }; } 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)); }); skills .command('install ') .description('Install a skill from a local directory') .option('--json', 'Output preflight and install result as JSON') .option('--preflight-only', 'Show installer preflight without performing install') .option('-c, --config ', 'Config file path') .action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; config?: string }) => { const loaded = loadConfigSafe(opts.config); if (loaded.error || !loaded.config) { console.error(loaded.error ?? 'Failed to load config'); process.exitCode = 1; return; } const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); const installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir); const preflight = toSkillInstallPreflightView(pathArg); if (opts.preflightOnly) { if (!preflight) { console.error(`Failed to generate install preflight from '${resolve(pathArg)}'.`); process.exitCode = 1; return; } if (opts.json) { console.log(JSON.stringify({ preflight }, null, 2)); return; } console.log(renderSkillInstallPreflight(preflight)); return; } if (preflight) { if (opts.json) { console.log(JSON.stringify({ preflight }, null, 2)); } else { console.log(renderSkillInstallPreflight(preflight)); } } const result = installSkillFromDirectory(installer, pathArg); if (result.error || !result.skill) { console.error(result.error ?? `Failed to install skill from '${pathArg}'.`); process.exitCode = 1; return; } if (opts.json) { console.log( JSON.stringify( { installed: { name: result.skill.manifest.name, version: result.skill.manifest.version, tier: result.skill.manifest.tier, directory: result.skill.directory, }, }, null, 2, ), ); return; } console.log(`Installed skill '${result.skill.manifest.name}' (${result.skill.manifest.version}).`); }); skills .command('uninstall ') .description('Uninstall a managed skill by name') .option('--yes', 'Confirm uninstall without prompt') .option('-c, --config ', 'Config file path') .action((name: string, opts: { yes?: boolean; config?: string }) => { const loaded = loadConfigSafe(opts.config); if (loaded.error || !loaded.config) { console.error(loaded.error ?? 'Failed to load config'); process.exitCode = 1; return; } const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); const installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir); const discoveredSkills = loadAllSkills({ bundledDir: loaded.config.skills.bundled_dir, managedDir: loaded.config.skills.managed_dir ?? defaultManagedDir, workspaceDir: loaded.config.skills.workspace_dir, }); const discovered = discoveredSkills.find( (skill) => skill.manifest.name === name && skill.manifest.tier === 'managed', ) ?? discoveredSkills.find((skill) => skill.manifest.name === name); const result = uninstallSkillByName(installer, name, { confirm: opts.yes ?? false, discoveredSkill: discovered, }); if (result.error || !result.removed) { console.error(result.error ?? `Failed to uninstall skill '${name}'.`); process.exitCode = 1; return; } console.log(`Uninstalled skill '${name}'.`); }); skills .command('refresh') .description('Refresh skill discovery and print summary') .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 summary = summarizeSkillsRefresh(loaded.skills); if (opts.json) { console.log(JSON.stringify(summary, null, 2)); return; } console.log(renderSkillsRefreshSummary(summary)); }); skills .command('plan ') .description('Print dry-run installer plan 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; } const view = toSkillInstallerPlanView(skill); if (opts.json) { console.log(JSON.stringify(view, null, 2)); return; } console.log(renderSkillInstallerPlan(view)); }); }