feat(skills): guard uninstall with explicit confirmation
This commit is contained in:
+11
-2
@@ -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 <name>` 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 <name>` command dispatch"
|
||||
"next_up": "Skills infrastructure Phase 1: add `flynn skills refresh` command dispatch"
|
||||
},
|
||||
"soul_md_and_cron_create": {
|
||||
"date": "2026-02-11",
|
||||
|
||||
+65
-1
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}'.`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user