feat(skills): add dry-run installer planning surface

This commit is contained in:
William Valentin
2026-02-12 17:56:51 -08:00
parent 81d04357a1
commit bd754d520e
6 changed files with 206 additions and 3 deletions
+16 -2
View File
@@ -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",
+20
View File
@@ -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' } }),
+18 -1
View File
@@ -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');
}
+2
View File
@@ -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';
+62
View File
@@ -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([]);
});
});
+88
View File
@@ -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 ?? '<default destination>';
steps.push({
installerType: 'download',
command: `download ${installer.url} -> ${destination}`,
});
}
return {
mode: 'dry-run',
steps,
skipped,
};
}