diff --git a/docs/plans/state.json b/docs/plans/state.json index 8b143f3..8e2552f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1361,6 +1361,15 @@ "src/cli/skills.test.ts" ], "test_status": "pnpm typecheck + pnpm test:run src/skills/planner.test.ts src/cli/skills.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" + }, + "installer_plan_command_json_output": { + "status": "completed", + "description": "Added dedicated skills plan command surface with reusable installer plan view and JSON/text rendering helpers for automation consumption", + "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" } } } @@ -1389,7 +1398,7 @@ }, "overall_progress": { - "total_test_count": 1524, + "total_test_count": 1526, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1409,7 +1418,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 dedicated installer-plan command/json output for non-interactive automation consumption" + "next_up": "Skills infrastructure Phase 3: wire installer plan output into install flow as preflight preview (no execution yet)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index e761c7f..c776899 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -11,6 +11,8 @@ import { renderSkillsRefreshSummary, installSkillFromDirectory, uninstallSkillByName, + toSkillInstallerPlanView, + renderSkillInstallerPlan, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -124,6 +126,40 @@ describe('skills CLI helpers', () => { expect(output).toContain('[download] download https://example.com/tool.tgz -> /tmp/tool.tgz'); }); + it('builds installer plan view for automation output', () => { + const view = toSkillInstallerPlanView( + buildSkill({ + manifest: { + name: 'plan-target', + description: 'Plan me', + version: '3.2.1', + tier: 'managed', + installers: [{ type: 'download', url: 'https://example.com/bin.tar.gz' }], + }, + }), + ); + + expect(view.skill.name).toBe('plan-target'); + expect(view.mode).toBe('dry-run'); + expect(view.steps.length).toBe(1); + expect(view.steps[0]?.installerType).toBe('download'); + }); + + it('renders installer plan summary text', () => { + const output = renderSkillInstallerPlan({ + skill: { name: 'plan-target', tier: 'bundled', version: '1.0.0' }, + mode: 'dry-run', + steps: [{ installerType: 'download', command: 'download https://example.com/tool -> /tmp/tool' }], + skipped: [{ installerType: 'brew', reason: 'brew not available in PATH' }], + }); + + expect(output).toContain("Installer plan for 'plan-target'"); + expect(output).toContain('Planned steps:'); + expect(output).toContain('[download] download https://example.com/tool -> /tmp/tool'); + expect(output).toContain('Skipped steps:'); + expect(output).toContain('[brew] brew not available in PATH'); + }); + 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 b06046d..05d1384 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -19,6 +19,17 @@ export interface SkillRefreshSummary { tiers: Record; } +export interface SkillInstallerPlanView { + skill: { + name: string; + tier: Skill['manifest']['tier']; + version: string; + }; + mode: 'dry-run'; + steps: Array<{ installerType: string; command: string }>; + skipped: Array<{ installerType: string; reason: string }>; +} + export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ @@ -95,6 +106,47 @@ export function renderSkillInfo(skill: Skill): string { return lines.join('\n'); } +export function toSkillInstallerPlanView(skill: Skill): SkillInstallerPlanView { + const plan = buildInstallerPlan(skill.manifest.installers); + return { + skill: { + name: skill.manifest.name, + tier: skill.manifest.tier, + version: skill.manifest.version, + }, + mode: plan.mode, + steps: plan.steps, + skipped: plan.skipped, + }; +} + +export function renderSkillInstallerPlan(view: SkillInstallerPlanView): string { + const lines: string[] = [ + `Installer plan for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`, + `Mode: ${view.mode}`, + ]; + + if (view.steps.length === 0) { + lines.push('Planned steps: none'); + } else { + lines.push('Planned steps:'); + for (const step of view.steps) { + lines.push(`- [${step.installerType}] ${step.command}`); + } + } + + if (view.skipped.length === 0) { + lines.push('Skipped steps: none'); + } else { + lines.push('Skipped steps:'); + 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, @@ -319,4 +371,33 @@ export function registerSkillsCommand(program: Command): void { console.log(renderSkillsRefreshSummary(summary)); }); + + skills + .command('plan ') + .description('Print dry-run installer plan for a skill') + .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 = toSkillInstallerPlanView(skill); + if (opts.json) { + console.log(JSON.stringify(view, null, 2)); + return; + } + + console.log(renderSkillInstallerPlan(view)); + }); }