From 5b8774f85394d11f4106b8da2f2cc646b53ede7e Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Wed, 25 Mar 2026 15:56:09 -0700 Subject: [PATCH 1/2] fix: spawn step skipped when no explicit --steps passed The spawn skill injection condition used `enabledSteps?.has("spawn")` which is falsy when enabledSteps is undefined (no --steps flag). Now checks the recursive beta flag directly and falls through when no explicit steps are selected, matching how auto-update works. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- packages/cli/src/shared/orchestrate.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b82ebf6..f732bb16 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.3", "type": "module", "bin": { "spawn": "cli.js" 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); From 0c09003be5778c7fc964411d5a32be6a899e3157 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Wed, 25 Mar 2026 15:59:20 -0700 Subject: [PATCH 2/2] fix: embed skill content in spawn-skill.ts instead of reading from disk The skills/ directory exists in the repo but isn't bundled when the CLI is installed via npm. readSkillContent() couldn't find the files at runtime, causing "No spawn skill file for agent" on every deploy. Fixed by embedding all skill content directly as string constants in the module. Removed fs-based getSkillsDir/readSkillContent/getSpawnSkillSourceFile in favor of a single AGENT_SKILLS config map with inline content. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- .../cli/src/__tests__/spawn-skill.test.ts | 112 +++------- packages/cli/src/shared/spawn-skill.ts | 200 +++++++++++------- 3 files changed, 147 insertions(+), 167 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index f732bb16..db51b708 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.3", + "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/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