diff --git a/packages/persona-kit/README.md b/packages/persona-kit/README.md index 57352c3..2e7b748 100644 --- a/packages/persona-kit/README.md +++ b/packages/persona-kit/README.md @@ -1,3 +1,93 @@ # @agentworkforce/persona-kit Persona-kit owns the AgentWorkforce persona instantiation lifecycle. See tracking issue [#64](https://github.com/AgentWorkforce/workforce/issues/64). + +## Top-level orchestration API + +The package exposes a two-phase API: a **pure plan builder** that composes a +persona's runtime data into a single inspectable value, and a **side-effecting +executor** that runs the plan and returns a handle that reverses every side +effect. + +### Persona JSON → running agent (end-to-end) + +```ts +import { + buildPersonaSpawnPlan, + executePersonaSpawnPlan, + type ResolvedPersona +} from '@agentworkforce/persona-kit'; +import { spawn } from 'node:child_process'; + +declare const persona: ResolvedPersona; // from loadPersonas() / personaCatalog + +// 1. Compose a plan. Pure — no I/O, no subprocesses. +// PlanOptions: inputValues, envOverrides, processEnv, installRoot. +// cwd belongs to ExecuteOptions, not the plan builder. +const plan = buildPersonaSpawnPlan(persona, { + inputValues: { TASK: 'tidy up the README' } +}); + +// 2. Inspect or stamp the plan if you like — it's JSON-serializable. +console.log(plan.cli, plan.args, plan.env); + +// 3. Run the side effects. Returns a handle whose dispose() reverses them. +const handle = await executePersonaSpawnPlan(plan, { cwd: process.cwd() }); + +try { + // 4. Spawn the harness at handle.cwd with plan.cli + plan.args + plan.env. + await new Promise((resolve, reject) => { + const child = spawn(plan.cli, plan.args, { + cwd: handle.cwd, + env: plan.env, + stdio: 'inherit' + }); + child.on('error', reject); + child.on('close', () => resolve()); + }); +} finally { + // 5. Tear down: removes installed skills, restores sidecar files, + // cleans up materialized config files, releases the mount. + await handle.dispose(); +} +``` + +### Plan structure + +`PersonaSpawnPlan` carries everything a caller needs: + +- `persona` — the resolved persona (model, harness, skills, env, …). +- `cli`, `args`, `initialPrompt` — what to spawn. +- `env` — final environment with input bindings + persona env merged in. +- `skills` — pure `SkillMaterializationPlan` produced by `materializeSkills`. +- `mount`, `sidecars`, `configFiles`, `inputs` — resolved pieces ready for + the executor. + +The plan is **JSON-serializable** — useful for stamping into launch metadata +or sending across a wire (e.g. relay's `getPersonaSpawnPlan`). + +### Piecewise helpers + +For callers who want their own orchestration: + +- `applyPersonaMount(mount, options)` — opens an `@relayfile/local-mount` + sandbox when a mount policy is supplied, no-op otherwise. Returns a handle + exposing the harness `cwd`. +- `runSkillInstalls(plan, options)` — spawns the install commands produced + by `buildInstallArtifacts`. Aborts on the first non-zero exit and attaches + the buffered subprocess output to `SkillInstallError`. +- `writePersonaSidecars(sidecars, options)` — writes `CLAUDE.md` / + `AGENTS.md` with restore-on-dispose semantics. +- `materializePersonaConfigFiles(configFiles, options)` — writes the harness + config files (e.g. `opencode.json`) with restore-on-dispose semantics. + +Each helper returns a handle of shape `{ dispose(): Promise }`. +`executePersonaSpawnPlan` composes them in order: mount → skills → sidecars +→ config files. If any step throws, prior handles are disposed in LIFO +order before the original error propagates. + +### Pure lower-level helpers + +`buildInteractiveSpec`, `materializeSkills`, `parsePersonaFile`, +`resolvePersonaInputs`, etc. remain available for advanced callers who want +finer control than the plan builder offers. diff --git a/packages/persona-kit/package.json b/packages/persona-kit/package.json index 5139402..33bb7a3 100644 --- a/packages/persona-kit/package.json +++ b/packages/persona-kit/package.json @@ -30,5 +30,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsc -p tsconfig.json && node --test dist/*.test.js", "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@relayfile/local-mount": "^0.7.0" } } diff --git a/packages/persona-kit/src/config-files.ts b/packages/persona-kit/src/config-files.ts new file mode 100644 index 0000000..9e1577e --- /dev/null +++ b/packages/persona-kit/src/config-files.ts @@ -0,0 +1,95 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join } from 'node:path'; +import type { InteractiveConfigFile } from './interactive-spec.js'; + +export interface PersonaConfigFilesHandle { + /** Reverse every config-file write. Idempotent. */ + dispose(): Promise; +} + +interface RestoredFile { + path: string; + prior: string | null; +} + +/** + * Reject paths that would escape the cwd or hit absolute targets — same + * policy as the CLI's existing `assertSafeRelativePath` so persona-kit's + * exec helpers can be plugged into the CLI later without weakening the + * sandbox guarantees. + */ +export function assertSafeRelativePath(relPath: string): void { + if (!relPath) { + throw new Error('configFile path must be a non-empty relative path'); + } + if (isAbsolute(relPath)) { + throw new Error( + `configFile path must be relative; got absolute path ${JSON.stringify(relPath)}` + ); + } + const segments = relPath.split(/[\\/]+/); + if (segments.some((s) => s === '..')) { + throw new Error( + `configFile path must not contain ".." segments; got ${JSON.stringify(relPath)}` + ); + } +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +/** + * Materialize each {@link InteractiveConfigFile} under `options.cwd`, + * creating any missing parent directories. The returned handle restores + * each touched path to its prior state on `dispose()`. + */ +export async function materializePersonaConfigFiles( + configFiles: readonly InteractiveConfigFile[], + options: { cwd: string } +): Promise { + const restored: RestoredFile[] = []; + let disposed = false; + try { + for (const file of configFiles) { + assertSafeRelativePath(file.path); + const target = join(options.cwd, file.path); + const prior = await readIfExists(target); + restored.push({ path: target, prior }); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.contents, 'utf8'); + } + } catch (err) { + await disposeRestored(restored); + throw err; + } + return { + async dispose(): Promise { + if (disposed) return; + disposed = true; + await disposeRestored(restored); + } + }; +} + +async function disposeRestored(restored: readonly RestoredFile[]): Promise { + for (let i = restored.length - 1; i >= 0; i -= 1) { + const entry = restored[i]; + try { + if (entry.prior === null) { + await unlink(entry.path).catch((err: NodeJS.ErrnoException) => { + if (err.code !== 'ENOENT') throw err; + }); + } else { + await writeFile(entry.path, entry.prior, 'utf8'); + } + } catch { + // Best-effort. + } + } +} diff --git a/packages/persona-kit/src/execute.test.ts b/packages/persona-kit/src/execute.test.ts new file mode 100644 index 0000000..045ef4e --- /dev/null +++ b/packages/persona-kit/src/execute.test.ts @@ -0,0 +1,316 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildPersonaSpawnPlan, type ResolvedPersona } from './plan.js'; +import { executePersonaSpawnPlan } from './execute.js'; +import { writePersonaSidecars } from './sidecars.js'; +import { materializePersonaConfigFiles } from './config-files.js'; +import { runSkillInstalls, SkillInstallError } from './skill-runner.js'; +import type { SkillMaterializationPlan } from './types.js'; + +const cleanEnv: NodeJS.ProcessEnv = Object.freeze({}) as NodeJS.ProcessEnv; + +function persona(over: Partial = {}): ResolvedPersona { + return { + personaId: 'p', + tier: 'best-value', + runtime: { + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + skills: [], + rationale: 'test', + ...over + }; +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function withTmpDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'persona-kit-test-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('writePersonaSidecars overwrite + dispose restores empty target', async () => { + await withTmpDir(async (dir) => { + const handle = await writePersonaSidecars( + [{ filename: 'CLAUDE.md', contents: 'persona body', mode: 'overwrite' }], + { cwd: dir } + ); + const target = join(dir, 'CLAUDE.md'); + assert.equal(await readFile(target, 'utf8'), 'persona body'); + await handle.dispose(); + assert.equal(await exists(target), false); + // Idempotent + await handle.dispose(); + }); +}); + +test('writePersonaSidecars resolves sourcePath at write time', async () => { + await withTmpDir(async (dir) => { + const sourcePath = join(dir, 'source.md'); + await writeFile(sourcePath, 'persona body from disk', 'utf8'); + const handle = await writePersonaSidecars( + [{ filename: 'CLAUDE.md', sourcePath, mode: 'overwrite' }], + { cwd: dir } + ); + assert.equal( + await readFile(join(dir, 'CLAUDE.md'), 'utf8'), + 'persona body from disk' + ); + await handle.dispose(); + }); +}); + +test('writePersonaSidecars rejects unsafe filenames', async () => { + await withTmpDir(async (dir) => { + await assert.rejects( + writePersonaSidecars( + [ + { + filename: '../escape.md' as 'CLAUDE.md', + contents: 'x', + mode: 'overwrite' + } + ], + { cwd: dir } + ), + /must be a basename/ + ); + await assert.rejects( + writePersonaSidecars( + [ + { + filename: '/abs.md' as 'CLAUDE.md', + contents: 'x', + mode: 'overwrite' + } + ], + { cwd: dir } + ), + /must be relative/ + ); + }); +}); + +test('writePersonaSidecars extend joins existing content with delimiter', async () => { + await withTmpDir(async (dir) => { + const target = join(dir, 'AGENTS.md'); + await writeFile(target, 'existing body', 'utf8'); + const handle = await writePersonaSidecars( + [{ filename: 'AGENTS.md', contents: 'persona body', mode: 'extend' }], + { cwd: dir } + ); + assert.equal( + await readFile(target, 'utf8'), + 'existing body\n\n---\n\npersona body' + ); + await handle.dispose(); + assert.equal(await readFile(target, 'utf8'), 'existing body'); + }); +}); + +test('materializePersonaConfigFiles writes nested paths and restores them', async () => { + await withTmpDir(async (dir) => { + const handle = await materializePersonaConfigFiles( + [{ path: '.opencode/agents.json', contents: '{}' }], + { cwd: dir } + ); + assert.equal(await readFile(join(dir, '.opencode/agents.json'), 'utf8'), '{}'); + await handle.dispose(); + assert.equal(await exists(join(dir, '.opencode/agents.json')), false); + }); +}); + +test('materializePersonaConfigFiles rejects unsafe paths before writing', async () => { + await withTmpDir(async (dir) => { + await assert.rejects( + materializePersonaConfigFiles([{ path: '../escape', contents: 'x' }], { cwd: dir }), + /must not contain ".." segments/ + ); + await assert.rejects( + materializePersonaConfigFiles([{ path: '/abs', contents: 'x' }], { cwd: dir }), + /must be relative/ + ); + }); +}); + +test('runSkillInstalls rejects cleanup paths that escape cwd', async () => { + await withTmpDir(async (dir) => { + const plan: SkillMaterializationPlan = { + harness: 'claude', + installs: [ + { + skillId: 's', + source: 'x/y', + sourceKind: 'prpm', + packageRef: 'x/y', + harness: 'claude', + installCommand: [process.execPath, '-e', 'process.exit(0)'], + installedDir: '.claude/skills/y', + installedManifest: '.claude/skills/y/SKILL.md', + cleanupPaths: ['../escape'] + } + ] + }; + const handle = await runSkillInstalls(plan, { cwd: dir }); + await assert.rejects(handle.dispose(), /must stay within cwd/); + }); +}); + +test('runSkillInstalls scaffolds the session install root and disposes it', async () => { + await withTmpDir(async (dir) => { + const sessionRoot = join(dir, 'session'); + const plan: SkillMaterializationPlan = { + harness: 'claude', + installs: [], + sessionInstallRoot: sessionRoot + }; + const handle = await runSkillInstalls(plan, { cwd: dir }); + assert.ok(await exists(join(sessionRoot, '.claude-plugin', 'plugin.json'))); + await handle.dispose(); + assert.equal(await exists(sessionRoot), false); + }); +}); + +test('runSkillInstalls spawns the chained install command for a non-session plan', async () => { + await withTmpDir(async (dir) => { + // Non-session mode runs `install.installCommand` verbatim — use a + // harmless `true` so the spawn path is exercised without network or + // disk side effects. cleanupPaths is empty so dispose is a no-op. + const plan: SkillMaterializationPlan = { + harness: 'claude', + installs: [ + { + skillId: 'noop', + source: 'noop/noop', + sourceKind: 'prpm', + packageRef: 'noop/noop', + harness: 'claude', + installCommand: [process.execPath, '-e', 'process.exit(0)'], + installedDir: '.claude/skills/noop', + installedManifest: '.claude/skills/noop/SKILL.md', + cleanupPaths: [] + } + ] + }; + const handle = await runSkillInstalls(plan, { cwd: dir }); + await handle.dispose(); + }); +}); + +test('runSkillInstalls surfaces a non-zero install with SkillInstallError', async () => { + await withTmpDir(async (dir) => { + const plan: SkillMaterializationPlan = { + harness: 'claude', + installs: [ + { + skillId: 'fail', + source: 'fail/fail', + sourceKind: 'prpm', + packageRef: 'fail/fail', + harness: 'claude', + installCommand: [process.execPath, '-e', 'process.exit(17)'], + installedDir: '.claude/skills/fail', + installedManifest: '.claude/skills/fail/SKILL.md', + cleanupPaths: [] + } + ] + }; + await assert.rejects(runSkillInstalls(plan, { cwd: dir }), (err: Error) => { + assert.equal(err.name, 'SkillInstallError'); + assert.equal((err as SkillInstallError).exitCode, 17); + return true; + }); + }); +}); + +test('executePersonaSpawnPlan happy path orders side effects and disposes them in LIFO', async () => { + await withTmpDir(async (dir) => { + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + runtime: { + harness: 'opencode', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'opencode prompt', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + agentsMdContent: '# persona agents', + agentsMdMode: 'overwrite' + }), + { processEnv: cleanEnv } + ); + assert.ok(plan.configFiles.some((f) => f.path.endsWith('opencode.json'))); + assert.equal(plan.sidecars[0]?.filename, 'AGENTS.md'); + + const handle = await executePersonaSpawnPlan(plan, { cwd: dir }); + assert.equal(handle.cwd, dir); + assert.equal(await readFile(join(dir, 'AGENTS.md'), 'utf8'), '# persona agents'); + assert.equal(await exists(join(dir, 'opencode.json')), true); + + await handle.dispose(); + assert.equal(await exists(join(dir, 'AGENTS.md')), false); + assert.equal(await exists(join(dir, 'opencode.json')), false); + // Idempotent + await handle.dispose(); + }); +}); + +test('executePersonaSpawnPlan empty-skills path is a no-op for skills', async () => { + await withTmpDir(async (dir) => { + const plan = buildPersonaSpawnPlan(persona(), { processEnv: cleanEnv }); + assert.equal(plan.skills.installs.length, 0); + const handle = await executePersonaSpawnPlan(plan, { cwd: dir }); + assert.equal(handle.cwd, dir); + await handle.dispose(); + }); +}); + +test('executePersonaSpawnPlan disposes prior handles when a later step fails', async () => { + await withTmpDir(async (dir) => { + // Pre-create a stub at AGENTS.md so we can verify it gets restored after a + // later failing step. Then synthesize a plan whose configFile path is unsafe + // — it should reject after the sidecar step has already written to disk. + const target = join(dir, 'AGENTS.md'); + await writeFile(target, 'previous content', 'utf8'); + + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + runtime: { + harness: 'opencode', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'opencode prompt', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + agentsMdContent: '# persona agents', + agentsMdMode: 'overwrite' + }), + { processEnv: cleanEnv } + ); + // Inject an unsafe configFile to force materializePersonaConfigFiles to throw + // after the sidecar has been written. The executor must then restore the + // sidecar's prior content before the error propagates. + plan.configFiles.push({ path: '../escape.json', contents: 'x' }); + + await assert.rejects(executePersonaSpawnPlan(plan, { cwd: dir }), /must not contain/); + // Sidecar must be restored to its original content. + assert.equal(await readFile(target, 'utf8'), 'previous content'); + }); +}); diff --git a/packages/persona-kit/src/execute.ts b/packages/persona-kit/src/execute.ts new file mode 100644 index 0000000..44ea15e --- /dev/null +++ b/packages/persona-kit/src/execute.ts @@ -0,0 +1,120 @@ +import { + applyPersonaMount, + type ApplyPersonaMountOptions, + type PersonaMountHandle +} from './mount.js'; +import { + materializePersonaConfigFiles, + type PersonaConfigFilesHandle +} from './config-files.js'; +import { writePersonaSidecars, type PersonaSidecarHandle } from './sidecars.js'; +import { runSkillInstalls, type PersonaSkillsHandle } from './skill-runner.js'; +import type { PersonaSpawnPlan } from './plan.js'; + +export interface ExecutionHandle { + /** The cwd the harness should be spawned in (mount root if mounted). */ + readonly cwd: string; + /** + * Reverse every side effect in LIFO order. Idempotent; safe to call twice. + * Best-effort: a failure inside one disposer does not prevent the others + * from running. + */ + dispose(): Promise; +} + +export interface ExecuteOptions { + /** Working directory for skill installs and sidecar writes. */ + cwd: string; + /** + * Whether to remove `.claude/skills/` and friends when `dispose()` runs. + * Default true — leaving someone's repo with a half-installed skills dir + * after a one-shot spawn is the bigger surprise. + */ + cleanupSkillsOnDispose?: boolean; + /** + * Mount-specific options forwarded to {@link applyPersonaMount} when the + * plan carries a {@link PersonaSpawnPlan.mount} policy. Required in that + * case — the executor cannot guess a mountDir. + */ + mount?: Omit; +} + +interface Disposer { + dispose(): Promise; +} + +async function disposeAll(handles: readonly Disposer[]): Promise { + for (let i = handles.length - 1; i >= 0; i -= 1) { + try { + await handles[i].dispose(); + } catch { + // Best-effort — keep going so the remaining handles get a chance. + } + } +} + +/** + * Run the plan's side effects in deterministic order with abort-on-failure. + * After this returns successfully, the harness can be spawned at + * `handle.cwd` with `plan.cli` + `plan.args` and `plan.env`. + * + * Order: + * 1. {@link applyPersonaMount} — mount policy first; everything else + * writes into the resulting cwd. + * 2. {@link runSkillInstalls} — install before sidecars/configFiles so a + * failing skill doesn't strand a half-written sidecar on disk. + * 3. {@link writePersonaSidecars} — claudeMd / agentsMd to disk with + * restore tracking. + * 4. {@link materializePersonaConfigFiles} — opencode.json and friends. + * + * If any step throws, prior steps' handles are disposed in LIFO order + * before the original error propagates. Callers never see partial state. + */ +export async function executePersonaSpawnPlan( + plan: PersonaSpawnPlan, + options: ExecuteOptions +): Promise { + const handles: Disposer[] = []; + let mountHandle: PersonaMountHandle | undefined; + try { + mountHandle = await applyPersonaMount(plan.mount, { + cwd: options.cwd, + personaId: plan.persona.personaId, + ...(options.mount ?? {}) + }); + handles.push(mountHandle); + + const childCwd = mountHandle.cwd; + + const skillsHandle: PersonaSkillsHandle = await runSkillInstalls(plan.skills, { + cwd: childCwd, + cleanupOnDispose: options.cleanupSkillsOnDispose ?? true + }); + handles.push(skillsHandle); + + const sidecarHandle: PersonaSidecarHandle = await writePersonaSidecars( + plan.sidecars, + { cwd: childCwd } + ); + handles.push(sidecarHandle); + + const configHandle: PersonaConfigFilesHandle = await materializePersonaConfigFiles( + plan.configFiles, + { cwd: childCwd } + ); + handles.push(configHandle); + + let disposed = false; + return { + cwd: childCwd, + async dispose(): Promise { + if (disposed) return; + disposed = true; + await disposeAll(handles); + } + }; + } catch (err) { + await disposeAll(handles); + throw err; + } +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 08f9307..c48c1d9 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -120,3 +120,43 @@ export { detectHarnesses, type HarnessAvailability } from './detect.js'; + +// Plan builder + plan types +export { + buildPersonaSpawnPlan, + type PersonaSpawnPlan, + type PlanOptions, + type ResolvedInputBinding, + type ResolvedMountPolicy, + type ResolvedPersona, + type ResolvedSidecarWrite +} from './plan.js'; + +// Side-effecting orchestration +export { + executePersonaSpawnPlan, + type ExecuteOptions, + type ExecutionHandle +} from './execute.js'; + +// Piecewise side-effect helpers (advanced orchestration) +export { + applyPersonaMount, + type ApplyPersonaMountOptions, + type PersonaMountHandle +} from './mount.js'; +export { + writePersonaSidecars, + type PersonaSidecarHandle +} from './sidecars.js'; +export { + assertSafeRelativePath, + materializePersonaConfigFiles, + type PersonaConfigFilesHandle +} from './config-files.js'; +export { + runSkillInstalls, + SkillInstallError, + type PersonaSkillsHandle, + type RunSkillInstallsOptions +} from './skill-runner.js'; diff --git a/packages/persona-kit/src/mount.ts b/packages/persona-kit/src/mount.ts new file mode 100644 index 0000000..ae8f340 --- /dev/null +++ b/packages/persona-kit/src/mount.ts @@ -0,0 +1,91 @@ +import { createMount } from '@relayfile/local-mount'; +import type { ResolvedMountPolicy } from './plan.js'; + +export interface PersonaMountHandle { + /** + * Working directory the harness should be spawned in. When the mount is + * undefined this is the caller-supplied cwd unchanged; when a mount is + * applied, this is the per-session mount directory. + */ + readonly cwd: string; + /** Tear down the mount. Idempotent; safe to call twice. */ + dispose(): Promise; +} + +export interface ApplyPersonaMountOptions { + /** Directory the harness would otherwise be spawned in. */ + cwd: string; + /** + * Absolute path the mount should be created under. Required when a mount + * policy is supplied. Ignored when `mount` is undefined. + */ + mountDir?: string; + /** + * Persona id used to label the mount's per-session `.git` worktree (when + * `includeGit` is true). Required when a mount policy is supplied. + */ + personaId?: string; + /** + * Whether to mirror the project's `.git` into the mount. Defaults to true + * so git commands work inside the sandbox; set false when callers want a + * pure file overlay. + */ + includeGit?: boolean; +} + +/** + * Apply the persona's mount policy. When `mount` is undefined, returns a + * no-op handle whose `cwd` is the caller-supplied directory — the harness + * runs in-place. When `mount` is defined, opens an `@relayfile/local-mount` + * sandbox under `options.mountDir` and returns a handle whose `cwd` is the + * mount root. `dispose()` tears the mount down. + * + * The caller is responsible for picking the mountDir (typically a + * per-session scratch directory) and for any auto-sync orchestration outside + * of mount lifecycle. Persona-kit's mount handle covers open/close only. + */ +export async function applyPersonaMount( + mount: ResolvedMountPolicy | undefined, + options: ApplyPersonaMountOptions +): Promise { + if (!mount) { + let disposed = false; + return { + cwd: options.cwd, + async dispose(): Promise { + disposed = true; + return; + } + }; + } + if (!options.mountDir) { + throw new Error( + 'applyPersonaMount: options.mountDir is required when a mount policy is supplied' + ); + } + if (!options.personaId) { + throw new Error( + 'applyPersonaMount: options.personaId is required when a mount policy is supplied' + ); + } + const handle = await createMount(options.cwd, options.mountDir, { + ignoredPatterns: [...mount.ignoredPatterns], + readonlyPatterns: [...mount.readonlyPatterns], + excludeDirs: [], + agentName: options.personaId, + includeGit: options.includeGit ?? true + }); + + let disposed = false; + return { + cwd: handle.mountDir, + async dispose(): Promise { + if (disposed) return; + disposed = true; + // Defensive await — relayfile's typed signature is void today, but + // future versions may return a promise; awaiting a non-promise is a + // no-op and protects the dispose contract executors rely on. + await handle.cleanup(); + } + }; +} diff --git a/packages/persona-kit/src/plan.test.ts b/packages/persona-kit/src/plan.test.ts new file mode 100644 index 0000000..82bee19 --- /dev/null +++ b/packages/persona-kit/src/plan.test.ts @@ -0,0 +1,250 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPersonaSpawnPlan, type ResolvedPersona } from './plan.js'; +import type { Harness } from './types.js'; + +function persona(over: Partial = {}): ResolvedPersona { + return { + personaId: 'p', + tier: 'best-value', + runtime: { + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + skills: [], + rationale: 'test', + ...over + }; +} + +const cleanEnv: NodeJS.ProcessEnv = Object.freeze({}) as NodeJS.ProcessEnv; + +test('buildPersonaSpawnPlan returns the persona, cli, and args for claude', () => { + const plan = buildPersonaSpawnPlan(persona(), { processEnv: cleanEnv }); + assert.equal(plan.cli, 'claude'); + assert.ok(plan.args.length > 0); + assert.equal(plan.persona.personaId, 'p'); + assert.deepEqual(plan.skills.installs, []); + assert.deepEqual(plan.sidecars, []); + assert.equal(plan.mount, undefined); + assert.deepEqual(plan.inputs, []); + assert.equal(plan.initialPrompt, undefined); +}); + +test('buildPersonaSpawnPlan emits initialPrompt for codex', () => { + const plan = buildPersonaSpawnPlan( + persona({ + runtime: { + harness: 'codex', + model: 'openai/gpt-5', + systemPrompt: 'codex prompt', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + } + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.cli, 'codex'); + assert.equal(plan.initialPrompt, 'codex prompt'); +}); + +test('buildPersonaSpawnPlan emits configFiles for opencode', () => { + const plan = buildPersonaSpawnPlan( + persona({ + personaId: 'sample', + runtime: { + harness: 'opencode', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'opencode prompt', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + } + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.cli, 'opencode'); + assert.ok( + plan.configFiles.some((f) => f.path.endsWith('opencode.json')), + 'opencode plan must emit an opencode.json' + ); +}); + +test('buildPersonaSpawnPlan resolves sidecars from claudeMdContent / agentsMdContent', () => { + const claudePlan = buildPersonaSpawnPlan( + persona({ + claudeMdContent: '# claude sidecar', + claudeMdMode: 'overwrite' + }), + { processEnv: cleanEnv } + ); + assert.equal(claudePlan.sidecars.length, 1); + assert.equal(claudePlan.sidecars[0].filename, 'CLAUDE.md'); + assert.equal(claudePlan.sidecars[0].contents, '# claude sidecar'); + + const opencodePlan = buildPersonaSpawnPlan( + persona({ + agentsMdContent: '# agents sidecar', + agentsMdMode: 'extend', + runtime: { + harness: 'opencode', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + } + }), + { processEnv: cleanEnv } + ); + assert.equal(opencodePlan.sidecars.length, 1); + assert.equal(opencodePlan.sidecars[0].filename, 'AGENTS.md'); + assert.equal(opencodePlan.sidecars[0].mode, 'extend'); +}); + +test('buildPersonaSpawnPlan threads mount policy through when patterns present', () => { + const plan = buildPersonaSpawnPlan( + persona({ + mount: { ignoredPatterns: ['secrets/**'], readonlyPatterns: ['vendor/**'] } + }), + { processEnv: cleanEnv } + ); + assert.deepEqual(plan.mount?.ignoredPatterns, ['secrets/**']); + assert.deepEqual(plan.mount?.readonlyPatterns, ['vendor/**']); +}); + +test('buildPersonaSpawnPlan drops empty mount policy', () => { + const plan = buildPersonaSpawnPlan(persona({ mount: {} }), { processEnv: cleanEnv }); + assert.equal(plan.mount, undefined); +}); + +test('buildPersonaSpawnPlan resolves inputs into env bindings', () => { + const plan = buildPersonaSpawnPlan( + persona({ + inputs: { + OUTPUT_PATH: { default: '/tmp/out' }, + TARGET: { env: 'TARGET_OVERRIDE' } + } + }), + { processEnv: { TARGET_OVERRIDE: 'frobnicate' } as NodeJS.ProcessEnv } + ); + const byName = Object.fromEntries(plan.inputs.map((b) => [b.name, b])); + assert.equal(byName.OUTPUT_PATH.envName, 'OUTPUT_PATH'); + assert.equal(byName.OUTPUT_PATH.value, '/tmp/out'); + assert.equal(byName.TARGET.envName, 'TARGET_OVERRIDE'); + assert.equal(byName.TARGET.value, 'frobnicate'); + assert.equal(plan.env.OUTPUT_PATH, '/tmp/out'); + assert.equal(plan.env.TARGET_OVERRIDE, 'frobnicate'); +}); + +test('buildPersonaSpawnPlan persona env wins over inputs and overrides', () => { + const plan = buildPersonaSpawnPlan( + persona({ + env: { FOO: 'persona-wins' }, + inputs: { FOO: { default: 'from-input' } } + }), + { + processEnv: cleanEnv, + envOverrides: { FOO: 'override-value' } + } + ); + assert.equal(plan.env.FOO, 'persona-wins'); +}); + +test('buildPersonaSpawnPlan is JSON-serializable', () => { + const plan = buildPersonaSpawnPlan( + persona({ + claudeMdContent: '# sidecar', + mount: { ignoredPatterns: ['x'] }, + env: { FOO: 'bar' } + }), + { processEnv: cleanEnv } + ); + const round = JSON.parse(JSON.stringify(plan)); + assert.deepEqual(round.cli, plan.cli); + assert.deepEqual(round.args, plan.args); + assert.deepEqual(round.sidecars, plan.sidecars); + assert.deepEqual(round.mount, plan.mount); + assert.deepEqual(round.env, plan.env); +}); + +test('buildPersonaSpawnPlan threads installRoot into the skill plan', () => { + const plan = buildPersonaSpawnPlan( + persona({ + skills: [ + { + id: 'prpm/x', + source: '@scope/x', + description: 'd' + } + ] + }), + { processEnv: cleanEnv, installRoot: '/tmp/session/plugin' } + ); + assert.equal(plan.skills.sessionInstallRoot, '/tmp/session/plugin'); + // Plugin dirs flow through into the claude argv. + assert.ok( + plan.args.some((arg) => arg === '/tmp/session/plugin'), + 'plugin-dir from installRoot should appear in claude argv' + ); +}); + +test('buildPersonaSpawnPlan emits sourcePath when only claudeMd path is set', () => { + const plan = buildPersonaSpawnPlan( + persona({ claudeMd: '/abs/path/to/CLAUDE.md', claudeMdMode: 'extend' }), + { processEnv: cleanEnv } + ); + assert.equal(plan.sidecars.length, 1); + assert.equal(plan.sidecars[0].filename, 'CLAUDE.md'); + assert.equal(plan.sidecars[0].sourcePath, '/abs/path/to/CLAUDE.md'); + assert.equal(plan.sidecars[0].contents, undefined); + assert.equal(plan.sidecars[0].mode, 'extend'); +}); + +test('buildPersonaSpawnPlan emits sourcePath for opencode/codex agentsMd path', () => { + const plan = buildPersonaSpawnPlan( + persona({ + runtime: { + harness: 'opencode', + model: 'm', + systemPrompt: 's', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + agentsMd: '/abs/path/to/AGENTS.md' + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.sidecars.length, 1); + assert.equal(plan.sidecars[0].sourcePath, '/abs/path/to/AGENTS.md'); +}); + +test('buildPersonaSpawnPlan does not capture ambient env by default', () => { + // No processEnv or includeProcessEnv — plan.env should only carry persona/input bindings. + const plan = buildPersonaSpawnPlan(persona({ env: { ONLY: 'persona' } })); + assert.deepEqual(plan.env, { ONLY: 'persona' }); +}); + +test('buildPersonaSpawnPlan opt-in includeProcessEnv captures process.env', () => { + const sentinel = `__PK_TEST_${Date.now()}_${Math.random()}__`; + process.env[sentinel] = 'on'; + try { + const plan = buildPersonaSpawnPlan(persona(), { includeProcessEnv: true }); + assert.equal(plan.env[sentinel], 'on'); + } finally { + delete process.env[sentinel]; + } +}); + +test('buildPersonaSpawnPlan empty-skills case keeps installs empty', () => { + for (const harness of ['claude', 'codex', 'opencode'] as Harness[]) { + const plan = buildPersonaSpawnPlan( + persona({ + runtime: { + harness, + model: 'm', + systemPrompt: 's', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + } + }), + { processEnv: cleanEnv } + ); + assert.equal(plan.skills.installs.length, 0, `harness ${harness}`); + } +}); diff --git a/packages/persona-kit/src/plan.ts b/packages/persona-kit/src/plan.ts new file mode 100644 index 0000000..f2631cc --- /dev/null +++ b/packages/persona-kit/src/plan.ts @@ -0,0 +1,293 @@ +import { buildInteractiveSpec, type InteractiveConfigFile } from './interactive-spec.js'; +import { resolvePersonaInputs, renderPersonaInputs } from './inputs.js'; +import { materializeSkills } from './skills.js'; +import type { + Harness, + PersonaInputSpec, + PersonaMount, + PersonaSelection, + SidecarMdMode, + SkillMaterializationPlan +} from './types.js'; + +/** + * Alias of {@link PersonaSelection}. The orchestration API names the + * fully-resolved persona "ResolvedPersona" because callers think of a + * spawn plan as "what runs", not "what was selected from the catalog". + */ +export type ResolvedPersona = PersonaSelection; + +/** + * Mount policy resolved against any caller dotfiles / extra patterns. + * The plan carries this through verbatim; the executor passes it to the + * mount provider (e.g. {@link import('./mount.js').applyPersonaMount}). + */ +export interface ResolvedMountPolicy { + ignoredPatterns: string[]; + readonlyPatterns: string[]; +} + +/** + * Sidecar markdown the executor must write into the harness cwd. The body + * is supplied either inline (built-in personas, where the catalog generator + * already inlined the markdown) or by absolute path (local/pack personas + * that ship the sidecar as a sibling file). The path form keeps the plan + * JSON-serializable; the executor reads the file at write time. + */ +export type ResolvedSidecarWrite = { + /** Filename inside the cwd: `CLAUDE.md` (claude) or `AGENTS.md` (opencode/codex). */ + filename: 'CLAUDE.md' | 'AGENTS.md'; + /** + * `overwrite` writes verbatim; `extend` appends a `\n\n---\n\n`-joined + * suffix onto whatever already exists at the destination at execute time. + */ + mode: SidecarMdMode; +} & ( + | { + /** Inlined body. */ + contents: string; + sourcePath?: never; + } + | { + /** Absolute path to read at execute time. */ + sourcePath: string; + contents?: never; + } +); + +/** Per-input env binding to merge into the spawn env. */ +export interface ResolvedInputBinding { + name: string; + /** Env-var name to bind under. Falls back to `name` when the spec omits one. */ + envName: string; + value: string; +} + +export interface PersonaSpawnPlan { + /** The fully resolved persona this plan was built from. */ + persona: ResolvedPersona; + /** Which CLI to spawn (`claude` | `codex` | `opencode`). */ + cli: Harness; + /** argv (excluding the cli itself) that the harness should be spawned with. */ + args: string[]; + /** Optional initial prompt — used by codex's argv-driven prompt mode. */ + initialPrompt?: string; + /** MCP / harness config files to materialize before spawn (and restore after). */ + configFiles: InteractiveConfigFile[]; + /** Pure skills install plan (from {@link materializeSkills}). */ + skills: SkillMaterializationPlan; + /** Resolved mount policy, or undefined if the persona declares none. */ + mount?: ResolvedMountPolicy; + /** Sidecar markdown writes (claudeMd / agentsMd) staged for the run. */ + sidecars: ResolvedSidecarWrite[]; + /** Inputs as resolved env bindings, ready to merge into spawn env. */ + inputs: ResolvedInputBinding[]; + /** + * Final env (process.env merged with input bindings + persona env, with + * persona env winning on conflict). Always materialized so callers do not + * need to re-merge by hand. + */ + env: Record; +} + +export interface PlanOptions { + /** + * Stage skills under this absolute directory instead of the repo's + * `.claude/skills/`. Claude harness only — throws otherwise (matching the + * existing {@link materializeSkills} behavior). + */ + installRoot?: string; + /** Extra env bindings to merge in. Persona env wins on conflict. */ + envOverrides?: Record; + /** + * Process env to read input env-var fallbacks from (default: process.env). + * **Captured into `plan.env`.** The plan is JSON-serializable, so passing + * `process.env` here will inline ambient values (potentially including + * secrets) into the serialized plan — set `includeProcessEnv` only when + * you intend to forward ambient env to the harness, and prefer supplying + * a curated snapshot via {@link processEnv}. + */ + processEnv?: NodeJS.ProcessEnv; + /** + * Opt in to capturing ambient {@link processEnv} into `plan.env`. Default + * `false`: when omitted, `plan.env` contains only persona-author env, + * resolved input bindings, and {@link envOverrides}. When `true` and no + * explicit {@link processEnv} is supplied, `process.env` is captured. + */ + includeProcessEnv?: boolean; + /** + * Caller-supplied input values (highest precedence over env/default). Same + * shape as {@link import('./inputs.js').resolvePersonaInputs}'s `provided`. + */ + inputValues?: Record; +} + +function resolvedInputBindings( + inputs: Record | undefined, + values: Record +): ResolvedInputBinding[] { + if (!inputs) return []; + return Object.entries(inputs) + .filter(([name]) => values[name] !== undefined) + .map(([name, spec]) => ({ + name, + envName: spec.env ?? name, + value: values[name] + })); +} + +function resolveSidecarWrite( + selection: ResolvedPersona +): ResolvedSidecarWrite[] { + const harness = selection.runtime.harness; + if (harness === 'claude') { + if (selection.claudeMdContent !== undefined) { + return [ + { + filename: 'CLAUDE.md', + contents: selection.claudeMdContent, + mode: selection.claudeMdMode ?? 'overwrite' + } + ]; + } + if (selection.claudeMd) { + return [ + { + filename: 'CLAUDE.md', + sourcePath: selection.claudeMd, + mode: selection.claudeMdMode ?? 'overwrite' + } + ]; + } + return []; + } + if (harness === 'opencode' || harness === 'codex') { + if (selection.agentsMdContent !== undefined) { + return [ + { + filename: 'AGENTS.md', + contents: selection.agentsMdContent, + mode: selection.agentsMdMode ?? 'overwrite' + } + ]; + } + if (selection.agentsMd) { + return [ + { + filename: 'AGENTS.md', + sourcePath: selection.agentsMd, + mode: selection.agentsMdMode ?? 'overwrite' + } + ]; + } + return []; + } + return []; +} + +function resolveMountPolicy( + mount: PersonaMount | undefined +): ResolvedMountPolicy | undefined { + if (!mount) return undefined; + const ignored = mount.ignoredPatterns ?? []; + const readonly = mount.readonlyPatterns ?? []; + if (ignored.length === 0 && readonly.length === 0) return undefined; + return { + ignoredPatterns: [...ignored], + readonlyPatterns: [...readonly] + }; +} + +/** + * Pure plan builder. Composes existing persona-kit helpers + * ({@link buildInteractiveSpec}, {@link materializeSkills}, + * {@link resolvePersonaInputs}) into a single inspectable + * {@link PersonaSpawnPlan}. Does **no** filesystem writes and spawns no + * subprocesses. + * + * The returned plan is JSON-serializable: every field is a plain value or + * primitive array. Callers can stamp it into launch metadata, send it across + * a wire, or hand it to {@link import('./execute.js').executePersonaSpawnPlan}. + */ +export function buildPersonaSpawnPlan( + persona: ResolvedPersona, + options: PlanOptions = {} +): PersonaSpawnPlan { + const harness = persona.runtime.harness; + // Input env-var fallbacks read from `processEnv` only when ambient capture + // is opted into. With ambient capture off, `resolvePersonaInputs` sees an + // empty env and inputs must resolve from explicit values, persona + // `inputValues`, or `default` — keeping plans deterministic across hosts. + const processEnv: NodeJS.ProcessEnv = + options.processEnv ?? (options.includeProcessEnv ? process.env : {}); + const inputResolution = resolvePersonaInputs( + persona.inputs ?? persona.inputValues + ? persona.inputs ?? undefined + : undefined, + options.inputValues ?? persona.inputValues, + processEnv + ); + const renderedSystemPrompt = renderPersonaInputs( + persona.runtime.systemPrompt, + inputResolution.values + ); + const skills = materializeSkills( + persona.skills, + harness, + options.installRoot !== undefined ? { installRoot: options.installRoot } : {} + ); + + const spec = buildInteractiveSpec({ + harness, + personaId: persona.personaId, + model: persona.runtime.model, + systemPrompt: renderedSystemPrompt, + ...(persona.mcpServers ? { mcpServers: persona.mcpServers } : {}), + ...(persona.permissions ? { permissions: persona.permissions } : {}), + ...(persona.runtime.harnessSettings + ? { harnessSettings: persona.runtime.harnessSettings } + : {}), + ...(skills.sessionInstallRoot + ? { pluginDirs: [skills.sessionInstallRoot] } + : {}) + }); + + const inputBindings = resolvedInputBindings(persona.inputs, inputResolution.values); + const sidecars = resolveSidecarWrite(persona); + const mount = resolveMountPolicy(persona.mount); + + // Env precedence (later wins): + // ambient processEnv (opt-in) + // → resolved input bindings + // → caller envOverrides + // → persona-author env + // + // Ambient capture is opt-in to keep secrets out of the JSON-serializable + // plan by default — callers must pass `includeProcessEnv: true` (or supply + // a curated `processEnv` snapshot) to forward ambient values. + const env: Record = {}; + const ambientSource = + options.processEnv ?? (options.includeProcessEnv ? process.env : undefined); + if (ambientSource) { + for (const [k, v] of Object.entries(ambientSource)) { + if (typeof v === 'string') env[k] = v; + } + } + for (const binding of inputBindings) env[binding.envName] = binding.value; + if (options.envOverrides) Object.assign(env, options.envOverrides); + if (persona.env) Object.assign(env, persona.env); + + const plan: PersonaSpawnPlan = { + persona, + cli: harness, + args: [...spec.args], + configFiles: spec.configFiles.map((f) => ({ path: f.path, contents: f.contents })), + skills, + sidecars, + inputs: inputBindings, + env, + ...(spec.initialPrompt !== null ? { initialPrompt: spec.initialPrompt } : {}), + ...(mount ? { mount } : {}) + }; + return plan; +} diff --git a/packages/persona-kit/src/sidecars.ts b/packages/persona-kit/src/sidecars.ts new file mode 100644 index 0000000..9030c01 --- /dev/null +++ b/packages/persona-kit/src/sidecars.ts @@ -0,0 +1,121 @@ +import { readFile, writeFile, unlink } from 'node:fs/promises'; +import { basename, isAbsolute, join } from 'node:path'; +import type { ResolvedSidecarWrite } from './plan.js'; + +export interface PersonaSidecarHandle { + /** Reverse the write. Idempotent; safe to call twice. */ + dispose(): Promise; +} + +interface ResoredFile { + path: string; + /** Prior contents to restore, or null if the file didn't exist. */ + prior: string | null; +} + +const SIDECAR_DELIMITER = '\n\n---\n\n'; + +/** + * The plan's filename is typed `'CLAUDE.md' | 'AGENTS.md'` at compile time, + * but plans can be JSON-deserialized from untrusted sources at runtime. + * Bound to safe basenames here so a hand-built or tampered plan cannot + * escape `cwd` via `..` or absolute path segments. + */ +function assertSafeSidecarFilename(filename: string): void { + if (!filename) throw new Error('sidecar filename must be non-empty'); + if (isAbsolute(filename)) { + throw new Error( + `sidecar filename must be relative; got ${JSON.stringify(filename)}` + ); + } + if (basename(filename) !== filename) { + throw new Error( + `sidecar filename must be a basename (no directory segments); got ${JSON.stringify(filename)}` + ); + } +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +async function loadSidecarBody(sidecar: ResolvedSidecarWrite): Promise { + if (sidecar.contents !== undefined) return sidecar.contents; + if (sidecar.sourcePath !== undefined) { + if (!isAbsolute(sidecar.sourcePath)) { + throw new Error( + `ResolvedSidecarWrite.sourcePath must be absolute; got ${JSON.stringify(sidecar.sourcePath)}` + ); + } + return readFile(sidecar.sourcePath, 'utf8'); + } + // Type system already enforces this; the runtime check keeps the message + // clear if a hand-built plan slips through. + const probe = sidecar as { filename?: string }; + throw new Error( + `ResolvedSidecarWrite for ${probe.filename ?? ''} must supply either contents or sourcePath.` + ); +} + +/** + * Write each sidecar to `/`. In `extend` mode the persona body + * is appended to the existing on-disk content (joined with `\n\n---\n\n`). In + * `overwrite` mode the file is replaced. Path-backed sidecars + * ({@link ResolvedSidecarWrite.sourcePath}) are read at this point, so the + * plan stays JSON-serializable. The returned handle restores every touched + * file to its prior state on `dispose()`. + */ +export async function writePersonaSidecars( + sidecars: readonly ResolvedSidecarWrite[], + options: { cwd: string } +): Promise { + const restored: ResoredFile[] = []; + let disposed = false; + try { + for (const sidecar of sidecars) { + assertSafeSidecarFilename(sidecar.filename); + const target = join(options.cwd, sidecar.filename); + const personaBody = await loadSidecarBody(sidecar); + const prior = await readIfExists(target); + restored.push({ path: target, prior }); + const body = + sidecar.mode === 'extend' && prior !== null + ? `${prior}${SIDECAR_DELIMITER}${personaBody}` + : personaBody; + await writeFile(target, body, 'utf8'); + } + } catch (err) { + await disposeRestored(restored); + throw err; + } + return { + async dispose(): Promise { + if (disposed) return; + disposed = true; + await disposeRestored(restored); + } + }; +} + +async function disposeRestored(restored: readonly ResoredFile[]): Promise { + for (let i = restored.length - 1; i >= 0; i -= 1) { + const entry = restored[i]; + try { + if (entry.prior === null) { + await unlink(entry.path).catch((err: NodeJS.ErrnoException) => { + if (err.code !== 'ENOENT') throw err; + }); + } else { + await writeFile(entry.path, entry.prior, 'utf8'); + } + } catch { + // Best-effort restore — losing a file restore must not stop the + // remaining entries from being processed. + } + } +} diff --git a/packages/persona-kit/src/skill-runner.ts b/packages/persona-kit/src/skill-runner.ts new file mode 100644 index 0000000..4794d53 --- /dev/null +++ b/packages/persona-kit/src/skill-runner.ts @@ -0,0 +1,122 @@ +import { spawn } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import { constants as osConstants } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { buildInstallArtifacts } from './skills.js'; +import type { SkillMaterializationPlan } from './types.js'; + +export interface PersonaSkillsHandle { + /** + * Remove the per-install ephemeral artifact paths declared by the plan + * (or, in session mode, the whole session install root). Idempotent. + */ + dispose(): Promise; +} + +export interface RunSkillInstallsOptions { + cwd: string; + /** + * When true (default) the dispose handle deletes installed skills paths + * (or, in session mode, the entire session install root). Set false to + * keep installs around — e.g. for repeat runs that share a stage dir. + */ + cleanupOnDispose?: boolean; +} + +export class SkillInstallError extends Error { + readonly exitCode: number; + readonly output: string; + constructor(exitCode: number, output: string) { + super(`Skill install failed (exit ${exitCode})`); + this.name = 'SkillInstallError'; + this.exitCode = exitCode; + this.output = output; + } +} + +function signalExitCode(signal: NodeJS.Signals | null): number { + if (!signal) return 0; + const num = (osConstants.signals as Record)[signal]; + return 128 + (num ?? 1); +} + +async function spawnInstall( + command: readonly string[], + cwd: string +): Promise<{ code: number; output: string }> { + const [bin, ...args] = command; + if (!bin) return { code: 0, output: '' }; + return new Promise((resolve) => { + const child = spawn(bin, args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + cwd + }); + let buffered = ''; + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + buffered += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + buffered += chunk; + }); + child.on('error', (err) => { + resolve({ code: 1, output: `${buffered}${err.message}\n` }); + }); + child.on('close', (status, signal) => { + const exit = + typeof status === 'number' ? status : signal ? signalExitCode(signal) : 1; + resolve({ code: exit, output: buffered }); + }); + }); +} + +/** + * Run every install in a {@link SkillMaterializationPlan}. Aborts on the + * first non-zero exit code with the buffered subprocess output attached to + * the thrown error. The returned handle removes the installed artifacts on + * `dispose()` (or the whole session root in session-install-root mode). + */ +export async function runSkillInstalls( + plan: SkillMaterializationPlan, + options: RunSkillInstallsOptions +): Promise { + const cleanupOnDispose = options.cleanupOnDispose ?? true; + const artifacts = buildInstallArtifacts(plan); + if (artifacts.installCommandString !== ':') { + const { code, output } = await spawnInstall(artifacts.installCommand, options.cwd); + if (code !== 0) { + throw new SkillInstallError(code, output); + } + } + + let disposed = false; + return { + async dispose(): Promise { + if (disposed) return; + disposed = true; + if (!cleanupOnDispose) return; + if (plan.sessionInstallRoot !== undefined) { + await rm(plan.sessionInstallRoot, { recursive: true, force: true }); + return; + } + const cwdAbs = resolve(options.cwd); + for (const install of plan.installs) { + for (const path of install.cleanupPaths) { + const abs = isAbsolute(path) ? resolve(path) : resolve(cwdAbs, path); + const rel = relative(cwdAbs, abs); + // Refuse to follow a tampered plan into directories outside the + // workspace — `rm -rf` doesn't get a free pass just because the + // path was declared in plan data. + if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { + throw new Error( + `runSkillInstalls: cleanup path must stay within cwd; got ${JSON.stringify(path)}` + ); + } + await rm(abs, { recursive: true, force: true }); + } + } + } + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe3e77..fb377b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,7 +54,11 @@ importers: specifier: workspace:* version: link:../workload-router - packages/persona-kit: {} + packages/persona-kit: + dependencies: + '@relayfile/local-mount': + specifier: ^0.7.0 + version: 0.7.0 packages/personas-core: {}