diff --git a/docs/plans/state.json b/docs/plans/state.json index 7f9e5ef..8b143f3 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1347,6 +1347,20 @@ "src/skills/loader.test.ts" ], "test_status": "pnpm typecheck + pnpm test:run src/skills/loader.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" + }, + "installer_dry_run_planning_surface": { + "status": "completed", + "description": "Added dry-run installer planning with package-manager selection rules and surfaced planned/skipped installer steps in skills info output", + "files_created": [ + "src/skills/planner.ts", + "src/skills/planner.test.ts" + ], + "files_modified": [ + "src/skills/index.ts", + "src/cli/skills.ts", + "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" } } } @@ -1375,7 +1389,7 @@ }, "overall_progress": { - "total_test_count": 1520, + "total_test_count": 1524, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1395,7 +1409,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 planning surface (selection rules and dry-run installer plan output)" + "next_up": "Skills infrastructure Phase 3: add dedicated installer-plan command/json output for non-interactive automation consumption" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index aefa385..e761c7f 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -104,6 +104,26 @@ describe('skills CLI helpers', () => { expect(output).toContain('Unavailable reasons: Required binary not found'); }); + it('renders dry-run installer plan when manifest installers are present', () => { + const output = renderSkillInfo( + buildSkill({ + manifest: { + name: 'installer-aware', + description: 'Installer-aware skill', + version: '1.0.0', + tier: 'bundled', + installers: [ + { type: 'download', url: 'https://example.com/tool.tgz', destination: '/tmp/tool.tgz' }, + ], + }, + }), + ); + + expect(output).toContain('Installer plan mode: dry-run'); + expect(output).toContain('Installer planned steps:'); + expect(output).toContain('[download] download https://example.com/tool.tgz -> /tmp/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 2832943..b06046d 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 } from '../skills/index.js'; +import { loadAllSkills, SkillInstaller, buildInstallerPlan } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; export interface SkillListRow { @@ -75,6 +75,23 @@ export function renderSkillInfo(skill: Skill): string { lines.push(`Unavailable reasons: ${skill.unavailableReasons.join('; ')}`); } + if (skill.manifest.installers && skill.manifest.installers.length > 0) { + const plan = buildInstallerPlan(skill.manifest.installers); + lines.push(`Installer plan mode: ${plan.mode}`); + if (plan.steps.length > 0) { + lines.push('Installer planned steps:'); + for (const step of plan.steps) { + lines.push(`- [${step.installerType}] ${step.command}`); + } + } + if (plan.skipped.length > 0) { + lines.push('Installer skipped steps:'); + for (const skip of plan.skipped) { + lines.push(`- [${skip.installerType}] ${skip.reason}`); + } + } + } + return lines.join('\n'); } diff --git a/src/skills/index.ts b/src/skills/index.ts index 6eab42f..7c1672a 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -10,6 +10,8 @@ export type { DownloadInstallerSpec, } from './types.js'; export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js'; +export { buildInstallerPlan } from './planner.js'; +export type { InstallerPlan, InstallerPlanStep, InstallerPlanSkip, InstallerPlanningOptions } from './planner.js'; export { SkillRegistry } from './registry.js'; export { SkillInstaller } from './installer.js'; export { SkillsWatcher } from './watcher.js'; diff --git a/src/skills/planner.test.ts b/src/skills/planner.test.ts new file mode 100644 index 0000000..0102b24 --- /dev/null +++ b/src/skills/planner.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { buildInstallerPlan } from './planner.js'; + +describe('buildInstallerPlan', () => { + it('plans brew and node installers when required binaries are present', () => { + const plan = buildInstallerPlan( + [ + { type: 'brew', packages: ['jq'] }, + { type: 'node', packages: ['typescript'] }, + ], + { + hasBinary: (name) => name === 'brew' || name === 'pnpm', + }, + ); + + expect(plan.mode).toBe('dry-run'); + expect(plan.steps).toEqual([ + { installerType: 'brew', command: 'brew install jq' }, + { installerType: 'node', command: 'pnpm add -g typescript' }, + ]); + expect(plan.skipped).toEqual([]); + }); + + it('skips installers when required package managers are missing', () => { + const plan = buildInstallerPlan( + [ + { type: 'brew', packages: ['wget'] }, + { type: 'node', packages: ['tsx'] }, + { type: 'go', packages: ['golang.org/x/tools/cmd/stringer'] }, + ], + { + hasBinary: () => false, + }, + ); + + expect(plan.steps).toEqual([]); + expect(plan.skipped).toEqual([ + { installerType: 'brew', reason: 'brew not available in PATH' }, + { installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }, + { installerType: 'go', reason: 'go not available in PATH' }, + ]); + }); + + it('expands go packages and includes download installers', () => { + const plan = buildInstallerPlan( + [ + { type: 'go', packages: ['example.com/tool/a', 'example.com/tool/b'] }, + { type: 'download', url: 'https://example.com/tool.tgz', destination: '/tmp/tool.tgz' }, + ], + { + hasBinary: (name) => name === 'go', + }, + ); + + expect(plan.steps).toEqual([ + { installerType: 'go', command: 'go install example.com/tool/a@latest' }, + { installerType: 'go', command: 'go install example.com/tool/b@latest' }, + { installerType: 'download', command: 'download https://example.com/tool.tgz -> /tmp/tool.tgz' }, + ]); + expect(plan.skipped).toEqual([]); + }); +}); diff --git a/src/skills/planner.ts b/src/skills/planner.ts new file mode 100644 index 0000000..c96c72a --- /dev/null +++ b/src/skills/planner.ts @@ -0,0 +1,88 @@ +import { execSync } from 'child_process'; +import type { SkillInstallerSpec } from './types.js'; + +export interface InstallerPlanStep { + installerType: SkillInstallerSpec['type']; + command: string; +} + +export interface InstallerPlanSkip { + installerType: SkillInstallerSpec['type']; + reason: string; +} + +export interface InstallerPlan { + mode: 'dry-run'; + steps: InstallerPlanStep[]; + skipped: InstallerPlanSkip[]; +} + +export interface InstallerPlanningOptions { + hasBinary?: (name: string) => boolean; +} + +function hasBinaryOnPath(name: string): boolean { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + try { + execSync(`${cmd} ${name}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +export function buildInstallerPlan( + installers: SkillInstallerSpec[] | undefined, + opts: InstallerPlanningOptions = {}, +): InstallerPlan { + const hasBinary = opts.hasBinary ?? hasBinaryOnPath; + const steps: InstallerPlanStep[] = []; + const skipped: InstallerPlanSkip[] = []; + + for (const installer of installers ?? []) { + if (installer.type === 'brew') { + if (!hasBinary('brew')) { + skipped.push({ installerType: 'brew', reason: 'brew not available in PATH' }); + continue; + } + steps.push({ installerType: 'brew', command: `brew install ${installer.packages.join(' ')}` }); + continue; + } + + if (installer.type === 'node') { + if (hasBinary('pnpm')) { + steps.push({ installerType: 'node', command: `pnpm add -g ${installer.packages.join(' ')}` }); + continue; + } + if (hasBinary('npm')) { + steps.push({ installerType: 'node', command: `npm install -g ${installer.packages.join(' ')}` }); + continue; + } + skipped.push({ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }); + continue; + } + + if (installer.type === 'go') { + if (!hasBinary('go')) { + skipped.push({ installerType: 'go', reason: 'go not available in PATH' }); + continue; + } + for (const pkg of installer.packages) { + steps.push({ installerType: 'go', command: `go install ${pkg}@latest` }); + } + continue; + } + + const destination = installer.destination ?? ''; + steps.push({ + installerType: 'download', + command: `download ${installer.url} -> ${destination}`, + }); + } + + return { + mode: 'dry-run', + steps, + skipped, + }; +}