feat(skills): add pluggable no-op runner interface

This commit is contained in:
William Valentin
2026-02-12 18:50:28 -08:00
parent a983e01db7
commit 0d324886eb
3 changed files with 106 additions and 4 deletions
+60
View File
@@ -19,6 +19,8 @@ import {
renderSkillInstallerExecutionStub,
toSkillInstallerExecutionStubFromPreflight,
evaluateInstallerExecutionPolicy,
runInstallerCommandsWithPolicy,
noOpSkillInstallerCommandRunner,
runSkillInstallAction,
} from './skills.js';
import type { Skill } from '../skills/index.js';
@@ -296,6 +298,36 @@ describe('skills CLI helpers', () => {
expect(policy.reason).toBe('execution_disabled');
});
it('does not invoke command runner when policy disables execution', () => {
const runner = {
run: vi.fn((_commands: string[]) => ['should-not-run']),
};
const executed = runInstallerCommandsWithPolicy(
['brew install jq'],
{ confirmed: false, execution_enabled: false, reason: 'confirmation_required' },
runner,
);
expect(executed).toEqual([]);
expect(runner.run).not.toHaveBeenCalled();
});
it('supports pluggable command runner when policy enables execution', () => {
const runner = {
run: vi.fn((commands: string[]) => commands),
};
const executed = runInstallerCommandsWithPolicy(
['brew install jq'],
{ confirmed: true, execution_enabled: true, reason: 'execution_disabled' },
runner,
);
expect(executed).toEqual(['brew install jq']);
expect(runner.run).toHaveBeenCalledWith(['brew install jq']);
});
it('summarizes refresh counts across status and tiers', () => {
const summary = summarizeSkillsRefresh([
buildSkill({ manifest: { name: 'a', description: 'a', version: '1.0.0', tier: 'bundled' } }),
@@ -436,6 +468,34 @@ describe('skills CLI helpers', () => {
rmSync(root, { recursive: true, force: true });
});
it('uses no-op command runner by default in install flow', () => {
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'), '# Install Skill\nInstructions');
writeFileSync(
join(sourceDir, 'manifest.json'),
JSON.stringify({ name: 'install-skill', description: 'Install', version: '1.0.0' }),
'utf-8',
);
const installer = new SkillInstaller(managedDir);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const runnerSpy = vi.spyOn(noOpSkillInstallerCommandRunner, 'run');
const result = runSkillInstallAction(installer, sourceDir, { mode: 'install', asJson: true, confirmed: true });
expect(result.ok).toBe(true);
expect(runnerSpy).not.toHaveBeenCalled();
const payload = JSON.parse(String(logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]));
expect(payload.execution.executed).toEqual([]);
runnerSpy.mockRestore();
logSpy.mockRestore();
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'));
+35 -2
View File
@@ -59,6 +59,16 @@ export interface SkillInstallerExecutionPolicy {
reason: SkillInstallerExecutionReason;
}
export interface SkillInstallerCommandRunner {
run(commands: string[]): string[];
}
export const noOpSkillInstallerCommandRunner: SkillInstallerCommandRunner = {
run(_commands: string[]): string[] {
return [];
},
};
export function toSkillListRows(skills: Skill[]): SkillListRow[] {
return skills
.map((skill) => ({
@@ -271,6 +281,18 @@ export function evaluateInstallerExecutionPolicy(opts: {
};
}
export function runInstallerCommandsWithPolicy(
commands: string[],
policy: SkillInstallerExecutionPolicy,
runner: SkillInstallerCommandRunner,
): string[] {
if (!policy.execution_enabled) {
return [];
}
return runner.run(commands);
}
export function renderSkillInstallerExecutionStub(view: SkillInstallerExecutionStubView): string {
const lines: string[] = [
`Installer execution stub for '${view.skill.name}' (${view.skill.tier}, v${view.skill.version})`,
@@ -359,7 +381,12 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath:
export function runSkillInstallAction(
installer: SkillInstaller,
sourcePath: string,
opts: { mode: SkillInstallActionMode; asJson: boolean; confirmed: boolean },
opts: {
mode: SkillInstallActionMode;
asJson: boolean;
confirmed: boolean;
commandRunner?: SkillInstallerCommandRunner;
},
): { ok: true } | { ok: false; error: string } {
const preflight = toSkillInstallPreflightView(sourcePath);
@@ -422,12 +449,18 @@ export function runSkillInstallAction(
mode: 'install' as const,
confirmed: installPolicy.confirmed,
execution_enabled: installPolicy.execution_enabled,
executed: [],
executed: runInstallerCommandsWithPolicy([], installPolicy, opts.commandRunner ?? noOpSkillInstallerCommandRunner),
reason: installPolicy.reason,
wouldRun: [],
skipped: [],
};
execution.executed = runInstallerCommandsWithPolicy(
execution.wouldRun,
installPolicy,
opts.commandRunner ?? noOpSkillInstallerCommandRunner,
);
if (opts.asJson) {
console.log(
JSON.stringify(