From 601844c50ebf3915f7d92b72f68804a05df26607 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 18:15:42 -0800 Subject: [PATCH] feat(skills): preview installer plan during install --- docs/plans/state.json | 13 +++++-- src/cli/skills.test.ts | 41 +++++++++++++++++++++ src/cli/skills.ts | 82 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 8e2552f..48e022f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1370,6 +1370,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_preflight_preview_in_install": { + "status": "completed", + "description": "Wired dry-run installer preflight preview into skills install flow with optional JSON output for automation clients", + "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" } } } @@ -1398,7 +1407,7 @@ }, "overall_progress": { - "total_test_count": 1526, + "total_test_count": 1528, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1418,7 +1427,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: wire installer plan output into install flow as preflight preview (no execution yet)" + "next_up": "Skills infrastructure Phase 3: add install preflight-only mode to print plan without performing install" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index c776899..2b0d5cd 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -13,6 +13,8 @@ import { uninstallSkillByName, toSkillInstallerPlanView, renderSkillInstallerPlan, + toSkillInstallPreflightView, + renderSkillInstallPreflight, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -160,6 +162,45 @@ describe('skills CLI helpers', () => { expect(output).toContain('[brew] brew not available in PATH'); }); + it('builds install preflight view from a source skill directory', () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); + const sourceDir = join(root, 'source-skill'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'SKILL.md'), '# Preflight Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ + name: 'preflight-skill', + description: 'Preflight test', + version: '1.0.0', + installers: [{ type: 'download', url: 'https://example.com/tool.tgz' }], + }), + 'utf-8', + ); + + const view = toSkillInstallPreflightView(sourceDir); + + expect(view).not.toBeNull(); + expect(view?.skill.name).toBe('preflight-skill'); + expect(view?.steps[0]?.installerType).toBe('download'); + + rmSync(root, { recursive: true, force: true }); + }); + + it('renders install preflight output text', () => { + const output = renderSkillInstallPreflight({ + sourcePath: '/tmp/source-skill', + skill: { name: 'preflight-skill', tier: 'managed', version: '1.0.0' }, + mode: 'dry-run', + steps: [{ installerType: 'download', command: 'download https://example.com/tool.tgz -> ' }], + skipped: [], + }); + + expect(output).toContain("Install preflight for 'preflight-skill' from /tmp/source-skill"); + expect(output).toContain('Planned installer steps:'); + expect(output).toContain('[download] download https://example.com/tool.tgz -> '); + }); + 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 05d1384..2df9d95 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import { resolve } from 'path'; import { homedir } from 'os'; import type { Skill } from '../skills/index.js'; -import { loadAllSkills, SkillInstaller, buildInstallerPlan } from '../skills/index.js'; +import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; export interface SkillListRow { @@ -30,6 +30,14 @@ export interface SkillInstallerPlanView { skipped: Array<{ installerType: string; reason: string }>; } +export interface SkillInstallPreflightView { + sourcePath: string; + skill: SkillInstallerPlanView['skill']; + mode: 'dry-run'; + steps: SkillInstallerPlanView['steps']; + skipped: SkillInstallerPlanView['skipped']; +} + export function toSkillListRows(skills: Skill[]): SkillListRow[] { return skills .map((skill) => ({ @@ -147,6 +155,47 @@ export function renderSkillInstallerPlan(view: SkillInstallerPlanView): string { return lines.join('\n'); } +export function toSkillInstallPreflightView(sourcePath: string): SkillInstallPreflightView | null { + const sourceSkill = loadSkill(resolve(sourcePath), 'managed'); + if (!sourceSkill) { + return null; + } + + const planView = toSkillInstallerPlanView(sourceSkill); + return { + sourcePath: resolve(sourcePath), + skill: planView.skill, + mode: planView.mode, + steps: planView.steps, + skipped: planView.skipped, + }; +} + +export function renderSkillInstallPreflight(view: SkillInstallPreflightView): string { + const lines: string[] = [ + `Install preflight for '${view.skill.name}' from ${view.sourcePath}`, + `Mode: ${view.mode}`, + ]; + + if (view.steps.length === 0) { + lines.push('Planned installer steps: none'); + } else { + lines.push('Planned installer steps:'); + for (const step of view.steps) { + lines.push(`- [${step.installerType}] ${step.command}`); + } + } + + if (view.skipped.length > 0) { + lines.push('Skipped installer 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, @@ -290,8 +339,9 @@ export function registerSkillsCommand(program: Command): void { skills .command('install ') .description('Install a skill from a local directory') + .option('--json', 'Output preflight and install result as JSON') .option('-c, --config ', 'Config file path') - .action((pathArg: string, opts: { config?: string }) => { + .action((pathArg: string, opts: { json?: boolean; config?: string }) => { const loaded = loadConfigSafe(opts.config); if (loaded.error || !loaded.config) { console.error(loaded.error ?? 'Failed to load config'); @@ -301,6 +351,16 @@ 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 (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) { @@ -309,6 +369,24 @@ export function registerSkillsCommand(program: Command): void { 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}).`); });