diff --git a/docs/plans/state.json b/docs/plans/state.json index 39a8b44..9fd23b8 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1388,6 +1388,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_execution_stub_command": { + "status": "completed", + "description": "Added skills execute command that consumes installer plans and reports stub execution output without running package-manager commands", + "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" } } } @@ -1416,7 +1425,7 @@ }, "overall_progress": { - "total_test_count": 1529, + "total_test_count": 1531, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1436,7 +1445,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 installer execution stub command that consumes plan output but does not run package manager commands yet" + "next_up": "Skills infrastructure Phase 3: add shared install action modes (plan-only/stub/install) to reduce CLI duplication while keeping execution disabled" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index ad0788d..5ca5a8f 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -15,6 +15,8 @@ import { renderSkillInstallerPlan, toSkillInstallPreflightView, renderSkillInstallPreflight, + toSkillInstallerExecutionStubView, + renderSkillInstallerExecutionStub, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -212,6 +214,39 @@ describe('skills CLI helpers', () => { expect(output).toContain('[download] download https://example.com/tool.tgz -> '); }); + it('builds installer execution stub view from skill plan', () => { + const view = toSkillInstallerExecutionStubView( + buildSkill({ + manifest: { + name: 'exec-stub', + description: 'Execution stub test', + version: '1.1.0', + tier: 'managed', + installers: [{ type: 'download', url: 'https://example.com/tool.tgz' }], + }, + }), + ); + + expect(view.execution).toBe('stub'); + expect(view.wouldRun.length).toBe(1); + expect(view.wouldRun[0]).toContain('download https://example.com/tool.tgz'); + }); + + it('renders installer execution stub output text', () => { + const output = renderSkillInstallerExecutionStub({ + skill: { name: 'exec-stub', tier: 'bundled', version: '1.0.0' }, + execution: 'stub', + wouldRun: ['brew install jq'], + skipped: [{ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }], + }); + + expect(output).toContain("Installer execution stub for 'exec-stub'"); + expect(output).toContain('No installer commands were executed.'); + expect(output).toContain('Would run:'); + expect(output).toContain('- brew install jq'); + expect(output).toContain('Skipped:'); + }); + it('summarizes refresh counts across status and tiers', () => { const summary = summarizeSkillsRefresh([ buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }), diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 1a2010d..8b70ba1 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -38,6 +38,13 @@ export interface SkillInstallPreflightView { skipped: SkillInstallerPlanView['skipped']; } +export interface SkillInstallerExecutionStubView { + skill: SkillInstallerPlanView['skill']; + execution: 'stub'; + wouldRun: string[]; + skipped: SkillInstallerPlanView['skipped']; +} + export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ @@ -196,6 +203,41 @@ export function renderSkillInstallPreflight(view: SkillInstallPreflightView): st return lines.join('\n'); } +export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerExecutionStubView { + const plan = toSkillInstallerPlanView(skill); + return { + skill: plan.skill, + execution: 'stub', + wouldRun: plan.steps.map((step) => step.command), + skipped: plan.skipped, + }; +} + +export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string { + const lines: string[] = [ + `Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`, + 'No installer commands were executed.', + ]; + + if (view.wouldRun.length === 0) { + lines.push('Would run: none'); + } else { + lines.push('Would run:'); + for (const command of view.wouldRun) { + lines.push(`- ${command}`); + } + } + + if (view.skipped.length > 0) { + lines.push('Skipped:'); + for (const skip of view.skipped) { + lines.push(`- [${skip.installerType}] ${skip.reason}`); + } + } + + return lines.join('\n'); +} + export function summarizeSkillsRefresh(skills: Skill[]): SkillRefreshSummary { const summary: SkillRefreshSummary = { total: skills.length, @@ -495,4 +537,33 @@ export function registerSkillsCommand(program: Command): void { console.log(renderSkillInstallerPlan(view)); }); + + skills + .command('execute ') + .description('Preview installer execution steps (stub only; no commands run)') + .option('--json', 'Output as JSON') + .option('-c, --config ', 'Config file path') + .action((name: string, opts: { json?: boolean; config?: string }) => { + const loaded = loadSkillsFromConfig(opts.config); + if (loaded.error || !loaded.skills) { + console.error(loaded.error ?? 'Failed to load skills'); + process.exitCode = 1; + return; + } + + const skill = loaded.skills.find((item) => item.manifest.name === name); + if (!skill) { + console.error(`Skill '${name}' not found.`); + process.exitCode = 1; + return; + } + + const view = toSkillInstallerExecutionStubView(skill); + if (opts.json) { + console.log(JSON.stringify(view, null, 2)); + return; + } + + console.log(renderSkillInstallerExecutionStub(view)); + }); }