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:
Ahmed Abushagur 2026-03-25 15:32:20 -07:00 committed by GitHub
parent 7194058c64
commit 17817533a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 750 additions and 7 deletions

View 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();
});
});

View file

@ -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.

View file

@ -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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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