570 lines
17 KiB
TypeScript
570 lines
17 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 interface SkillInstallerExecutionStubView {
|
|
skill: SkillInstallerPlanView['skill'];
|
|
execution: 'stub';
|
|
wouldRun: string[];
|
|
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 toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerExecutionStubView {
|
|
const plan = toSkillInstallerPlanView(skill);
|
|
return {
|
|
skill: plan.skill,
|
|
execution: 'stub',
|
|
wouldRun: plan.steps.map((step) => step.command),
|
|
skipped: plan.skipped,
|
|
};
|
|
}
|
|
|
|
export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string {
|
|
const lines: string[] = [
|
|
`Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`,
|
|
'No installer commands were executed.',
|
|
];
|
|
|
|
if (view.wouldRun.length === 0) {
|
|
lines.push('Would run: none');
|
|
} else {
|
|
lines.push('Would run:');
|
|
for (const command of view.wouldRun) {
|
|
lines.push(`- ${command}`);
|
|
}
|
|
}
|
|
|
|
if (view.skipped.length > 0) {
|
|
lines.push('Skipped:');
|
|
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));
|
|
});
|
|
|
|
skills
|
|
.command('execute <name>')
|
|
.description('Preview installer execution steps (stub only; no commands run)')
|
|
.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 = toSkillInstallerExecutionStubView(skill);
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(view, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log(renderSkillInstallerExecutionStub(view));
|
|
});
|
|
}
|