feat(skills): guard uninstall with explicit confirmation

This commit is contained in:
William Valentin
2026-02-12 16:59:50 -08:00
parent d5b7d72e5d
commit 2d753321b3
3 changed files with 136 additions and 3 deletions
+65 -1
View File
@@ -3,7 +3,13 @@ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { SkillInstaller } from '../skills/index.js';
import { toSkillListRows, renderSkillsTable, renderSkillInfo, installSkillFromDirectory } from './skills.js';
import {
toSkillListRows,
renderSkillsTable,
renderSkillInfo,
installSkillFromDirectory,
uninstallSkillByName,
} from './skills.js';
import type { Skill } from '../skills/index.js';
function buildSkill(overrides: Partial<Skill>): Skill {
@@ -129,4 +135,62 @@ describe('skills CLI helpers', () => {
rmSync(root, { recursive: true, force: true });
});
it('requires --yes confirmation for uninstall helper', () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const installer = new SkillInstaller(join(root, 'managed'));
const result = uninstallSkillByName(installer, 'any-skill', { confirm: false });
expect(result.removed).toBeUndefined();
expect(result.error).toContain('--yes');
rmSync(root, { recursive: true, force: true });
});
it('uninstalls managed skill when confirmed', () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const sourceDir = join(root, 'source-skill');
const managedDir = join(root, 'managed');
mkdirSync(sourceDir, { recursive: true });
writeFileSync(join(sourceDir, 'SKILL.md'), '# Managed\nInstructions');
writeFileSync(
join(sourceDir, 'manifest.json'),
JSON.stringify({ name: 'managed-skill', description: 'Managed', version: '1.0.0' }),
'utf-8',
);
const installer = new SkillInstaller(managedDir);
installSkillFromDirectory(installer, sourceDir);
const result = uninstallSkillByName(installer, 'managed-skill', { confirm: true });
expect(result.error).toBeUndefined();
expect(result.removed).toBe(true);
expect(existsSync(join(managedDir, 'managed-skill', 'SKILL.md'))).toBe(false);
rmSync(root, { recursive: true, force: true });
});
it('blocks uninstall of bundled/workspace-only skill', () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const installer = new SkillInstaller(join(root, 'managed'));
const result = uninstallSkillByName(installer, 'bundled-only', {
confirm: true,
discoveredSkill: buildSkill({
manifest: {
name: 'bundled-only',
description: 'Bundled',
version: '1.0.0',
tier: 'bundled',
},
}),
});
expect(result.removed).toBeUndefined();
expect(result.error).toContain('cannot be uninstalled from managed skills');
rmSync(root, { recursive: true, force: true });
});
});
+60
View File
@@ -100,6 +100,28 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath:
}
}
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')
@@ -182,4 +204,42 @@ export function registerSkillsCommand(program: Command): void {
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}'.`);
});
}