diff --git a/docs/plans/state.json b/docs/plans/state.json index 2955b5f..4ab7bb8 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1424,6 +1424,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" + }, + "installer_runner_interface_noop": { + "status": "completed", + "description": "Added a pluggable installer command runner interface with policy-gated dispatch and a default no-op runner, preserving execution-disabled behavior while preparing a future real runner", + "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" } } } @@ -1452,7 +1461,7 @@ }, "overall_progress": { - "total_test_count": 1537, + "total_test_count": 1540, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1472,7 +1481,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: introduce a pluggable installer command runner interface behind the existing execution policy gates (default no-op)" + "next_up": "Skills infrastructure Phase 3: add structured per-step execution result envelopes for the future real runner while keeping no-op default" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 9577097..6622eb1 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -19,6 +19,8 @@ import { renderSkillInstallerExecutionStub, toSkillInstallerExecutionStubFromPreflight, evaluateInstallerExecutionPolicy, + runInstallerCommandsWithPolicy, + noOpSkillInstallerCommandRunner, runSkillInstallAction, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -296,6 +298,36 @@ describe('skills CLI helpers', () => { expect(policy.reason).toBe('execution_disabled'); }); + it('does not invoke command runner when policy disables execution', () => { + const runner = { + run: vi.fn((_commands: string[]) => ['should-not-run']), + }; + + const executed = runInstallerCommandsWithPolicy( + ['brew install jq'], + { confirmed: false, execution_enabled: false, reason: 'confirmation_required' }, + runner, + ); + + expect(executed).toEqual([]); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('supports pluggable command runner when policy enables execution', () => { + const runner = { + run: vi.fn((commands: string[]) => commands), + }; + + const executed = runInstallerCommandsWithPolicy( + ['brew install jq'], + { confirmed: true, execution_enabled: true, reason: 'execution_disabled' }, + runner, + ); + + expect(executed).toEqual(['brew install jq']); + expect(runner.run).toHaveBeenCalledWith(['brew install jq']); + }); + it('summarizes refresh counts across status and tiers', () => { const summary = summarizeSkillsRefresh([ buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }), @@ -436,6 +468,34 @@ describe('skills CLI helpers', () => { rmSync(root, { recursive: true, force: true }); }); + it('uses no-op command runner by default in install flow', () => { + 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'), '# Install Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ name: 'install-skill', description: 'Install', version: '1.0.0' }), + 'utf-8', + ); + + const installer = new SkillInstaller(managedDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const runnerSpy = vi.spyOn(noOpSkillInstallerCommandRunner, 'run'); + + const result = runSkillInstallAction(installer, sourceDir, { mode: 'install', asJson: true, confirmed: true }); + + expect(result.ok).toBe(true); + expect(runnerSpy).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0])); + expect(payload.execution.executed).toEqual([]); + + runnerSpy.mockRestore(); + logSpy.mockRestore(); + 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 4f35356..ab48dce 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -59,6 +59,16 @@ export interface SkillInstallerExecutionPolicy { reason: SkillInstallerExecutionReason; } +export interface SkillInstallerCommandRunner { + run(commands: string[]): string[]; +} + +export const noOpSkillInstallerCommandRunner: SkillInstallerCommandRunner = { + run(_commands: string[]): string[] { + return []; + }, +}; + export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ @@ -271,6 +281,18 @@ export function evaluateInstallerExecutionPolicy(opts: { }; } +export function runInstallerCommandsWithPolicy( + commands: string[], + policy: SkillInstallerExecutionPolicy, + runner: SkillInstallerCommandRunner, +): string[] { + if (!policy.execution_enabled) { + return []; + } + + return runner.run(commands); +} + export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string { const lines: string[] = [ `Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`, @@ -359,7 +381,12 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath: export function runSkillInstallAction( installer: SkillInstaller, sourcePath: string, - opts: { mode: SkillInstallActionMode; asJson: boolean; confirmed: boolean }, + opts: { + mode: SkillInstallActionMode; + asJson: boolean; + confirmed: boolean; + commandRunner?: SkillInstallerCommandRunner; + }, ): { ok: true } | { ok: false; error: string } { const preflight = toSkillInstallPreflightView(sourcePath); @@ -422,12 +449,18 @@ export function runSkillInstallAction( mode: 'install' as const, confirmed: installPolicy.confirmed, execution_enabled: installPolicy.execution_enabled, - executed: [], + executed: runInstallerCommandsWithPolicy([], installPolicy, opts.commandRunner ?? noOpSkillInstallerCommandRunner), reason: installPolicy.reason, wouldRun: [], skipped: [], }; + execution.executed = runInstallerCommandsWithPolicy( + execution.wouldRun, + installPolicy, + opts.commandRunner ?? noOpSkillInstallerCommandRunner, + ); + if (opts.asJson) { console.log( JSON.stringify(