mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-26 11:00:38 +00:00
feat(cli): --repo flag clones a template repo and applies spawn.md (#3360)
spawn <agent> <cloud> --repo user/template Clones https://github.com/user/template.git to ~/project on the VM, parses spawn.md (YAML frontmatter), and applies its custom-setup contract: - `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM), api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc), command (run on VM) - `mcp_servers`: env values stay as ${NAME} placeholders so secrets never end up in the template repo. Replay routes through the existing skills.ts helpers (Claude settings.json, Cursor mcp.json, Codex config.toml) — no `node -e` injection. - `setup_commands`: run inside ~/project When the clone succeeds, the agent launches with `cd ~/project && ...` so the user lands in their template's working directory. Reconnect via `spawn last` replays the same launchCmd. Built-in steps (github auth, auto-update, etc.) stay in the CLI --steps flag — spawn.md only handles custom setup that Spawn doesn't know about natively. Bumps CLI to 1.0.22. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0e93a508d
commit
3e6c8768d1
8 changed files with 734 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
91
packages/cli/src/__tests__/spawn-md.test.ts
Normal file
91
packages/cli/src/__tests__/spawn-md.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Unit tests for the spawn.md parser
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseSpawnMd } from "../shared/spawn-md.js";
|
||||
|
||||
describe("parseSpawnMd", () => {
|
||||
it("parses a complete frontmatter block", () => {
|
||||
const content = `---
|
||||
name: my-template
|
||||
description: A test template
|
||||
setup:
|
||||
- type: cli_auth
|
||||
name: Vercel CLI
|
||||
command: vercel login
|
||||
description: Authenticate with Vercel
|
||||
- type: api_key
|
||||
name: STRIPE_KEY
|
||||
description: Stripe live key
|
||||
mcp_servers:
|
||||
- name: github
|
||||
command: gh-mcp
|
||||
args: ["serve"]
|
||||
env:
|
||||
GH_TOKEN: \${GH_TOKEN}
|
||||
setup_commands:
|
||||
- npm install
|
||||
- npm run build
|
||||
---
|
||||
|
||||
# my-template
|
||||
|
||||
Body content here.
|
||||
`;
|
||||
const config = parseSpawnMd(content);
|
||||
expect(config).not.toBeNull();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
expect(config.name).toBe("my-template");
|
||||
expect(config.description).toBe("A test template");
|
||||
expect(config.setup).toHaveLength(2);
|
||||
expect(config.setup?.[0]).toMatchObject({
|
||||
type: "cli_auth",
|
||||
name: "Vercel CLI",
|
||||
command: "vercel login",
|
||||
});
|
||||
expect(config.setup?.[1]).toMatchObject({
|
||||
type: "api_key",
|
||||
name: "STRIPE_KEY",
|
||||
});
|
||||
expect(config.mcp_servers).toHaveLength(1);
|
||||
expect(config.mcp_servers?.[0].name).toBe("github");
|
||||
expect(config.mcp_servers?.[0].args).toEqual([
|
||||
"serve",
|
||||
]);
|
||||
expect(config.mcp_servers?.[0].env).toEqual({
|
||||
GH_TOKEN: "${GH_TOKEN}",
|
||||
});
|
||||
expect(config.setup_commands).toEqual([
|
||||
"npm install",
|
||||
"npm run build",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for content with no frontmatter", () => {
|
||||
expect(parseSpawnMd("# Just a body, no frontmatter\n")).toBeNull();
|
||||
expect(parseSpawnMd("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid frontmatter shape", () => {
|
||||
const content = `---
|
||||
setup:
|
||||
- type: not_a_real_type
|
||||
name: bad
|
||||
---
|
||||
`;
|
||||
expect(parseSpawnMd(content)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a frontmatter with only name", () => {
|
||||
const content = `---
|
||||
name: minimal
|
||||
---
|
||||
body
|
||||
`;
|
||||
const config = parseSpawnMd(content);
|
||||
expect(config?.name).toBe("minimal");
|
||||
expect(config?.setup).toBeUndefined();
|
||||
expect(config?.mcp_servers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -227,6 +227,7 @@ describe("KNOWN_FLAGS completeness", () => {
|
|||
"-m",
|
||||
"--config",
|
||||
"--steps",
|
||||
"--repo",
|
||||
"--fast",
|
||||
"--flat",
|
||||
"--user",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export const KNOWN_FLAGS = new Set([
|
|||
"-m",
|
||||
"--config",
|
||||
"--steps",
|
||||
"--repo",
|
||||
"--fast",
|
||||
"--flat",
|
||||
"--user",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ function checkUnknownFlags(args: string[]): void {
|
|||
console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`);
|
||||
console.error(` ${pc.cyan("--config <path>")} Load config from JSON file`);
|
||||
console.error(` ${pc.cyan("--steps <list>")} Comma-separated setup steps to enable`);
|
||||
console.error(` ${pc.cyan("--repo <user/repo>")} Clone a template repo and apply spawn.md`);
|
||||
console.error(` ${pc.cyan("--beta tarball")} Use pre-built tarball for agent install (repeatable)`);
|
||||
console.error(` ${pc.cyan("--beta images")} Use pre-built DO marketplace images (faster boot)`);
|
||||
console.error(` ${pc.cyan("--beta parallel")} Parallelize server boot with setup prompts`);
|
||||
|
|
@ -1047,6 +1048,19 @@ async function main(): Promise<void> {
|
|||
process.env.SPAWN_NAME = nameFlag;
|
||||
}
|
||||
|
||||
// Extract --repo <user/repo> flag — clone a template repo and apply spawn.md
|
||||
const [repoFlag, repoFilteredArgs] = extractFlagValue(
|
||||
filteredArgs,
|
||||
[
|
||||
"--repo",
|
||||
],
|
||||
'spawn <agent> <cloud> --repo "user/my-template"',
|
||||
);
|
||||
filteredArgs.splice(0, filteredArgs.length, ...repoFilteredArgs);
|
||||
if (repoFlag) {
|
||||
process.env.SPAWN_REPO = repoFlag;
|
||||
}
|
||||
|
||||
// Extract --zone / --region <value> flag (maps to cloud-specific env vars)
|
||||
const [zoneFlag, zoneFilteredArgs] = extractFlagValue(
|
||||
filteredArgs,
|
||||
|
|
|
|||
|
|
@ -617,6 +617,35 @@ async function postInstall(
|
|||
spawnId: string,
|
||||
_options?: OrchestrationOptions,
|
||||
): Promise<void> {
|
||||
// ── Repo clone + spawn.md (--repo mode) ────────────────────────────────
|
||||
// Built-in steps (github, auto-update, etc.) come from the CLI --steps
|
||||
// flag, not from spawn.md. spawn.md only handles custom setup (OAuth,
|
||||
// MCP servers, setup commands).
|
||||
let spawnMdConfig: import("./spawn-md.js").SpawnMdConfig | null = null;
|
||||
let repoCloned = false;
|
||||
const repoSlug = process.env.SPAWN_REPO;
|
||||
if (repoSlug && cloud.cloudName !== "local") {
|
||||
// Validate slug format (user/repo, no path traversal)
|
||||
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug)) {
|
||||
logWarn(`Invalid repo slug: ${repoSlug} — skipping repo clone`);
|
||||
} else {
|
||||
logStep("Cloning template repository...");
|
||||
const cloneResult = await asyncTryCatch(() =>
|
||||
cloud.runner.runServer(`git clone https://github.com/${repoSlug}.git ~/project`),
|
||||
);
|
||||
if (!cloneResult.ok) {
|
||||
logWarn(`Repo clone failed (${getErrorMessage(cloneResult.error)}) — continuing without template`);
|
||||
} else {
|
||||
repoCloned = true;
|
||||
const { readRemoteSpawnMd } = await import("./spawn-md.js");
|
||||
spawnMdConfig = await readRemoteSpawnMd(cloud.runner);
|
||||
if (spawnMdConfig) {
|
||||
logInfo(`Template loaded: ${spawnMdConfig.name ?? repoSlug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse enabled setup steps
|
||||
let enabledSteps: Set<string> | undefined;
|
||||
const stepsEnv = process.env.SPAWN_ENABLED_STEPS;
|
||||
|
|
@ -759,6 +788,12 @@ async function postInstall(
|
|||
}
|
||||
}
|
||||
|
||||
// Apply spawn.md custom setup (after built-in steps, before pre-launch)
|
||||
if (spawnMdConfig) {
|
||||
const { applySpawnMdSetup } = await import("./spawn-md.js");
|
||||
await applySpawnMdSetup(cloud.runner, spawnMdConfig, agentName);
|
||||
}
|
||||
|
||||
// Pre-launch hooks (retry loop)
|
||||
if (agent.preLaunch) {
|
||||
for (;;) {
|
||||
|
|
@ -872,7 +907,12 @@ async function postInstall(
|
|||
headless: process.env.SPAWN_HEADLESS === "1",
|
||||
});
|
||||
|
||||
const launchCmd = agent.launchCmd();
|
||||
// When --repo cloned successfully, launch the agent inside the cloned
|
||||
// project directory. Gate on the actual clone outcome rather than the flag
|
||||
// so an invalid slug or clone failure doesn't leave the agent trying to cd
|
||||
// into a non-existent dir.
|
||||
const baseLaunchCmd = agent.launchCmd();
|
||||
const launchCmd = repoCloned ? `cd ~/project && ${baseLaunchCmd}` : baseLaunchCmd;
|
||||
saveLaunchCmd(launchCmd, spawnId);
|
||||
|
||||
// In headless mode, provisioning is done — skip the interactive session.
|
||||
|
|
|
|||
|
|
@ -204,7 +204,10 @@ export async function installSkills(
|
|||
}
|
||||
|
||||
/** Merge MCP servers into Claude Code's ~/.claude/settings.json. */
|
||||
async function installClaudeMcpServers(runner: CloudRunner, servers: Record<string, McpServerConfig>): Promise<void> {
|
||||
export async function installClaudeMcpServers(
|
||||
runner: CloudRunner,
|
||||
servers: Record<string, McpServerConfig>,
|
||||
): Promise<void> {
|
||||
const tmpLocal = join(getTmpDir(), `claude_settings_${Date.now()}.json`);
|
||||
const dlResult = await asyncTryCatch(() => runner.downloadFile("$HOME/.claude/settings.json", tmpLocal));
|
||||
|
||||
|
|
@ -226,7 +229,10 @@ async function installClaudeMcpServers(runner: CloudRunner, servers: Record<stri
|
|||
}
|
||||
|
||||
/** Write MCP servers to Cursor's ~/.cursor/mcp.json. */
|
||||
async function installCursorMcpServers(runner: CloudRunner, servers: Record<string, McpServerConfig>): Promise<void> {
|
||||
export async function installCursorMcpServers(
|
||||
runner: CloudRunner,
|
||||
servers: Record<string, McpServerConfig>,
|
||||
): Promise<void> {
|
||||
const tmpLocal = join(getTmpDir(), `cursor_mcp_${Date.now()}.json`);
|
||||
const dlResult = await asyncTryCatch(() => runner.downloadFile("$HOME/.cursor/mcp.json", tmpLocal));
|
||||
|
||||
|
|
@ -248,7 +254,7 @@ async function installCursorMcpServers(runner: CloudRunner, servers: Record<stri
|
|||
}
|
||||
|
||||
/** Generic MCP install — writes a .mcp.json in the agent's config directory. */
|
||||
async function installGenericMcpServers(
|
||||
export async function installGenericMcpServers(
|
||||
runner: CloudRunner,
|
||||
agentName: string,
|
||||
servers: Record<string, McpServerConfig>,
|
||||
|
|
@ -263,6 +269,60 @@ async function installGenericMcpServers(
|
|||
await uploadConfigFile(runner, config, `$HOME/.${agentName}/mcp.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append MCP server entries to Codex's ~/.codex/config.toml under
|
||||
* [mcp_servers.NAME] sections. Existing sections with the same name are
|
||||
* left untouched (we don't try to merge mid-file); new ones are appended.
|
||||
*/
|
||||
export async function installCodexMcpServers(
|
||||
runner: CloudRunner,
|
||||
servers: Record<string, McpServerConfig>,
|
||||
): Promise<void> {
|
||||
const tmpLocal = join(getTmpDir(), `codex_config_${Date.now()}.toml`);
|
||||
const dlResult = await asyncTryCatch(() => runner.downloadFile("$HOME/.codex/config.toml", tmpLocal));
|
||||
|
||||
let existing = "";
|
||||
if (dlResult.ok) {
|
||||
const readResult = tryCatch(() => readFileSync(tmpLocal, "utf-8"));
|
||||
if (readResult.ok) {
|
||||
existing = readResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
const existingNames = new Set<string>();
|
||||
for (const m of existing.matchAll(/^\[mcp_servers\.([^.\]]+)\]/gm)) {
|
||||
existingNames.add(m[1]);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [name, cfg] of Object.entries(servers)) {
|
||||
if (existingNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`[mcp_servers.${name}]`);
|
||||
lines.push(`command = ${tomlString(cfg.command)}`);
|
||||
lines.push(`args = [${cfg.args.map((a) => tomlString(a)).join(", ")}]`);
|
||||
if (cfg.env) {
|
||||
lines.push(`[mcp_servers.${name}.env]`);
|
||||
for (const [k, val] of Object.entries(cfg.env)) {
|
||||
lines.push(`${k} = ${tomlString(val)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = `${existing.replace(/\n+$/, "")}\n${lines.join("\n")}\n`;
|
||||
await uploadConfigFile(runner, merged, "$HOME/.codex/config.toml");
|
||||
}
|
||||
|
||||
function tomlString(s: string): string {
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Inject an instruction skill (SKILL.md) onto the remote VM. */
|
||||
async function injectInstructionSkill(
|
||||
runner: CloudRunner,
|
||||
|
|
|
|||
522
packages/cli/src/shared/spawn-md.ts
Normal file
522
packages/cli/src/shared/spawn-md.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
// shared/spawn-md.ts — Parse and apply spawn.md template files
|
||||
//
|
||||
// spawn.md lives at the root of a user's repo and declares the "recipe" for
|
||||
// setting up an agent: custom auth flows, MCP servers, and setup commands.
|
||||
// It never contains actual secrets — env values are placeholders like
|
||||
// ${MY_TOKEN} and the user fills them in at replay time.
|
||||
|
||||
import type { CloudRunner } from "./agent-setup.js";
|
||||
|
||||
import * as v from "valibot";
|
||||
import { asyncTryCatch, tryCatch } from "./result.js";
|
||||
import { logInfo, logStep, logWarn, openBrowser } from "./ui.js";
|
||||
|
||||
// ── YAML frontmatter parsing ───────────────────────────────────────────────
|
||||
// spawn.md uses a subset of YAML in the frontmatter (between --- delimiters).
|
||||
// We parse it with a minimal hand-rolled parser to avoid adding a YAML dep.
|
||||
|
||||
/** Split spawn.md content into { frontmatter, body } */
|
||||
function splitFrontmatter(content: string): {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
} {
|
||||
const trimmed = content.trimStart();
|
||||
if (!trimmed.startsWith("---")) {
|
||||
return {
|
||||
frontmatter: "",
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
const endIdx = trimmed.indexOf("\n---", 3);
|
||||
if (endIdx === -1) {
|
||||
return {
|
||||
frontmatter: "",
|
||||
body: content,
|
||||
};
|
||||
}
|
||||
const frontmatter = trimmed.slice(3, endIdx).trim();
|
||||
const body = trimmed.slice(endIdx + 4).trim();
|
||||
return {
|
||||
frontmatter,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function parseYamlScalar(s: string): string | number | boolean {
|
||||
if (s === "true") {
|
||||
return true;
|
||||
}
|
||||
if (s === "false") {
|
||||
return false;
|
||||
}
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
const num = Number(s);
|
||||
if (!Number.isNaN(num) && s !== "") {
|
||||
return num;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Helper to treat target as a record and set a key */
|
||||
function setOnRecord(target: Record<string, unknown> | unknown[], key: string, val: unknown): void {
|
||||
if (Array.isArray(target)) {
|
||||
return;
|
||||
}
|
||||
target[key] = val;
|
||||
}
|
||||
|
||||
/** Helper to get from a record by key */
|
||||
function getFromRecord(target: Record<string, unknown> | unknown[], key: string): unknown {
|
||||
if (Array.isArray(target)) {
|
||||
return undefined;
|
||||
}
|
||||
return target[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML-to-JSON parser for spawn.md frontmatter.
|
||||
* Handles: scalars, arrays of scalars, arrays of objects, nested objects.
|
||||
* Does NOT handle: anchors, tags, multi-line strings. Intentionally simple.
|
||||
*/
|
||||
function parseYamlFrontmatter(yaml: string): Record<string, unknown> {
|
||||
const lines = yaml.split("\n");
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
type Frame = {
|
||||
indent: number;
|
||||
target: Record<string, unknown> | unknown[];
|
||||
key?: string;
|
||||
};
|
||||
const stack: Frame[] = [
|
||||
{
|
||||
indent: -1,
|
||||
target: result,
|
||||
},
|
||||
];
|
||||
|
||||
const currentFrame = (): Frame => stack[stack.length - 1];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indent = line.length - trimmed.length;
|
||||
|
||||
// Pop stack to find the right nesting level
|
||||
while (stack.length > 1 && indent <= currentFrame().indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
// Array item: "- value" or "- key: value"
|
||||
if (trimmed.startsWith("- ")) {
|
||||
const itemContent = trimmed.slice(2).trim();
|
||||
const frame = currentFrame();
|
||||
let targetArray: unknown[] | null = null;
|
||||
|
||||
if (Array.isArray(frame.target)) {
|
||||
targetArray = frame.target;
|
||||
} else if (frame.key) {
|
||||
const existing = getFromRecord(frame.target, frame.key);
|
||||
if (Array.isArray(existing)) {
|
||||
targetArray = existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetArray) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if item is a key-value pair (object in array)
|
||||
const colonIdx = itemContent.indexOf(":");
|
||||
if (colonIdx > 0 && !itemContent.startsWith("[") && !itemContent.startsWith('"')) {
|
||||
const key = itemContent.slice(0, colonIdx).trim();
|
||||
const val = itemContent.slice(colonIdx + 1).trim();
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj[key] = parseYamlScalar(val);
|
||||
targetArray.push(obj);
|
||||
stack.push({
|
||||
indent: indent + 1,
|
||||
target: obj,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flow sequence: [a, b, c]
|
||||
if (itemContent.startsWith("[") && itemContent.endsWith("]")) {
|
||||
const inner = itemContent.slice(1, -1);
|
||||
targetArray.push(inner.split(",").map((s) => parseYamlScalar(s.trim())));
|
||||
continue;
|
||||
}
|
||||
|
||||
targetArray.push(parseYamlScalar(itemContent));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair: "key: value"
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
||||
const frame = currentFrame();
|
||||
const target = frame.target;
|
||||
|
||||
if (Array.isArray(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rawVal === "" || rawVal === "|" || rawVal === ">") {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine !== undefined) {
|
||||
const nextTrimmed = nextLine.trimStart();
|
||||
if (nextTrimmed.startsWith("- ")) {
|
||||
const arr: unknown[] = [];
|
||||
setOnRecord(target, key, arr);
|
||||
stack.push({
|
||||
indent,
|
||||
target: arr,
|
||||
key,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const obj: Record<string, unknown> = {};
|
||||
setOnRecord(target, key, obj);
|
||||
stack.push({
|
||||
indent,
|
||||
target: obj,
|
||||
key,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setOnRecord(target, key, "");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flow sequence: key: [a, b, c]
|
||||
if (rawVal.startsWith("[") && rawVal.endsWith("]")) {
|
||||
const inner = rawVal.slice(1, -1);
|
||||
setOnRecord(
|
||||
target,
|
||||
key,
|
||||
inner.split(",").map((s) => parseYamlScalar(s.trim())),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
setOnRecord(target, key, parseYamlScalar(rawVal));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Valibot schemas ────────────────────────────────────────────────────────
|
||||
|
||||
const OAuthSetupSchema = v.object({
|
||||
type: v.literal("oauth"),
|
||||
name: v.string(),
|
||||
url: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const CliAuthSetupSchema = v.object({
|
||||
type: v.literal("cli_auth"),
|
||||
name: v.string(),
|
||||
command: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const ApiKeySetupSchema = v.object({
|
||||
type: v.literal("api_key"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
guide_url: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const CommandSetupSchema = v.object({
|
||||
type: v.literal("command"),
|
||||
name: v.optional(v.string()),
|
||||
command: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const SetupStepSchema = v.union([
|
||||
OAuthSetupSchema,
|
||||
CliAuthSetupSchema,
|
||||
ApiKeySetupSchema,
|
||||
CommandSetupSchema,
|
||||
]);
|
||||
|
||||
const McpServerEntrySchema = v.object({
|
||||
name: v.string(),
|
||||
command: v.string(),
|
||||
args: v.array(v.string()),
|
||||
env: v.optional(v.record(v.string(), v.string())),
|
||||
});
|
||||
|
||||
export const SpawnMdSchema = v.object({
|
||||
name: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
// Built-in steps (github, auto-update, etc.) go in the CLI --steps flag,
|
||||
// not here. spawn.md only handles custom setup that Spawn doesn't know about.
|
||||
setup: v.optional(v.array(SetupStepSchema)),
|
||||
mcp_servers: v.optional(v.array(McpServerEntrySchema)),
|
||||
setup_commands: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
export type SpawnMdConfig = v.InferOutput<typeof SpawnMdSchema>;
|
||||
type SetupStep = v.InferOutput<typeof SetupStepSchema>;
|
||||
type McpServerEntry = v.InferOutput<typeof McpServerEntrySchema>;
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse spawn.md content into a typed config. Returns null on parse failure. */
|
||||
export function parseSpawnMd(content: string): SpawnMdConfig | null {
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
if (!frontmatter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = parseYamlFrontmatter(frontmatter);
|
||||
const result = v.safeParse(SpawnMdSchema, raw);
|
||||
if (!result.success) {
|
||||
logWarn("spawn.md has invalid frontmatter — ignoring");
|
||||
return null;
|
||||
}
|
||||
return result.output;
|
||||
}
|
||||
|
||||
// ── Applying spawn.md on a VM ──────────────────────────────────────────────
|
||||
|
||||
/** Read and parse spawn.md from a remote VM */
|
||||
export async function readRemoteSpawnMd(runner: CloudRunner): Promise<SpawnMdConfig | null> {
|
||||
const catResult = await captureCommand(runner, "cat ~/project/spawn.md 2>/dev/null");
|
||||
if (catResult) {
|
||||
return parseSpawnMd(catResult);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run a command on the remote and capture its stdout */
|
||||
async function captureCommand(runner: CloudRunner, cmd: string): Promise<string | null> {
|
||||
const tmpFile = `/tmp/spawn-capture-${Date.now()}`;
|
||||
const { readFileSync, unlinkSync } = await import("node:fs");
|
||||
const result = await asyncTryCatch(async () => {
|
||||
await runner.runServer(`${cmd} > ${tmpFile} 2>/dev/null; true`);
|
||||
await runner.downloadFile(tmpFile, tmpFile);
|
||||
const content = readFileSync(tmpFile, "utf-8");
|
||||
const cleanupResult = tryCatch(() => unlinkSync(tmpFile));
|
||||
// ignore local cleanup failure
|
||||
void cleanupResult;
|
||||
await asyncTryCatch(() => runner.runServer(`rm -f ${tmpFile}`));
|
||||
return content || null;
|
||||
});
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom setup steps from spawn.md onto a running VM.
|
||||
* Built-in `steps` (github, auto-update, etc.) are handled by the existing
|
||||
* postInstall infrastructure — this function only handles the `setup` array,
|
||||
* `mcp_servers`, and `setup_commands`.
|
||||
*/
|
||||
export async function applySpawnMdSetup(runner: CloudRunner, config: SpawnMdConfig, agentName: string): Promise<void> {
|
||||
if (config.setup && config.setup.length > 0) {
|
||||
logStep("Running template setup steps...");
|
||||
for (const step of config.setup) {
|
||||
await applySetupStep(runner, step);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.mcp_servers && config.mcp_servers.length > 0) {
|
||||
logStep("Installing MCP servers from template...");
|
||||
await installMcpServersFromTemplate(runner, config.mcp_servers, agentName);
|
||||
}
|
||||
|
||||
if (config.setup_commands && config.setup_commands.length > 0) {
|
||||
logStep("Running template setup commands...");
|
||||
for (const cmd of config.setup_commands) {
|
||||
logInfo(` Running: ${cmd}`);
|
||||
const cmdResult = await asyncTryCatch(() => runner.runServer(`cd ~/project 2>/dev/null; ${cmd}`));
|
||||
if (!cmdResult.ok) {
|
||||
logWarn(` Setup command failed: ${cmd}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applySetupStep(runner: CloudRunner, step: SetupStep): Promise<void> {
|
||||
switch (step.type) {
|
||||
case "oauth": {
|
||||
logInfo(` ${step.name}: Opening ${step.url}`);
|
||||
if (step.description) {
|
||||
logInfo(` ${step.description}`);
|
||||
}
|
||||
openBrowser(step.url);
|
||||
logInfo(" Complete the OAuth flow in your browser, then press Enter to continue.");
|
||||
await waitForEnter();
|
||||
break;
|
||||
}
|
||||
case "cli_auth": {
|
||||
logInfo(` ${step.name}: Running ${step.command}`);
|
||||
if (step.description) {
|
||||
logInfo(` ${step.description}`);
|
||||
}
|
||||
const authResult = await asyncTryCatch(() => runner.runServer(step.command));
|
||||
if (authResult.ok) {
|
||||
logInfo(` ${step.name} authenticated`);
|
||||
} else {
|
||||
logWarn(` ${step.name} auth failed — you can run it manually later: ${step.command}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "api_key": {
|
||||
logInfo(` ${step.name}: API key required`);
|
||||
if (step.description) {
|
||||
logInfo(` ${step.description}`);
|
||||
}
|
||||
if (step.guide_url) {
|
||||
logInfo(` Get your key: ${step.guide_url}`);
|
||||
openBrowser(step.guide_url);
|
||||
}
|
||||
const value = await promptSecret(` Enter ${step.name}: `);
|
||||
if (value) {
|
||||
const escapedName = step.name.replace(/[^A-Za-z0-9_]/g, "");
|
||||
const b64Val = Buffer.from(value).toString("base64");
|
||||
await runner.runServer(
|
||||
`mkdir -p /etc/spawn && printf 'export %s="%s"\\n' '${escapedName}' "$(echo '${b64Val}' | base64 -d)" >> /etc/spawn/secrets && chmod 600 /etc/spawn/secrets`,
|
||||
);
|
||||
await runner.runServer(
|
||||
`grep -q '/etc/spawn/secrets' ~/.bashrc 2>/dev/null || echo 'source /etc/spawn/secrets 2>/dev/null' >> ~/.bashrc`,
|
||||
);
|
||||
logInfo(` ${step.name} saved`);
|
||||
} else {
|
||||
logWarn(` No value provided for ${step.name} — set it later in /etc/spawn/secrets`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "command": {
|
||||
const label = step.name ?? step.command;
|
||||
logInfo(` Running: ${label}`);
|
||||
if (step.description) {
|
||||
logInfo(` ${step.description}`);
|
||||
}
|
||||
const runResult = await asyncTryCatch(() => runner.runServer(step.command));
|
||||
if (!runResult.ok) {
|
||||
logWarn(` Command failed: ${step.command}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Install MCP servers from spawn.md template into agent config */
|
||||
async function installMcpServersFromTemplate(
|
||||
runner: CloudRunner,
|
||||
servers: McpServerEntry[],
|
||||
agentName: string,
|
||||
): Promise<void> {
|
||||
const record: Record<
|
||||
string,
|
||||
{
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
> = {};
|
||||
for (const server of servers) {
|
||||
record[server.name] = server.env
|
||||
? {
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
}
|
||||
: {
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
};
|
||||
}
|
||||
|
||||
const { installClaudeMcpServers, installCursorMcpServers, installCodexMcpServers, installGenericMcpServers } =
|
||||
await import("./skills.js");
|
||||
const installResult = await asyncTryCatch(async () => {
|
||||
if (agentName === "claude") {
|
||||
await installClaudeMcpServers(runner, record);
|
||||
} else if (agentName === "cursor") {
|
||||
await installCursorMcpServers(runner, record);
|
||||
} else if (agentName === "codex") {
|
||||
await installCodexMcpServers(runner, record);
|
||||
} else {
|
||||
await installGenericMcpServers(runner, agentName, record);
|
||||
}
|
||||
});
|
||||
if (installResult.ok) {
|
||||
logInfo(` Installed ${servers.length} MCP server${servers.length > 1 ? "s" : ""}`);
|
||||
} else {
|
||||
logWarn(" MCP server installation failed — configure manually");
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for the user to press Enter */
|
||||
async function waitForEnter(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!process.stdin.isTTY) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onData = (): void => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
resolve();
|
||||
};
|
||||
process.stdin.once("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompt for a secret value (no echo) */
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
process.stderr.write(message);
|
||||
return new Promise((resolve) => {
|
||||
if (!process.stdin.isTTY) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
let buf = "";
|
||||
const wasRaw = process.stdin.isRaw ?? false;
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
const onData = (data: Buffer): void => {
|
||||
const ch = data.toString();
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stderr.write("\n");
|
||||
resolve(buf);
|
||||
return;
|
||||
}
|
||||
if (ch === "\x03") {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stderr.write("\n");
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
buf += ch;
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue