feat(cli): --repo flag clones a template repo and applies spawn.md (#3360)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run

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:
Ahmed Abushagur 2026-04-24 23:42:23 -07:00 committed by GitHub
parent f0e93a508d
commit 3e6c8768d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 734 additions and 5 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.21",
"version": "1.0.22",
"type": "module",
"bin": {
"spawn": "cli.js"

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

View file

@ -227,6 +227,7 @@ describe("KNOWN_FLAGS completeness", () => {
"-m",
"--config",
"--steps",
"--repo",
"--fast",
"--flat",
"--user",

View file

@ -35,6 +35,7 @@ export const KNOWN_FLAGS = new Set([
"-m",
"--config",
"--steps",
"--repo",
"--fast",
"--flat",
"--user",

View file

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

View file

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

View file

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

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