From 2d753321b3382383791845ea03f88c05d50d6c72 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 16:59:50 -0800 Subject: [PATCH] feat(skills): guard uninstall with explicit confirmation --- docs/plans/state.json | 13 +++++++-- src/cli/skills.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++- src/cli/skills.ts | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index c0d07f9..3e13e30 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1237,6 +1237,15 @@ "src/cli/skills.test.ts" ], "test_status": "typecheck + targeted skills CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" + }, + "skills_uninstall_command": { + "status": "completed", + "description": "Added `flynn skills uninstall ` command dispatch with --yes confirmation guard and managed-tier uninstall protection", + "files_modified": [ + "src/cli/skills.ts", + "src/cli/skills.test.ts" + ], + "test_status": "typecheck + targeted skills CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" } } }, @@ -1277,7 +1286,7 @@ }, "overall_progress": { - "total_test_count": 1497, + "total_test_count": 1500, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1297,7 +1306,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure Phase 1: add `flynn skills uninstall ` command dispatch" + "next_up": "Skills infrastructure Phase 1: add `flynn skills refresh` command dispatch" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 25ddb71..625ee97 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -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 { @@ -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 }); + }); }); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index aaf02b9..d2d0cf3 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -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 ') + .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}'.`); + }); }