mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
feat: skill injection — teach agents how to use spawn on recursive VMs (#2989)
When `--beta recursive` is active, a new "Spawn CLI" setup step injects agent-native instruction files teaching each agent how to use the `spawn` CLI to create child VMs. Skill files live in `skills/` at the repo root and use each agent's native format (YAML frontmatter for Claude/Codex/ OpenClaw, plain markdown for others, append mode for Hermes). - Add `skills/` directory with 8 agent-specific skill files - Add `spawn-skill.ts` module with path mapping, file reading, and injection - Register "spawn" as a conditional setup step gated by `--beta recursive` - Wire `injectSpawnSkill()` into orchestrate.ts postInstall flow - Add 52 tests covering path mapping, append mode, file existence, injection - Bump CLI version to 0.26.0 (minor: new feature) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7194058c64
commit
17817533a4
12 changed files with 750 additions and 7 deletions
345
packages/cli/src/__tests__/spawn-skill.test.ts
Normal file
345
packages/cli/src/__tests__/spawn-skill.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
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";
|
||||
|
||||
// ─── Path mapping tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("getSpawnSkillPath", () => {
|
||||
it("returns correct path for claude", () => {
|
||||
expect(getSpawnSkillPath("claude")).toBe("~/.claude/skills/spawn/SKILL.md");
|
||||
});
|
||||
|
||||
it("returns correct path for codex", () => {
|
||||
expect(getSpawnSkillPath("codex")).toBe("~/.agents/skills/spawn/SKILL.md");
|
||||
});
|
||||
|
||||
it("returns correct path for openclaw", () => {
|
||||
expect(getSpawnSkillPath("openclaw")).toBe("~/.openclaw/skills/spawn/SKILL.md");
|
||||
});
|
||||
|
||||
it("returns correct path for zeroclaw", () => {
|
||||
expect(getSpawnSkillPath("zeroclaw")).toBe("~/.zeroclaw/workspace/AGENTS.md");
|
||||
});
|
||||
|
||||
it("returns correct path for opencode", () => {
|
||||
expect(getSpawnSkillPath("opencode")).toBe("~/.config/opencode/AGENTS.md");
|
||||
});
|
||||
|
||||
it("returns correct path for kilocode", () => {
|
||||
expect(getSpawnSkillPath("kilocode")).toBe("~/.kilocode/rules/spawn.md");
|
||||
});
|
||||
|
||||
it("returns correct path for hermes", () => {
|
||||
expect(getSpawnSkillPath("hermes")).toBe("~/.hermes/SOUL.md");
|
||||
});
|
||||
|
||||
it("returns correct path for junie", () => {
|
||||
expect(getSpawnSkillPath("junie")).toBe("~/.junie/AGENTS.md");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown agent", () => {
|
||||
expect(getSpawnSkillPath("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("returns true for hermes", () => {
|
||||
expect(isAppendMode("hermes")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for claude", () => {
|
||||
expect(isAppendMode("claude")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for codex", () => {
|
||||
expect(isAppendMode("codex")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for openclaw", () => {
|
||||
expect(isAppendMode("openclaw")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for zeroclaw", () => {
|
||||
expect(isAppendMode("zeroclaw")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for opencode", () => {
|
||||
expect(isAppendMode("opencode")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for kilocode", () => {
|
||||
expect(isAppendMode("kilocode")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for junie", () => {
|
||||
expect(isAppendMode("junie")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
|
||||
const agents = [
|
||||
"claude",
|
||||
"codex",
|
||||
"openclaw",
|
||||
"zeroclaw",
|
||||
"opencode",
|
||||
"kilocode",
|
||||
"hermes",
|
||||
"junie",
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of [
|
||||
"claude",
|
||||
"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");
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of [
|
||||
"zeroclaw",
|
||||
"opencode",
|
||||
"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");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── injectSpawnSkill tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("injectSpawnSkill", () => {
|
||||
it("calls runner.runServer with correct base64 + path for claude", async () => {
|
||||
let capturedCmd = "";
|
||||
const mockRunner = {
|
||||
runServer: mock(async (cmd: string) => {
|
||||
capturedCmd = cmd;
|
||||
}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
await injectSpawnSkill(mockRunner, "claude");
|
||||
|
||||
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
||||
expect(capturedCmd).toContain("~/.claude/skills/spawn/SKILL.md");
|
||||
expect(capturedCmd).toContain("mkdir -p ~/.claude/skills/spawn");
|
||||
expect(capturedCmd).toContain("base64 -d >");
|
||||
expect(capturedCmd).toContain("chmod 644");
|
||||
// Should use overwrite (>) not append (>>)
|
||||
expect(capturedCmd).not.toContain(">>");
|
||||
});
|
||||
|
||||
it("uses append mode (>>) for hermes", async () => {
|
||||
let capturedCmd = "";
|
||||
const mockRunner = {
|
||||
runServer: mock(async (cmd: string) => {
|
||||
capturedCmd = cmd;
|
||||
}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
await injectSpawnSkill(mockRunner, "hermes");
|
||||
|
||||
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
||||
expect(capturedCmd).toContain("~/.hermes/SOUL.md");
|
||||
expect(capturedCmd).toContain(">>");
|
||||
// Should NOT contain chmod for append mode
|
||||
expect(capturedCmd).not.toContain("chmod");
|
||||
});
|
||||
|
||||
it("creates parent directories for all agents", async () => {
|
||||
const agents = [
|
||||
"claude",
|
||||
"codex",
|
||||
"openclaw",
|
||||
"zeroclaw",
|
||||
"opencode",
|
||||
"kilocode",
|
||||
"hermes",
|
||||
"junie",
|
||||
];
|
||||
for (const agent of agents) {
|
||||
let capturedCmd = "";
|
||||
const mockRunner = {
|
||||
runServer: mock(async (cmd: string) => {
|
||||
capturedCmd = cmd;
|
||||
}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
await injectSpawnSkill(mockRunner, agent);
|
||||
expect(capturedCmd).toContain("mkdir -p");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles runner failure gracefully", async () => {
|
||||
const mockRunner = {
|
||||
runServer: mock(async () => {
|
||||
throw new Error("SSH connection refused");
|
||||
}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await injectSpawnSkill(mockRunner, "claude");
|
||||
expect(mockRunner.runServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing for unknown agent", async () => {
|
||||
const mockRunner = {
|
||||
runServer: mock(async () => {}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
await injectSpawnSkill(mockRunner, "nonexistent");
|
||||
expect(mockRunner.runServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("base64-encodes real skill content", async () => {
|
||||
let capturedCmd = "";
|
||||
const mockRunner = {
|
||||
runServer: mock(async (cmd: string) => {
|
||||
capturedCmd = cmd;
|
||||
}),
|
||||
uploadFile: mock(async () => {}),
|
||||
};
|
||||
|
||||
await injectSpawnSkill(mockRunner, "codex");
|
||||
|
||||
// Extract the base64 string from the command
|
||||
const b64Match = capturedCmd.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
|
||||
expect(b64Match).not.toBeNull();
|
||||
// Decode and verify it contains spawn skill content
|
||||
const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8");
|
||||
expect(decoded).toContain("Spawn");
|
||||
expect(decoded).toContain("spawn");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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", () => {
|
||||
const savedBeta = process.env.SPAWN_BETA;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedBeta === undefined) {
|
||||
delete process.env.SPAWN_BETA;
|
||||
} else {
|
||||
process.env.SPAWN_BETA = savedBeta;
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
expect(spawnStep).toBeDefined();
|
||||
expect(spawnStep!.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("spawn step does not appear without --beta recursive", async () => {
|
||||
delete process.env.SPAWN_BETA;
|
||||
const { getAgentOptionalSteps } = await import("../shared/agents.js");
|
||||
const steps = getAgentOptionalSteps("claude");
|
||||
const spawnStep = steps.find((s) => s.value === "spawn");
|
||||
expect(spawnStep).toBeUndefined();
|
||||
});
|
||||
|
||||
it("spawn step appears alongside other beta features", async () => {
|
||||
process.env.SPAWN_BETA = "tarball,recursive";
|
||||
const { getAgentOptionalSteps } = await import("../shared/agents.js");
|
||||
const steps = getAgentOptionalSteps("openclaw");
|
||||
const spawnStep = steps.find((s) => s.value === "spawn");
|
||||
expect(spawnStep).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -113,6 +113,14 @@ const AGENT_EXTRA_STEPS: Record<string, OptionalStep[]> = {
|
|||
],
|
||||
};
|
||||
|
||||
/** The "spawn" step — only shown when --beta recursive is active. */
|
||||
const SPAWN_STEP: OptionalStep = {
|
||||
value: "spawn",
|
||||
label: "Spawn CLI",
|
||||
hint: "install spawn for recursive VM creation",
|
||||
defaultOn: true,
|
||||
};
|
||||
|
||||
/** Steps shown for every agent. */
|
||||
const COMMON_STEPS: OptionalStep[] = [
|
||||
{
|
||||
|
|
@ -140,13 +148,23 @@ const COMMON_STEPS: OptionalStep[] = [
|
|||
|
||||
/** Get the optional setup steps for a given agent (no CloudRunner required). */
|
||||
export function getAgentOptionalSteps(agentName: string): OptionalStep[] {
|
||||
const extra = AGENT_EXTRA_STEPS[agentName];
|
||||
return extra
|
||||
const betaFeatures = (process.env.SPAWN_BETA ?? "").split(",").filter(Boolean);
|
||||
const hasRecursive = betaFeatures.includes("recursive");
|
||||
|
||||
const steps = hasRecursive
|
||||
? [
|
||||
...COMMON_STEPS,
|
||||
...extra,
|
||||
SPAWN_STEP,
|
||||
]
|
||||
: COMMON_STEPS;
|
||||
: [
|
||||
...COMMON_STEPS,
|
||||
];
|
||||
|
||||
const extra = AGENT_EXTRA_STEPS[agentName];
|
||||
if (extra) {
|
||||
steps.push(...extra);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Validate step names against the known steps for an agent.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getOrPromptApiKey } from "./oauth.js";
|
|||
import { getSpawnCloudConfigPath, getSpawnPreferencesPath, getUserHome } from "./paths.js";
|
||||
import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatch } from "./result.js";
|
||||
import { isWindows } from "./shell.js";
|
||||
import { injectSpawnSkill } from "./spawn-skill.js";
|
||||
import { sleep, startSshTunnel } from "./ssh.js";
|
||||
import { ensureSshKeys, getSshKeyOpts } from "./ssh-keys.js";
|
||||
import {
|
||||
|
|
@ -549,11 +550,11 @@ async function postInstall(
|
|||
await setupAutoUpdate(cloud.runner, agentName, agent.updateCmd);
|
||||
}
|
||||
|
||||
// Recursive spawn setup — install spawn CLI and delegate credentials
|
||||
const betaFeaturesPost = new Set((process.env.SPAWN_BETA ?? "").split(",").filter(Boolean));
|
||||
if (betaFeaturesPost.has("recursive") && cloud.cloudName !== "local") {
|
||||
// Spawn CLI + skill injection (recursive spawn)
|
||||
if (enabledSteps?.has("spawn") && cloud.cloudName !== "local") {
|
||||
await installSpawnCli(cloud.runner);
|
||||
await delegateCloudCredentials(cloud.runner, cloud.cloudName);
|
||||
await injectSpawnSkill(cloud.runner, agentName);
|
||||
}
|
||||
|
||||
// Pre-launch hooks (retry loop)
|
||||
|
|
|
|||
129
packages/cli/src/shared/spawn-skill.ts
Normal file
129
packages/cli/src/shared/spawn-skill.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// shared/spawn-skill.ts — Skill injection for recursive spawn
|
||||
// Writes agent-native instruction files teaching each agent how to use `spawn`.
|
||||
|
||||
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 { 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",
|
||||
};
|
||||
|
||||
/** 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",
|
||||
};
|
||||
|
||||
/** 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];
|
||||
}
|
||||
|
||||
/** 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];
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function injectSpawnSkill(runner: CloudRunner, agentName: string): Promise<void> {
|
||||
const remotePath = getSpawnSkillPath(agentName);
|
||||
const content = readSkillContent(agentName);
|
||||
|
||||
if (!remotePath || !content) {
|
||||
logWarn(`No spawn skill file for agent: ${agentName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const b64 = Buffer.from(content).toString("base64");
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) {
|
||||
throw new Error("Unexpected characters in base64 output");
|
||||
}
|
||||
|
||||
const append = isAppendMode(agentName);
|
||||
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
|
||||
? `mkdir -p ${remoteDir} && printf '%s' '${b64}' | base64 -d ${operator} ${remotePath}`
|
||||
: `mkdir -p ${remoteDir} && printf '%s' '${b64}' | base64 -d ${operator} ${remotePath} && chmod 644 ${remotePath}`;
|
||||
|
||||
const result = await asyncTryCatchIf(isOperationalError, () => wrapSshCall(runner.runServer(cmd)));
|
||||
|
||||
if (result.ok) {
|
||||
logInfo(`Spawn skill injected: ${remotePath}`);
|
||||
} else {
|
||||
logWarn("Spawn skill injection failed — agent will work without spawn instructions");
|
||||
}
|
||||
}
|
||||
38
skills/claude/SKILL.md
Normal file
38
skills/claude/SKILL.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: spawn
|
||||
description: Create child cloud VMs with AI coding agents using the spawn CLI
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# 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
|
||||
38
skills/codex/SKILL.md
Normal file
38
skills/codex/SKILL.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: spawn
|
||||
description: Create child cloud VMs with AI coding agents using the spawn CLI
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# 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
|
||||
8
skills/hermes/SOUL.md
Normal file
8
skills/hermes/SOUL.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
|
||||
## 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.
|
||||
32
skills/junie/AGENTS.md
Normal file
32
skills/junie/AGENTS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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
|
||||
32
skills/kilocode/spawn.md
Normal file
32
skills/kilocode/spawn.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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
|
||||
38
skills/openclaw/SKILL.md
Normal file
38
skills/openclaw/SKILL.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: spawn
|
||||
description: Create child cloud VMs with AI coding agents using the spawn CLI
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# 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
|
||||
32
skills/opencode/AGENTS.md
Normal file
32
skills/opencode/AGENTS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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
|
||||
32
skills/zeroclaw/AGENTS.md
Normal file
32
skills/zeroclaw/AGENTS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue