refactor(skills): centralize install action modes
This commit is contained in:
+11
-2
@@ -1397,6 +1397,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"
|
||||
},
|
||||
"shared_install_action_modes": {
|
||||
"status": "completed",
|
||||
"description": "Centralized install action handling into shared plan-only/stub/install modes and wired install command options through the shared flow while keeping real installer execution disabled",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1425,7 +1434,7 @@
|
||||
},
|
||||
|
||||
"overall_progress": {
|
||||
"total_test_count": 1531,
|
||||
"total_test_count": 1533,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
@@ -1445,7 +1454,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 shared install action modes (plan-only/stub/install) to reduce CLI duplication while keeping execution disabled"
|
||||
"next_up": "Skills infrastructure Phase 3: add explicit safety confirmations and no-op execution receipts for future real installer execution path"
|
||||
},
|
||||
"soul_md_and_cron_create": {
|
||||
"date": "2026-02-11",
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
renderSkillInstallPreflight,
|
||||
toSkillInstallerExecutionStubView,
|
||||
renderSkillInstallerExecutionStub,
|
||||
toSkillInstallerExecutionStubFromPreflight,
|
||||
runSkillInstallAction,
|
||||
} from './skills.js';
|
||||
import type { Skill } from '../skills/index.js';
|
||||
|
||||
@@ -247,6 +249,21 @@ describe('skills CLI helpers', () => {
|
||||
expect(output).toContain('Skipped:');
|
||||
});
|
||||
|
||||
it('derives execution stub view from preflight data', () => {
|
||||
const preflight = {
|
||||
sourcePath: '/tmp/source-skill',
|
||||
skill: { name: 'exec-stub', tier: 'managed' as const, version: '1.0.0' },
|
||||
mode: 'dry-run' as const,
|
||||
steps: [{ installerType: 'download', command: 'download https://example.com/a.tgz -> /tmp/a.tgz' }],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
const view = toSkillInstallerExecutionStubFromPreflight(preflight);
|
||||
|
||||
expect(view.execution).toBe('stub');
|
||||
expect(view.wouldRun).toEqual(['download https://example.com/a.tgz -> /tmp/a.tgz']);
|
||||
});
|
||||
|
||||
it('summarizes refresh counts across status and tiers', () => {
|
||||
const summary = summarizeSkillsRefresh([
|
||||
buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }),
|
||||
@@ -306,6 +323,27 @@ describe('skills CLI helpers', () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('supports plan-only install action mode without installing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
||||
const sourceDir = join(root, 'source-skill');
|
||||
const managedDir = join(root, 'managed');
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(join(sourceDir, 'SKILL.md'), '# Plan Skill\nInstructions');
|
||||
writeFileSync(
|
||||
join(sourceDir, 'manifest.json'),
|
||||
JSON.stringify({ name: 'plan-skill', description: 'Plan only', version: '1.0.0' }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(existsSync(join(managedDir, 'plan-skill', 'SKILL.md'))).toBe(false);
|
||||
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('requires --yes confirmation for uninstall helper', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
||||
const installer = new SkillInstaller(join(root, 'managed'));
|
||||
|
||||
+89
-50
@@ -45,6 +45,8 @@ export interface SkillInstallerExecutionStubView {
|
||||
skipped: SkillInstallerPlanView['skipped'];
|
||||
}
|
||||
|
||||
export type SkillInstallActionMode = 'plan-only' | 'stub' | 'install';
|
||||
|
||||
export function toSkillListRows(skills: Skill[]): SkillListRow[] {
|
||||
return skills
|
||||
.map((skill) => ({
|
||||
@@ -213,6 +215,17 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE
|
||||
};
|
||||
}
|
||||
|
||||
export function toSkillInstallerExecutionStubFromPreflight(
|
||||
preflight: SkillInstallPreflightView,
|
||||
): SkillInstallerExecutionStubView {
|
||||
return {
|
||||
skill: preflight.skill,
|
||||
execution: 'stub',
|
||||
wouldRun: preflight.steps.map((step) => step.command),
|
||||
skipped: preflight.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string {
|
||||
const lines: string[] = [
|
||||
`Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`,
|
||||
@@ -298,6 +311,73 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath:
|
||||
}
|
||||
}
|
||||
|
||||
export function runSkillInstallAction(
|
||||
installer: SkillInstaller,
|
||||
sourcePath: string,
|
||||
opts: { mode: SkillInstallActionMode; asJson: boolean },
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
const preflight = toSkillInstallPreflightView(sourcePath);
|
||||
|
||||
if (opts.mode === 'plan-only') {
|
||||
if (!preflight) {
|
||||
return { ok: false, error: `Failed to generate install preflight from '${resolve(sourcePath)}'.` };
|
||||
}
|
||||
if (opts.asJson) {
|
||||
console.log(JSON.stringify({ preflight }, null, 2));
|
||||
} else {
|
||||
console.log(renderSkillInstallPreflight(preflight));
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (opts.mode === 'stub') {
|
||||
if (!preflight) {
|
||||
return { ok: false, error: `Failed to generate installer execution stub from '${resolve(sourcePath)}'.` };
|
||||
}
|
||||
const stub = toSkillInstallerExecutionStubFromPreflight(preflight);
|
||||
if (opts.asJson) {
|
||||
console.log(JSON.stringify({ execution: stub }, null, 2));
|
||||
} else {
|
||||
console.log(renderSkillInstallerExecutionStub(stub));
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (preflight) {
|
||||
if (opts.asJson) {
|
||||
console.log(JSON.stringify({ preflight }, null, 2));
|
||||
} else {
|
||||
console.log(renderSkillInstallPreflight(preflight));
|
||||
}
|
||||
}
|
||||
|
||||
const result = installSkillFromDirectory(installer, sourcePath);
|
||||
if (result.error || !result.skill) {
|
||||
return { ok: false, error: result.error ?? `Failed to install skill from '${sourcePath}'.` };
|
||||
}
|
||||
|
||||
if (opts.asJson) {
|
||||
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,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(`Installed skill '${result.skill.manifest.name}' (${result.skill.manifest.version}).`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function uninstallSkillByName(
|
||||
installer: SkillInstaller,
|
||||
name: string,
|
||||
@@ -383,8 +463,9 @@ export function registerSkillsCommand(program: Command): void {
|
||||
.description('Install a skill from a local directory')
|
||||
.option('--json', 'Output preflight and install result as JSON')
|
||||
.option('--preflight-only', 'Show installer preflight without performing install')
|
||||
.option('--stub', 'Show installer execution stub without performing install')
|
||||
.option('-c, --config <path>', 'Config file path')
|
||||
.action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; config?: string }) => {
|
||||
.action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; config?: string }) => {
|
||||
const loaded = loadConfigSafe(opts.config);
|
||||
if (loaded.error || !loaded.config) {
|
||||
console.error(loaded.error ?? 'Failed to load config');
|
||||
@@ -394,59 +475,17 @@ 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 (opts.preflightOnly) {
|
||||
if (!preflight) {
|
||||
console.error(`Failed to generate install preflight from '${resolve(pathArg)}'.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const mode: SkillInstallActionMode = opts.preflightOnly ? 'plan-only' : opts.stub ? 'stub' : 'install';
|
||||
const result = runSkillInstallAction(installer, pathArg, {
|
||||
mode,
|
||||
asJson: opts.json ?? false,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ preflight }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderSkillInstallPreflight(preflight));
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(result.error ?? `Failed to install skill from '${pathArg}'.`);
|
||||
if (!result.ok) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
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}).`);
|
||||
});
|
||||
|
||||
skills
|
||||
|
||||
Reference in New Issue
Block a user