From 30fcccd05aecdc4a45305672256018a420f8f353 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 19:18:20 -0800 Subject: [PATCH] feat(skills): add optional shell command runner --- docs/plans/state.json | 13 +++++++++-- src/cli/skills.test.ts | 28 +++++++++++++++++++++++ src/cli/skills.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 9235cb8..f7d4f2f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1451,6 +1451,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" + }, + "installer_optional_shell_runner": { + "status": "completed", + "description": "Added an optional concrete shell-based installer command runner that emits structured succeeded/failed command results with machine-readable reasons, while default flow remains policy-gated and no-op", + "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" } } } @@ -1479,7 +1488,7 @@ }, "overall_progress": { - "total_test_count": 1543, + "total_test_count": 1545, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1499,7 +1508,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 an optional concrete runner implementation (still opt-in) that emits command-level success/failure reasons into the existing execution envelope schema" + "next_up": "Skills infrastructure Phase 3: add explicit CLI opt-in wiring for execution runner selection while preserving execution-disabled defaults and existing confirmation policy gates" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index adef9e0..89ff3ba 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -23,6 +23,7 @@ import { mergeInstallerExecutionResults, runInstallerCommandsWithPolicy, noOpSkillInstallerCommandRunner, + createShellSkillInstallerCommandRunner, runSkillInstallAction, } from './skills.js'; import type { Skill } from '../skills/index.js'; @@ -376,6 +377,33 @@ describe('skills CLI helpers', () => { expect(runner.run).toHaveBeenCalledWith(['brew install jq']); }); + it('shell command runner reports succeeded command status', () => { + const runner = createShellSkillInstallerCommandRunner(); + + const results = runner.run(['node -e "process.exit(0)"']); + + expect(results).toEqual([ + { + command: 'node -e "process.exit(0)"', + status: 'succeeded', + }, + ]); + }); + + it('shell command runner reports failed command with exit code reason', () => { + const runner = createShellSkillInstallerCommandRunner(); + + const results = runner.run(['node -e "process.exit(7)"']); + + expect(results).toEqual([ + { + command: 'node -e "process.exit(7)"', + status: 'failed', + reason: 'exit_code_7', + }, + ]); + }); + it('maps runner command results into structured per-step statuses', () => { const attempted = [ { installer_type: 'brew', command: 'brew install jq' }, diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 01d1ee0..a601a4f 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; import { resolve } from 'path'; import { homedir } from 'os'; +import { spawnSync } from 'child_process'; import type { Skill } from '../skills/index.js'; import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; @@ -78,6 +79,57 @@ export const noOpSkillInstallerCommandRunner: SkillInstallerCommandRunner = { }, }; +export function createShellSkillInstallerCommandRunner(): SkillInstallerCommandRunner { + return { + run(commands: string[]): SkillInstallerCommandRunResult[] { + return commands.map((command) => { + const result = spawnSync(command, { + shell: true, + stdio: 'pipe', + encoding: 'utf-8', + }); + + if (result.error) { + return { + command, + status: 'failed', + reason: `spawn_error:${result.error.message}`, + }; + } + + if (result.status === 0) { + return { + command, + status: 'succeeded', + }; + } + + if (result.status !== null) { + return { + command, + status: 'failed', + reason: `exit_code_${result.status}`, + }; + } + + if (result.signal) { + return { + command, + status: 'failed', + reason: `signal_${result.signal}`, + }; + } + + return { + command, + status: 'failed', + reason: 'unknown_failure', + }; + }); + }, + }; +} + export function toInstallerExecutionStepEnvelopes( steps: Array<{ installerType: string; command: string }>, policy: SkillInstallerExecutionPolicy,