feat(skills): add confirmed no-op execution receipts
This commit is contained in:
+11
-2
@@ -1406,6 +1406,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_safety_confirmation_receipts": {
|
||||||
|
"status": "completed",
|
||||||
|
"description": "Added explicit --confirm semantics and stable no-op execution receipt fields for plan-only/stub/install JSON outputs while keeping installer command 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1434,7 +1443,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1533,
|
"total_test_count": 1535,
|
||||||
"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%)",
|
||||||
@@ -1454,7 +1463,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: add explicit safety confirmations and no-op execution receipts for future real installer execution path"
|
"next_up": "Skills infrastructure Phase 3: add explicit execution-policy gate checks for future real installer command runners while preserving no-op default"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
+78
-3
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
@@ -230,6 +230,11 @@ describe('skills CLI helpers', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(view.execution).toBe('stub');
|
expect(view.execution).toBe('stub');
|
||||||
|
expect(view.mode).toBe('stub');
|
||||||
|
expect(view.confirmed).toBe(false);
|
||||||
|
expect(view.execution_enabled).toBe(false);
|
||||||
|
expect(view.executed).toEqual([]);
|
||||||
|
expect(view.reason).toBe('execution_disabled');
|
||||||
expect(view.wouldRun.length).toBe(1);
|
expect(view.wouldRun.length).toBe(1);
|
||||||
expect(view.wouldRun[0]).toContain('download https://example.com/tool.tgz');
|
expect(view.wouldRun[0]).toContain('download https://example.com/tool.tgz');
|
||||||
});
|
});
|
||||||
@@ -238,6 +243,11 @@ describe('skills CLI helpers', () => {
|
|||||||
const output = renderSkillInstallerExecutionStub({
|
const output = renderSkillInstallerExecutionStub({
|
||||||
skill: { name: 'exec-stub', tier: 'bundled', version: '1.0.0' },
|
skill: { name: 'exec-stub', tier: 'bundled', version: '1.0.0' },
|
||||||
execution: 'stub',
|
execution: 'stub',
|
||||||
|
mode: 'stub',
|
||||||
|
confirmed: false,
|
||||||
|
execution_enabled: false,
|
||||||
|
executed: [],
|
||||||
|
reason: 'execution_disabled',
|
||||||
wouldRun: ['brew install jq'],
|
wouldRun: ['brew install jq'],
|
||||||
skipped: [{ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }],
|
skipped: [{ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }],
|
||||||
});
|
});
|
||||||
@@ -258,9 +268,14 @@ describe('skills CLI helpers', () => {
|
|||||||
skipped: [],
|
skipped: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = toSkillInstallerExecutionStubFromPreflight(preflight);
|
const view = toSkillInstallerExecutionStubFromPreflight(preflight, { mode: 'install', confirmed: true });
|
||||||
|
|
||||||
expect(view.execution).toBe('stub');
|
expect(view.execution).toBe('stub');
|
||||||
|
expect(view.mode).toBe('install');
|
||||||
|
expect(view.confirmed).toBe(true);
|
||||||
|
expect(view.execution_enabled).toBe(false);
|
||||||
|
expect(view.executed).toEqual([]);
|
||||||
|
expect(view.reason).toBe('execution_disabled');
|
||||||
expect(view.wouldRun).toEqual(['download https://example.com/a.tgz -> /tmp/a.tgz']);
|
expect(view.wouldRun).toEqual(['download https://example.com/a.tgz -> /tmp/a.tgz']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,7 +351,7 @@ describe('skills CLI helpers', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const installer = new SkillInstaller(managedDir);
|
const installer = new SkillInstaller(managedDir);
|
||||||
const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false });
|
const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false, confirmed: false });
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(existsSync(join(managedDir, 'plan-skill', 'SKILL.md'))).toBe(false);
|
expect(existsSync(join(managedDir, 'plan-skill', 'SKILL.md'))).toBe(false);
|
||||||
@@ -344,6 +359,66 @@ describe('skills CLI helpers', () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits plan-only JSON with no-op execution receipt fields', () => {
|
||||||
|
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 logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: true, confirmed: true });
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
|
||||||
|
expect(payload.execution.confirmed).toBe(true);
|
||||||
|
expect(payload.execution.mode).toBe('plan-only');
|
||||||
|
expect(payload.execution.execution_enabled).toBe(false);
|
||||||
|
expect(payload.execution.executed).toEqual([]);
|
||||||
|
expect(payload.execution.reason).toBe('execution_disabled');
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits install JSON with no-op execution receipt fields', () => {
|
||||||
|
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 result = runSkillInstallAction(installer, sourceDir, { mode: 'install', asJson: true, confirmed: false });
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(logSpy.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
const payload = JSON.parse(String(logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]));
|
||||||
|
expect(payload.execution.confirmed).toBe(false);
|
||||||
|
expect(payload.execution.mode).toBe('install');
|
||||||
|
expect(payload.execution.execution_enabled).toBe(false);
|
||||||
|
expect(payload.execution.executed).toEqual([]);
|
||||||
|
expect(payload.execution.reason).toBe('execution_disabled');
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('requires --yes confirmation for uninstall helper', () => {
|
it('requires --yes confirmation for uninstall helper', () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
||||||
const installer = new SkillInstaller(join(root, 'managed'));
|
const installer = new SkillInstaller(join(root, 'managed'));
|
||||||
|
|||||||
+60
-10
@@ -41,6 +41,11 @@ export interface SkillInstallPreflightView {
|
|||||||
export interface SkillInstallerExecutionStubView {
|
export interface SkillInstallerExecutionStubView {
|
||||||
skill: SkillInstallerPlanView['skill'];
|
skill: SkillInstallerPlanView['skill'];
|
||||||
execution: 'stub';
|
execution: 'stub';
|
||||||
|
mode: SkillInstallActionMode;
|
||||||
|
confirmed: boolean;
|
||||||
|
execution_enabled: boolean;
|
||||||
|
executed: string[];
|
||||||
|
reason: 'execution_disabled';
|
||||||
wouldRun: string[];
|
wouldRun: string[];
|
||||||
skipped: SkillInstallerPlanView['skipped'];
|
skipped: SkillInstallerPlanView['skipped'];
|
||||||
}
|
}
|
||||||
@@ -210,6 +215,11 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE
|
|||||||
return {
|
return {
|
||||||
skill: plan.skill,
|
skill: plan.skill,
|
||||||
execution: 'stub',
|
execution: 'stub',
|
||||||
|
mode: 'stub',
|
||||||
|
confirmed: false,
|
||||||
|
execution_enabled: false,
|
||||||
|
executed: [],
|
||||||
|
reason: 'execution_disabled',
|
||||||
wouldRun: plan.steps.map((step) => step.command),
|
wouldRun: plan.steps.map((step) => step.command),
|
||||||
skipped: plan.skipped,
|
skipped: plan.skipped,
|
||||||
};
|
};
|
||||||
@@ -217,10 +227,18 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE
|
|||||||
|
|
||||||
export function toSkillInstallerExecutionStubFromPreflight(
|
export function toSkillInstallerExecutionStubFromPreflight(
|
||||||
preflight: SkillInstallPreflightView,
|
preflight: SkillInstallPreflightView,
|
||||||
|
options?: { mode?: SkillInstallActionMode; confirmed?: boolean },
|
||||||
): SkillInstallerExecutionStubView {
|
): SkillInstallerExecutionStubView {
|
||||||
|
const mode = options?.mode ?? 'stub';
|
||||||
|
const confirmed = options?.confirmed ?? false;
|
||||||
return {
|
return {
|
||||||
skill: preflight.skill,
|
skill: preflight.skill,
|
||||||
execution: 'stub',
|
execution: 'stub',
|
||||||
|
mode,
|
||||||
|
confirmed,
|
||||||
|
execution_enabled: false,
|
||||||
|
executed: [],
|
||||||
|
reason: 'execution_disabled',
|
||||||
wouldRun: preflight.steps.map((step) => step.command),
|
wouldRun: preflight.steps.map((step) => step.command),
|
||||||
skipped: preflight.skipped,
|
skipped: preflight.skipped,
|
||||||
};
|
};
|
||||||
@@ -314,7 +332,7 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath:
|
|||||||
export function runSkillInstallAction(
|
export function runSkillInstallAction(
|
||||||
installer: SkillInstaller,
|
installer: SkillInstaller,
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
opts: { mode: SkillInstallActionMode; asJson: boolean },
|
opts: { mode: SkillInstallActionMode; asJson: boolean; confirmed: boolean },
|
||||||
): { ok: true } | { ok: false; error: string } {
|
): { ok: true } | { ok: false; error: string } {
|
||||||
const preflight = toSkillInstallPreflightView(sourcePath);
|
const preflight = toSkillInstallPreflightView(sourcePath);
|
||||||
|
|
||||||
@@ -323,7 +341,11 @@ export function runSkillInstallAction(
|
|||||||
return { ok: false, error: `Failed to generate install preflight from '${resolve(sourcePath)}'.` };
|
return { ok: false, error: `Failed to generate install preflight from '${resolve(sourcePath)}'.` };
|
||||||
}
|
}
|
||||||
if (opts.asJson) {
|
if (opts.asJson) {
|
||||||
console.log(JSON.stringify({ preflight }, null, 2));
|
const execution = toSkillInstallerExecutionStubFromPreflight(preflight, {
|
||||||
|
mode: 'plan-only',
|
||||||
|
confirmed: opts.confirmed,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ preflight, execution }, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.log(renderSkillInstallPreflight(preflight));
|
console.log(renderSkillInstallPreflight(preflight));
|
||||||
}
|
}
|
||||||
@@ -334,7 +356,10 @@ export function runSkillInstallAction(
|
|||||||
if (!preflight) {
|
if (!preflight) {
|
||||||
return { ok: false, error: `Failed to generate installer execution stub from '${resolve(sourcePath)}'.` };
|
return { ok: false, error: `Failed to generate installer execution stub from '${resolve(sourcePath)}'.` };
|
||||||
}
|
}
|
||||||
const stub = toSkillInstallerExecutionStubFromPreflight(preflight);
|
const stub = toSkillInstallerExecutionStubFromPreflight(preflight, {
|
||||||
|
mode: 'stub',
|
||||||
|
confirmed: opts.confirmed,
|
||||||
|
});
|
||||||
if (opts.asJson) {
|
if (opts.asJson) {
|
||||||
console.log(JSON.stringify({ execution: stub }, null, 2));
|
console.log(JSON.stringify({ execution: stub }, null, 2));
|
||||||
} else {
|
} else {
|
||||||
@@ -343,23 +368,43 @@ export function runSkillInstallAction(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preflight) {
|
if (preflight && !opts.asJson) {
|
||||||
if (opts.asJson) {
|
|
||||||
console.log(JSON.stringify({ preflight }, null, 2));
|
|
||||||
} else {
|
|
||||||
console.log(renderSkillInstallPreflight(preflight));
|
console.log(renderSkillInstallPreflight(preflight));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = installSkillFromDirectory(installer, sourcePath);
|
const result = installSkillFromDirectory(installer, sourcePath);
|
||||||
if (result.error || !result.skill) {
|
if (result.error || !result.skill) {
|
||||||
return { ok: false, error: result.error ?? `Failed to install skill from '${sourcePath}'.` };
|
return { ok: false, error: result.error ?? `Failed to install skill from '${sourcePath}'.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const execution =
|
||||||
|
preflight !== null
|
||||||
|
? toSkillInstallerExecutionStubFromPreflight(preflight, {
|
||||||
|
mode: 'install',
|
||||||
|
confirmed: opts.confirmed,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
skill: {
|
||||||
|
name: result.skill.manifest.name,
|
||||||
|
version: result.skill.manifest.version,
|
||||||
|
tier: result.skill.manifest.tier,
|
||||||
|
},
|
||||||
|
execution: 'stub' as const,
|
||||||
|
mode: 'install' as const,
|
||||||
|
confirmed: opts.confirmed,
|
||||||
|
execution_enabled: false,
|
||||||
|
executed: [],
|
||||||
|
reason: 'execution_disabled' as const,
|
||||||
|
wouldRun: [],
|
||||||
|
skipped: [],
|
||||||
|
};
|
||||||
|
|
||||||
if (opts.asJson) {
|
if (opts.asJson) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
|
preflight,
|
||||||
|
execution,
|
||||||
installed: {
|
installed: {
|
||||||
name: result.skill.manifest.name,
|
name: result.skill.manifest.name,
|
||||||
version: result.skill.manifest.version,
|
version: result.skill.manifest.version,
|
||||||
@@ -464,8 +509,10 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
.option('--json', 'Output preflight and install result as JSON')
|
.option('--json', 'Output preflight and install result as JSON')
|
||||||
.option('--preflight-only', 'Show installer preflight without performing install')
|
.option('--preflight-only', 'Show installer preflight without performing install')
|
||||||
.option('--stub', 'Show installer execution stub without performing install')
|
.option('--stub', 'Show installer execution stub without performing install')
|
||||||
|
.option('--confirm', 'Mark installer execution intent as confirmed (execution remains disabled)')
|
||||||
.option('-c, --config <path>', 'Config file path')
|
.option('-c, --config <path>', 'Config file path')
|
||||||
.action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; config?: string }) => {
|
.action(
|
||||||
|
(pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; confirm?: 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');
|
||||||
@@ -480,6 +527,7 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
const result = runSkillInstallAction(installer, pathArg, {
|
const result = runSkillInstallAction(installer, pathArg, {
|
||||||
mode,
|
mode,
|
||||||
asJson: opts.json ?? false,
|
asJson: opts.json ?? false,
|
||||||
|
confirmed: opts.confirm ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -581,8 +629,9 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
.command('execute <name>')
|
.command('execute <name>')
|
||||||
.description('Preview installer execution steps (stub only; no commands run)')
|
.description('Preview installer execution steps (stub only; no commands run)')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
|
.option('--confirm', 'Mark installer execution intent as confirmed (execution remains disabled)')
|
||||||
.option('-c, --config <path>', 'Config file path')
|
.option('-c, --config <path>', 'Config file path')
|
||||||
.action((name: string, opts: { json?: boolean; config?: string }) => {
|
.action((name: string, opts: { json?: boolean; confirm?: boolean; config?: string }) => {
|
||||||
const loaded = loadSkillsFromConfig(opts.config);
|
const loaded = loadSkillsFromConfig(opts.config);
|
||||||
if (loaded.error || !loaded.skills) {
|
if (loaded.error || !loaded.skills) {
|
||||||
console.error(loaded.error ?? 'Failed to load skills');
|
console.error(loaded.error ?? 'Failed to load skills');
|
||||||
@@ -598,6 +647,7 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const view = toSkillInstallerExecutionStubView(skill);
|
const view = toSkillInstallerExecutionStubView(skill);
|
||||||
|
view.confirmed = opts.confirm ?? false;
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify(view, null, 2));
|
console.log(JSON.stringify(view, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user