mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
fix: embed skill content instead of reading from disk (#2992)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
17817533a4
commit
b47d6bbe1d
4 changed files with 155 additions and 168 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.26.2",
|
||||
"version": "0.26.4",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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",
|
||||
]);
|
||||
`;
|
||||
|
||||
/** 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 <agent> <cloud> --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 <agent> <cloud> --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;
|
||||
}
|
||||
|
||||
/** Per-agent skill configuration: remote path, content, and write mode. */
|
||||
const AGENT_SKILLS: Record<string, SkillConfig> = {
|
||||
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 SKILL_REMOTE_PATHS[agentName];
|
||||
}
|
||||
|
||||
/** 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];
|
||||
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);
|
||||
return AGENT_SKILLS[agentName]?.append === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue