diff --git a/packages/cli/package.json b/packages/cli/package.json index cde6a190..ede748d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.21", + "version": "1.0.22", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/spawn-md.test.ts b/packages/cli/src/__tests__/spawn-md.test.ts new file mode 100644 index 00000000..46438d4a --- /dev/null +++ b/packages/cli/src/__tests__/spawn-md.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/__tests__/unknown-flags.test.ts b/packages/cli/src/__tests__/unknown-flags.test.ts index 169180b2..4f1a7463 100644 --- a/packages/cli/src/__tests__/unknown-flags.test.ts +++ b/packages/cli/src/__tests__/unknown-flags.test.ts @@ -227,6 +227,7 @@ describe("KNOWN_FLAGS completeness", () => { "-m", "--config", "--steps", + "--repo", "--fast", "--flat", "--user", diff --git a/packages/cli/src/flags.ts b/packages/cli/src/flags.ts index 71b46181..e2be3de2 100644 --- a/packages/cli/src/flags.ts +++ b/packages/cli/src/flags.ts @@ -35,6 +35,7 @@ export const KNOWN_FLAGS = new Set([ "-m", "--config", "--steps", + "--repo", "--fast", "--flat", "--user", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7234e516..a5e2a443 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ")} Load config from JSON file`); console.error(` ${pc.cyan("--steps ")} Comma-separated setup steps to enable`); + console.error(` ${pc.cyan("--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 { process.env.SPAWN_NAME = nameFlag; } + // Extract --repo flag — clone a template repo and apply spawn.md + const [repoFlag, repoFilteredArgs] = extractFlagValue( + filteredArgs, + [ + "--repo", + ], + 'spawn --repo "user/my-template"', + ); + filteredArgs.splice(0, filteredArgs.length, ...repoFilteredArgs); + if (repoFlag) { + process.env.SPAWN_REPO = repoFlag; + } + // Extract --zone / --region flag (maps to cloud-specific env vars) const [zoneFlag, zoneFilteredArgs] = extractFlagValue( filteredArgs, diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index a10f69f0..b1857eb5 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -617,6 +617,35 @@ async function postInstall( spawnId: string, _options?: OrchestrationOptions, ): Promise { + // ── 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 | 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. diff --git a/packages/cli/src/shared/skills.ts b/packages/cli/src/shared/skills.ts index 2884348a..5c210f2e 100644 --- a/packages/cli/src/shared/skills.ts +++ b/packages/cli/src/shared/skills.ts @@ -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): Promise { +export async function installClaudeMcpServers( + runner: CloudRunner, + servers: Record, +): Promise { 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): Promise { +export async function installCursorMcpServers( + runner: CloudRunner, + servers: Record, +): Promise { 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, @@ -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, +): Promise { + 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(); + 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, diff --git a/packages/cli/src/shared/spawn-md.ts b/packages/cli/src/shared/spawn-md.ts new file mode 100644 index 00000000..6dfac623 --- /dev/null +++ b/packages/cli/src/shared/spawn-md.ts @@ -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 | 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 | 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 { + const lines = yaml.split("\n"); + const result: Record = {}; + + type Frame = { + indent: number; + target: Record | 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 = {}; + 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 = {}; + 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; +type SetupStep = v.InferOutput; +type McpServerEntry = v.InferOutput; + +// ── 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 { + 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 { + 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 { + 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 { + 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 { + const record: Record< + string, + { + command: string; + args: string[]; + env?: Record; + } + > = {}; + 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 { + 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 { + 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); + }); +}