feat(skills): preview installer plan during install
This commit is contained in:
+11
-2
@@ -1370,6 +1370,15 @@
|
|||||||
"src/cli/skills.test.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"
|
"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": {
|
"overall_progress": {
|
||||||
"total_test_count": 1526,
|
"total_test_count": 1528,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
uninstallSkillByName,
|
uninstallSkillByName,
|
||||||
toSkillInstallerPlanView,
|
toSkillInstallerPlanView,
|
||||||
renderSkillInstallerPlan,
|
renderSkillInstallerPlan,
|
||||||
|
toSkillInstallPreflightView,
|
||||||
|
renderSkillInstallPreflight,
|
||||||
} from './skills.js';
|
} from './skills.js';
|
||||||
import type { Skill } from '../skills/index.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');
|
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 -> <default destination>' }],
|
||||||
|
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 -> <default destination>');
|
||||||
|
});
|
||||||
|
|
||||||
it('summarizes refresh counts across status and tiers', () => {
|
it('summarizes refresh counts across status and tiers', () => {
|
||||||
const summary = summarizeSkillsRefresh([
|
const summary = summarizeSkillsRefresh([
|
||||||
buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }),
|
buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }),
|
||||||
|
|||||||
+80
-2
@@ -2,7 +2,7 @@ import type { Command } from 'commander';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import type { Skill } from '../skills/index.js';
|
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';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
|
||||||
export interface SkillListRow {
|
export interface SkillListRow {
|
||||||
@@ -30,6 +30,14 @@ export interface SkillInstallerPlanView {
|
|||||||
skipped: Array<{ installerType: string; reason: string }>;
|
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[] {
|
export function toSkillListRows(skills: Skill[]): SkillListRow[] {
|
||||||
return skills
|
return skills
|
||||||
.map((skill) => ({
|
.map((skill) => ({
|
||||||
@@ -147,6 +155,47 @@ export function renderSkillInstallerPlan(view: SkillInstallerPlanView): string {
|
|||||||
return lines.join('\n');
|
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 {
|
export function summarizeSkillsRefresh(skills: Skill[]): SkillRefreshSummary {
|
||||||
const summary: SkillRefreshSummary = {
|
const summary: SkillRefreshSummary = {
|
||||||
total: skills.length,
|
total: skills.length,
|
||||||
@@ -290,8 +339,9 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
skills
|
skills
|
||||||
.command('install <path>')
|
.command('install <path>')
|
||||||
.description('Install a skill from a local directory')
|
.description('Install a skill from a local directory')
|
||||||
|
.option('--json', 'Output preflight and install result as JSON')
|
||||||
.option('-c, --config <path>', 'Config file path')
|
.option('-c, --config <path>', 'Config file path')
|
||||||
.action((pathArg: string, opts: { config?: string }) => {
|
.action((pathArg: string, opts: { json?: boolean; config?: string }) => {
|
||||||
const loaded = loadConfigSafe(opts.config);
|
const loaded = loadConfigSafe(opts.config);
|
||||||
if (loaded.error || !loaded.config) {
|
if (loaded.error || !loaded.config) {
|
||||||
console.error(loaded.error ?? 'Failed to load 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 defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
|
||||||
const installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir);
|
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);
|
const result = installSkillFromDirectory(installer, pathArg);
|
||||||
|
|
||||||
if (result.error || !result.skill) {
|
if (result.error || !result.skill) {
|
||||||
@@ -309,6 +369,24 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
return;
|
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}).`);
|
console.log(`Installed skill '${result.skill.manifest.name}' (${result.skill.manifest.version}).`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user