Files
flynn/src/cli/skills.ts
T
2026-02-12 18:17:46 -08:00

499 lines
15 KiB
TypeScript

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<Skill['manifest']['tier'], number>;
}
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 <path>', '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 <name>')
.description('Show details for a skill')
.option('--json', 'Output as JSON')
.option('-c, --config <path>', '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 <path>')
.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 <path>', '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 <name>')
.description('Uninstall a managed skill by name')
.option('--yes', 'Confirm uninstall without prompt')
.option('-c, --config <path>', '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 <path>', '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 <name>')
.description('Print dry-run installer plan for a skill')
.option('--json', 'Output as JSON')
.option('-c, --config <path>', '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));
});
}