diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b82ebf6..db51b708 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.2", + "version": "0.26.4", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/spawn-skill.test.ts b/packages/cli/src/__tests__/spawn-skill.test.ts index 986163e3..914320f5 100644 --- a/packages/cli/src/__tests__/spawn-skill.test.ts +++ b/packages/cli/src/__tests__/spawn-skill.test.ts @@ -1,13 +1,5 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - getSpawnSkillPath, - getSpawnSkillSourceFile, - injectSpawnSkill, - isAppendMode, - readSkillContent, -} from "../shared/spawn-skill.js"; +import { getSkillContent, getSpawnSkillPath, injectSpawnSkill, isAppendMode } from "../shared/spawn-skill.js"; // ─── Path mapping tests ───────────────────────────────────────────────────── @@ -49,44 +41,6 @@ describe("getSpawnSkillPath", () => { }); }); -describe("getSpawnSkillSourceFile", () => { - it("returns correct source for claude", () => { - expect(getSpawnSkillSourceFile("claude")).toBe("claude/SKILL.md"); - }); - - it("returns correct source for codex", () => { - expect(getSpawnSkillSourceFile("codex")).toBe("codex/SKILL.md"); - }); - - it("returns correct source for openclaw", () => { - expect(getSpawnSkillSourceFile("openclaw")).toBe("openclaw/SKILL.md"); - }); - - it("returns correct source for zeroclaw", () => { - expect(getSpawnSkillSourceFile("zeroclaw")).toBe("zeroclaw/AGENTS.md"); - }); - - it("returns correct source for opencode", () => { - expect(getSpawnSkillSourceFile("opencode")).toBe("opencode/AGENTS.md"); - }); - - it("returns correct source for kilocode", () => { - expect(getSpawnSkillSourceFile("kilocode")).toBe("kilocode/spawn.md"); - }); - - it("returns correct source for hermes", () => { - expect(getSpawnSkillSourceFile("hermes")).toBe("hermes/SOUL.md"); - }); - - it("returns correct source for junie", () => { - expect(getSpawnSkillSourceFile("junie")).toBe("junie/AGENTS.md"); - }); - - it("returns undefined for unknown agent", () => { - expect(getSpawnSkillSourceFile("nonexistent")).toBeUndefined(); - }); -}); - // ─── Append mode tests ────────────────────────────────────────────────────── describe("isAppendMode", () => { @@ -123,12 +77,9 @@ describe("isAppendMode", () => { }); }); -// ─── Skill file existence tests ───────────────────────────────────────────── - -describe("skill files exist in repo", () => { - // Find the skills/ directory relative to this test - const skillsDir = join(import.meta.dir, "../../../../skills"); +// ─── Embedded content tests ───────────────────────────────────────────────── +describe("getSkillContent", () => { const agents = [ "claude", "codex", @@ -141,13 +92,10 @@ describe("skill files exist in repo", () => { ]; for (const agent of agents) { - it(`skill file exists and is non-empty for ${agent}`, () => { - const sourceFile = getSpawnSkillSourceFile(agent); - expect(sourceFile).toBeDefined(); - const filePath = join(skillsDir, sourceFile!); - expect(existsSync(filePath)).toBe(true); - const content = readFileSync(filePath, "utf-8"); - expect(content.length).toBeGreaterThan(0); + it(`returns non-empty content for ${agent}`, () => { + const content = getSkillContent(agent); + expect(content).toBeDefined(); + expect(content!.length).toBeGreaterThan(0); }); } @@ -156,12 +104,11 @@ describe("skill files exist in repo", () => { "codex", "openclaw", ]) { - it(`${agent} skill file contains YAML frontmatter with name: spawn`, () => { - const sourceFile = getSpawnSkillSourceFile(agent); - const filePath = join(skillsDir, sourceFile!); - const content = readFileSync(filePath, "utf-8"); - expect(content).toStartWith("---\n"); - expect(content).toContain("name: spawn"); + it(`${agent} content has YAML frontmatter with name: spawn`, () => { + const content = getSkillContent(agent); + expect(content).toBeDefined(); + expect(content!).toStartWith("---\n"); + expect(content!).toContain("name: spawn"); }); } @@ -171,13 +118,23 @@ describe("skill files exist in repo", () => { "kilocode", "junie", ]) { - it(`${agent} skill file is plain markdown (no YAML frontmatter)`, () => { - const sourceFile = getSpawnSkillSourceFile(agent); - const filePath = join(skillsDir, sourceFile!); - const content = readFileSync(filePath, "utf-8"); - expect(content).toStartWith("# Spawn"); + it(`${agent} content is plain markdown (no YAML frontmatter)`, () => { + const content = getSkillContent(agent); + expect(content).toBeDefined(); + expect(content!).toStartWith("# Spawn"); }); } + + it("hermes content is short append snippet", () => { + const content = getSkillContent("hermes"); + expect(content).toBeDefined(); + expect(content!).toContain("Spawn Capability"); + expect(content!).not.toContain("# Spawn — Create Child VMs"); + }); + + it("returns undefined for unknown agent", () => { + expect(getSkillContent("nonexistent")).toBeUndefined(); + }); }); // ─── injectSpawnSkill tests ───────────────────────────────────────────────── @@ -290,20 +247,6 @@ describe("injectSpawnSkill", () => { }); }); -// ─── readSkillContent tests ───────────────────────────────────────────────── - -describe("readSkillContent", () => { - it("returns content for known agent", () => { - const content = readSkillContent("claude"); - expect(content).not.toBeNull(); - expect(content).toContain("Spawn"); - }); - - it("returns null for unknown agent", () => { - expect(readSkillContent("nonexistent")).toBeNull(); - }); -}); - // ─── "spawn" step visibility tests ────────────────────────────────────────── describe("spawn step gating", () => { @@ -319,7 +262,6 @@ describe("spawn step gating", () => { it("spawn step appears when SPAWN_BETA includes recursive", async () => { process.env.SPAWN_BETA = "recursive"; - // Re-import to pick up the env var (the function reads env at call time) const { getAgentOptionalSteps } = await import("../shared/agents.js"); const steps = getAgentOptionalSteps("claude"); const spawnStep = steps.find((s) => s.value === "spawn"); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index aaeaa9fe..949d6c5a 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -551,7 +551,14 @@ async function postInstall( } // Spawn CLI + skill injection (recursive spawn) - if (enabledSteps?.has("spawn") && cloud.cloudName !== "local") { + // The "spawn" step is defaultOn when --beta recursive is active, so it should + // run when no explicit steps are selected (!enabledSteps) AND the beta flag is set. + const betaFeaturesPost = new Set((process.env.SPAWN_BETA ?? "").split(",").filter(Boolean)); + if ( + cloud.cloudName !== "local" && + betaFeaturesPost.has("recursive") && + (!enabledSteps || enabledSteps.has("spawn")) + ) { await installSpawnCli(cloud.runner); await delegateCloudCredentials(cloud.runner, cloud.cloudName); await injectSpawnSkill(cloud.runner, agentName); diff --git a/packages/cli/src/shared/spawn-skill.ts b/packages/cli/src/shared/spawn-skill.ts index 13b7b6cf..0cb58a95 100644 --- a/packages/cli/src/shared/spawn-skill.ts +++ b/packages/cli/src/shared/spawn-skill.ts @@ -1,118 +1,156 @@ // shared/spawn-skill.ts — Skill injection for recursive spawn // Writes agent-native instruction files teaching each agent how to use `spawn`. +// Content is embedded directly so it works when installed via npm (no fs reads). import type { CloudRunner } from "./agent-setup.js"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { wrapSshCall } from "./agent-setup.js"; -import { asyncTryCatchIf, isOperationalError, tryCatch } from "./result.js"; +import { asyncTryCatchIf, isOperationalError } from "./result.js"; import { logInfo, logWarn } from "./ui.js"; -/** Map agent name → remote path where the skill file should be written. */ -const SKILL_REMOTE_PATHS: Record = { - claude: "~/.claude/skills/spawn/SKILL.md", - codex: "~/.agents/skills/spawn/SKILL.md", - openclaw: "~/.openclaw/skills/spawn/SKILL.md", - zeroclaw: "~/.zeroclaw/workspace/AGENTS.md", - opencode: "~/.config/opencode/AGENTS.md", - kilocode: "~/.kilocode/rules/spawn.md", - hermes: "~/.hermes/SOUL.md", - junie: "~/.junie/AGENTS.md", -}; +// ─── Embedded skill content ───────────────────────────────────────────────── -/** Map agent name → local file inside the skills/ directory. */ -const SKILL_SOURCE_FILES: Record = { - claude: "claude/SKILL.md", - codex: "codex/SKILL.md", - openclaw: "openclaw/SKILL.md", - zeroclaw: "zeroclaw/AGENTS.md", - opencode: "opencode/AGENTS.md", - kilocode: "kilocode/spawn.md", - hermes: "hermes/SOUL.md", - junie: "junie/AGENTS.md", -}; +/** YAML frontmatter skill (Claude Code, Codex CLI, OpenClaw). */ +const SKILL_FRONTMATTER = `--- +name: spawn +description: Create child cloud VMs with AI coding agents using the spawn CLI +allowed-tools: Bash +--- -/** Agents that use append mode (>>) instead of overwrite (>). */ -const APPEND_AGENTS = new Set([ - "hermes", -]); +`; -/** Get the remote target path for a given agent's spawn skill file. */ -export function getSpawnSkillPath(agentName: string): string | undefined { - return SKILL_REMOTE_PATHS[agentName]; +/** Main skill body (shared by all agents except hermes). */ +const SKILL_BODY = `# Spawn — Create Child VMs + +You have the \`spawn\` CLI installed. Use it to provision cloud VMs with AI agents. + +## Usage + +\`\`\`bash +spawn --headless --output json --prompt "task description" +\`\`\` + +## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie +## Clouds: hetzner, digitalocean, aws, gcp, sprite + +## Example + +\`\`\`bash +spawn claude hetzner --headless --output json --prompt "Implement the auth module" +\`\`\` + +Returns JSON: \`{"status":"success","ip_address":"...","ssh_user":"root","server_id":"..."}\` + +## Managing Children + +- \`spawn list --json\` — see running children +- \`spawn delete\` — tear down a child VM +- \`spawn tree\` — see the full spawn tree + +## Context + +- You are running inside a spawned VM (SPAWN_DEPTH is set) +- Cloud credentials are pre-configured — no auth prompts +- OpenRouter billing is shared with the parent +`; + +/** Short snippet for Hermes (appended to SOUL.md, 20K char cap). */ +const HERMES_SNIPPET = ` + +## Spawn Capability + +You have the \`spawn\` CLI for creating child cloud VMs with AI agents. +Use \`spawn --headless --output json --prompt "task"\` to delegate work. +Available agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie. +Cloud credentials are pre-configured. Run \`spawn list --json\` to see children. +`; + +// ─── Agent config ─────────────────────────────────────────────────────────── + +interface SkillConfig { + remotePath: string; + content: string; + append: boolean; } -/** Get the local source file path (relative to skills/) for a given agent. */ -export function getSpawnSkillSourceFile(agentName: string): string | undefined { - return SKILL_SOURCE_FILES[agentName]; +/** Per-agent skill configuration: remote path, content, and write mode. */ +const AGENT_SKILLS: Record = { + claude: { + remotePath: "~/.claude/skills/spawn/SKILL.md", + content: SKILL_FRONTMATTER + SKILL_BODY, + append: false, + }, + codex: { + remotePath: "~/.agents/skills/spawn/SKILL.md", + content: SKILL_FRONTMATTER + SKILL_BODY, + append: false, + }, + openclaw: { + remotePath: "~/.openclaw/skills/spawn/SKILL.md", + content: SKILL_FRONTMATTER + SKILL_BODY, + append: false, + }, + zeroclaw: { + remotePath: "~/.zeroclaw/workspace/AGENTS.md", + content: SKILL_BODY, + append: false, + }, + opencode: { + remotePath: "~/.config/opencode/AGENTS.md", + content: SKILL_BODY, + append: false, + }, + kilocode: { + remotePath: "~/.kilocode/rules/spawn.md", + content: SKILL_BODY, + append: false, + }, + hermes: { + remotePath: "~/.hermes/SOUL.md", + content: HERMES_SNIPPET, + append: true, + }, + junie: { + remotePath: "~/.junie/AGENTS.md", + content: SKILL_BODY, + append: false, + }, +}; + +/** Get the remote target path for a given agent's spawn skill file. */ +export function getSpawnSkillPath(agentName: string): string | undefined { + return AGENT_SKILLS[agentName]?.remotePath; } /** Whether the agent uses append mode (hermes appends to SOUL.md). */ export function isAppendMode(agentName: string): boolean { - return APPEND_AGENTS.has(agentName); -} - -/** - * Resolve the absolute path to the skills/ directory. - * Works both in dev (source tree) and when bundled (cli.js next to skills/). - */ -function getSkillsDir(): string { - // In the source tree: packages/cli/src/shared/spawn-skill.ts - // skills/ is at the repo root: ../../../../skills/ - // When bundled as cli.js: packages/cli/cli.js → ../../skills/ - // Use import.meta.dir which gives the directory of the current file. - const candidates = [ - join(import.meta.dir, "../../../../skills"), - join(import.meta.dir, "../../../skills"), - join(import.meta.dir, "../../skills"), - ]; - for (const candidate of candidates) { - const r = tryCatch(() => readFileSync(join(candidate, "claude/SKILL.md"))); - if (r.ok) { - return candidate; - } - } - // Fallback: assume repo root relative to process.cwd() - return join(process.cwd(), "skills"); + return AGENT_SKILLS[agentName]?.append === true; } -/** - * Read a skill file's content from the local skills/ directory. - * Returns null if the file doesn't exist or the agent has no skill file. - */ -export function readSkillContent(agentName: string): string | null { - const sourceFile = getSpawnSkillSourceFile(agentName); - if (!sourceFile) { - return null; - } - const r = tryCatch(() => readFileSync(join(getSkillsDir(), sourceFile), "utf-8")); - return r.ok ? r.data : null; +/** Get the embedded skill content for an agent. */ +export function getSkillContent(agentName: string): string | undefined { + return AGENT_SKILLS[agentName]?.content; } /** * Inject the spawn skill file onto a remote VM for the given agent. - * Reads content from skills/{agent}/, base64-encodes it, and writes - * to the agent's native instruction file path on the remote. + * Base64-encodes embedded content and writes to the agent's native + * instruction file path on the remote. */ export async function injectSpawnSkill(runner: CloudRunner, agentName: string): Promise { - const remotePath = getSpawnSkillPath(agentName); - const content = readSkillContent(agentName); - - if (!remotePath || !content) { + const config = AGENT_SKILLS[agentName]; + if (!config) { logWarn(`No spawn skill file for agent: ${agentName}`); return; } - const b64 = Buffer.from(content).toString("base64"); + const b64 = Buffer.from(config.content).toString("base64"); if (!/^[A-Za-z0-9+/=]+$/.test(b64)) { throw new Error("Unexpected characters in base64 output"); } - const append = isAppendMode(agentName); + const { remotePath, append } = config; const operator = append ? ">>" : ">"; - // dirname of ~ paths like ~/.claude/skills/spawn/SKILL.md - // We need to extract the directory portion for mkdir -p const remoteDir = remotePath.slice(0, remotePath.lastIndexOf("/")); const cmd = append