diff --git a/docs/plans/state.json b/docs/plans/state.json index 9fd23b8..47c3231 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1397,6 +1397,15 @@ "src/cli/skills.test.ts" ], "test_status": "pnpm typecheck + pnpm test:run src/cli/skills.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" + }, + "shared_install_action_modes": { + "status": "completed", + "description": "Centralized install action handling into shared plan-only/stub/install modes and wired install command options through the shared flow while keeping real installer execution disabled", + "files_modified": [ + "src/cli/skills.ts", + "src/cli/skills.test.ts" + ], + "test_status": "pnpm typecheck + pnpm test:run src/cli/skills.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" } } } @@ -1425,7 +1434,7 @@ }, "overall_progress": { - "total_test_count": 1531, + "total_test_count": 1533, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1445,7 +1454,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 3: add shared install action modes (plan-only/stub/install) to reduce CLI duplication while keeping execution disabled" + "next_up": "Skills infrastructure Phase 3: add explicit safety confirmations and no-op execution receipts for future real installer execution path" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 5ca5a8f..5ac8a30 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -17,6 +17,8 @@ import { renderSkillInstallPreflight, toSkillInstallerExecutionStubView, renderSkillInstallerExecutionStub, + toSkillInstallerExecutionStubFromPreflight, + runSkillInstallAction, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -247,6 +249,21 @@ describe('skills CLI helpers', () => { expect(output).toContain('Skipped:'); }); + it('derives execution stub view from preflight data', () => { + const preflight = { + sourcePath: '/tmp/source-skill', + skill: { name: 'exec-stub', tier: 'managed' as const, version: '1.0.0' }, + mode: 'dry-run' as const, + steps: [{ installerType: 'download', command: 'download https://example.com/a.tgz -> /tmp/a.tgz' }], + skipped: [], + }; + + const view = toSkillInstallerExecutionStubFromPreflight(preflight); + + expect(view.execution).toBe('stub'); + expect(view.wouldRun).toEqual(['download https://example.com/a.tgz -> /tmp/a.tgz']); + }); + it('summarizes refresh counts across status and tiers', () => { const summary = summarizeSkillsRefresh([ buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }), @@ -306,6 +323,27 @@ describe('skills CLI helpers', () => { rmSync(root, { recursive: true, force: true }); }); + it('supports plan-only install action mode without installing', () => { + 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'), '# Plan Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ name: 'plan-skill', description: 'Plan only', version: '1.0.0' }), + 'utf-8', + ); + + const installer = new SkillInstaller(managedDir); + const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false }); + + expect(result.ok).toBe(true); + expect(existsSync(join(managedDir, 'plan-skill', 'SKILL.md'))).toBe(false); + + 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')); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 8b70ba1..efb3ba6 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -45,6 +45,8 @@ export interface SkillInstallerExecutionStubView { skipped: SkillInstallerPlanView['skipped']; } +export type SkillInstallActionMode = 'plan-only' | 'stub' | 'install'; + export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ @@ -213,6 +215,17 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE }; } +export function toSkillInstallerExecutionStubFromPreflight( + preflight: SkillInstallPreflightView, +): SkillInstallerExecutionStubView { + return { + skill: preflight.skill, + execution: 'stub', + wouldRun: preflight.steps.map((step) => step.command), + skipped: preflight.skipped, + }; +} + export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string { const lines: string[] = [ `Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`, @@ -298,6 +311,73 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath: } } +export function runSkillInstallAction( + installer: SkillInstaller, + sourcePath: string, + opts: { mode: SkillInstallActionMode; asJson: boolean }, +): { ok: true } | { ok: false; error: string } { + const preflight = toSkillInstallPreflightView(sourcePath); + + if (opts.mode === 'plan-only') { + if (!preflight) { + return { ok: false, error: `Failed to generate install preflight from '${resolve(sourcePath)}'.` }; + } + if (opts.asJson) { + console.log(JSON.stringify({ preflight }, null, 2)); + } else { + console.log(renderSkillInstallPreflight(preflight)); + } + return { ok: true }; + } + + if (opts.mode === 'stub') { + if (!preflight) { + return { ok: false, error: `Failed to generate installer execution stub from '${resolve(sourcePath)}'.` }; + } + const stub = toSkillInstallerExecutionStubFromPreflight(preflight); + if (opts.asJson) { + console.log(JSON.stringify({ execution: stub }, null, 2)); + } else { + console.log(renderSkillInstallerExecutionStub(stub)); + } + return { ok: true }; + } + + if (preflight) { + if (opts.asJson) { + console.log(JSON.stringify({ preflight }, null, 2)); + } else { + console.log(renderSkillInstallPreflight(preflight)); + } + } + + const result = installSkillFromDirectory(installer, sourcePath); + if (result.error || !result.skill) { + return { ok: false, error: result.error ?? `Failed to install skill from '${sourcePath}'.` }; + } + + if (opts.asJson) { + 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, + ), + ); + } else { + console.log(`Installed skill '${result.skill.manifest.name}' (${result.skill.manifest.version}).`); + } + + return { ok: true }; +} + export function uninstallSkillByName( installer: SkillInstaller, name: string, @@ -383,8 +463,9 @@ export function registerSkillsCommand(program: Command): void { .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('--stub', 'Show installer execution stub without performing install') .option('-c, --config ', 'Config file path') - .action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; config?: string }) => { + .action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; config?: string }) => { const loaded = loadConfigSafe(opts.config); if (loaded.error || !loaded.config) { console.error(loaded.error ?? 'Failed to load config'); @@ -394,59 +475,17 @@ export function registerSkillsCommand(program: Command): void { 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; - } + const mode: SkillInstallActionMode = opts.preflightOnly ? 'plan-only' : opts.stub ? 'stub' : 'install'; + const result = runSkillInstallAction(installer, pathArg, { + mode, + asJson: opts.json ?? false, + }); - 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}'.`); + if (!result.ok) { + console.error(result.error); 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