diff --git a/README.md b/README.md index 42e8932ba..1ce908d4e 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ spawn claude gcp --beta tarball --beta parallel | `parallel` | Parallelize server boot with setup prompts | | `recursive` | Install spawn CLI on VM so it can spawn child VMs | | `sandbox` | Run local agents in a Docker container (sandboxed) | +| `skills` | Pre-install MCP servers and tools on the VM | `--fast` enables `tarball`, `images`, and `parallel` (not `recursive` or `sandbox`). @@ -209,6 +210,42 @@ spawn --beta sandbox # Interactive picker shows both local options spawn openclaw local --beta sandbox # Direct launch, sandboxed ``` +#### Skills + +Use `--beta skills` to pre-install MCP servers and instruction skills on the remote VM during setup: + +```bash +spawn claude digitalocean --beta skills +``` + +A skills picker appears after setup options, letting you choose what to install: + +**MCP Servers** (Claude Code, Cursor): + +| Skill | Package | Default | +|-------|---------|---------| +| GitHub | `@modelcontextprotocol/server-github` | Yes | +| Playwright | `@anthropic-ai/mcp-server-playwright` | Yes | +| Fetch | `@anthropic-ai/mcp-server-fetch` | No | +| Context7 | `@upstash/context7-mcp` | No | +| PostgreSQL | `@modelcontextprotocol/server-postgres` | No | + +**Instruction Skills** (Claude Code, OpenClaw, Codex): + +| Skill | Description | Default | +|-------|-------------|---------| +| Git Workflow | Branching, conventional commits, PR workflow | Yes | +| Web Search | Search the web, fetch URLs, extract content | Yes | +| Docker | Container management, compose, best practices | No | +| Deploy | Deploy via SSH, Docker, pm2, systemd | No | + +Skills requiring env vars (e.g. `GITHUB_TOKEN`) will prompt during setup. + +For headless use: +```bash +SPAWN_SELECTED_SKILLS=github-mcp,git-workflow,web-search spawn claude hetzner +``` + ### Without the CLI Every combination works as a one-liner — no install required: diff --git a/manifest.json b/manifest.json index afc5d2ac7..47b6d0004 100644 --- a/manifest.json +++ b/manifest.json @@ -492,5 +492,203 @@ "digitalocean/cursor": "implemented", "gcp/cursor": "implemented", "sprite/cursor": "implemented" + }, + "skills": { + "github-mcp": { + "name": "GitHub", + "description": "PRs, issues, repos, search via GitHub API", + "type": "mcp", + "package": "@modelcontextprotocol/server-github", + "env_vars": ["GITHUB_TOKEN"], + "agents": { + "claude": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } + }, + "default": true + }, + "cursor": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } + }, + "default": true + } + } + }, + "playwright-mcp": { + "name": "Playwright", + "description": "Browser automation — navigate, click, screenshot, scrape", + "type": "mcp", + "package": "@anthropic-ai/mcp-server-playwright", + "agents": { + "claude": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + }, + "default": true + }, + "cursor": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-playwright"] + }, + "default": true + } + } + }, + "fetch-mcp": { + "name": "Fetch", + "description": "HTTP requests — fetch web pages, APIs, and files", + "type": "mcp", + "package": "@anthropic-ai/mcp-server-fetch", + "agents": { + "claude": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-fetch"] + }, + "default": false + }, + "cursor": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-fetch"] + }, + "default": false + } + } + }, + "context7": { + "name": "Context7", + "description": "Library docs lookup — up-to-date documentation in context", + "type": "mcp", + "package": "@upstash/context7-mcp", + "agents": { + "claude": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + }, + "default": false + }, + "cursor": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + }, + "default": false + } + } + }, + "postgres-mcp": { + "name": "PostgreSQL", + "description": "Database access — query, inspect schema, manage tables", + "type": "mcp", + "package": "@modelcontextprotocol/server-postgres", + "env_vars": ["DATABASE_URL"], + "agents": { + "claude": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { "DATABASE_URL": "${DATABASE_URL}" } + }, + "default": false + }, + "cursor": { + "mcp_config": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { "DATABASE_URL": "${DATABASE_URL}" } + }, + "default": false + } + } + }, + "git-workflow": { + "name": "Git Workflow", + "description": "Branch management, PR workflow, conventional commits", + "type": "instruction", + "content": "---\nname: git-workflow\ndescription: Git branching strategy, conventional commits, and PR workflow best practices\n---\n\n# Git Workflow\n\nFollow these conventions for all git operations:\n\n## Branching\n- `feat/` for new features\n- `fix/` for bug fixes\n- `chore/` for maintenance\n- Always branch from `main`\n\n## Commits\nUse conventional commits: `type(scope): description`\n- `feat:` new feature\n- `fix:` bug fix\n- `docs:` documentation\n- `refactor:` code restructuring\n- `test:` adding tests\n- `chore:` maintenance\n\n## Pull Requests\n1. Create a feature branch\n2. Make focused, atomic commits\n3. Open a PR with a clear title and description\n4. Request review, address feedback\n5. Squash merge when approved\n", + "agents": { + "claude": { + "instruction_path": "~/.claude/skills/git-workflow/SKILL.md", + "default": true + }, + "openclaw": { + "instruction_path": "~/.openclaw/skills/git-workflow/SKILL.md", + "default": true + }, + "codex": { + "instruction_path": "~/.agents/skills/git-workflow/SKILL.md", + "default": true + } + } + }, + "docker": { + "name": "Docker", + "description": "Container management — build, run, compose, debug", + "type": "instruction", + "content": "---\nname: docker\ndescription: Docker container management, Dockerfile best practices, and docker compose workflows\n---\n\n# Docker\n\nYou can use Docker on this machine for containerized workflows.\n\n## Common Commands\n\n```bash\n# Build an image\ndocker build -t myapp .\n\n# Run a container\ndocker run -d -p 8080:8080 --name myapp myapp\n\n# View running containers\ndocker ps\n\n# View logs\ndocker logs -f myapp\n\n# Execute command in container\ndocker exec -it myapp bash\n\n# Stop and remove\ndocker stop myapp && docker rm myapp\n```\n\n## Docker Compose\n\n```bash\ndocker compose up -d # Start services\ndocker compose down # Stop services\ndocker compose logs -f # Follow logs\ndocker compose ps # List services\n```\n\n## Dockerfile Best Practices\n- Use multi-stage builds to minimize image size\n- Pin base image versions (e.g., `node:22-slim`)\n- Copy `package.json` before source for layer caching\n- Use `.dockerignore` to exclude `node_modules`, `.git`\n- Run as non-root user in production\n", + "agents": { + "claude": { + "instruction_path": "~/.claude/skills/docker/SKILL.md", + "default": false + }, + "openclaw": { + "instruction_path": "~/.openclaw/skills/docker/SKILL.md", + "default": false + }, + "codex": { + "instruction_path": "~/.agents/skills/docker/SKILL.md", + "default": false + } + } + }, + "web-search": { + "name": "Web Search", + "description": "Search the web, fetch URLs, and extract content", + "type": "instruction", + "content": "---\nname: web-search\ndescription: Search the web and fetch URL content using command-line tools\nallowed-tools: Bash\n---\n\n# Web Search & Fetch\n\nYou can search the web and fetch content from URLs.\n\n## Fetch a URL\n\n```bash\ncurl -fsSL \"https://example.com\" | head -200\n```\n\n## Search with DuckDuckGo (no API key needed)\n\n```bash\n# HTML search results\ncurl -fsSL \"https://html.duckduckgo.com/html/?q=your+search+query\" | bun -e \"\nconst html = await Bun.stdin.text();\nconst results = [...html.matchAll(/class=\\\"result__a\\\"[^>]*href=\\\"([^\\\"]+)\\\"[^>]*>([^<]+)/g)];\nfor (const [, url, title] of results.slice(0, 5)) {\n const decoded = decodeURIComponent(url.replace(/.*uddg=/, '').replace(/&.*/, ''));\n console.log(title.trim(), '-', decoded);\n}\"\n```\n\n## Fetch and Extract Text\n\n```bash\n# Fetch URL and strip HTML tags\ncurl -fsSL \"URL\" | bun -e \"\nconst html = await Bun.stdin.text();\nconst text = html.replace(/]*>[\\\\s\\\\S]*?<\\\\/script>/gi, '')\n .replace(/]*>[\\\\s\\\\S]*?<\\\\/style>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\\\s+/g, ' ').trim();\nconsole.log(text.slice(0, 5000));\"\n```\n", + "agents": { + "claude": { + "instruction_path": "~/.claude/skills/web-search/SKILL.md", + "default": true + }, + "openclaw": { + "instruction_path": "~/.openclaw/skills/web-search/SKILL.md", + "default": true + }, + "codex": { + "instruction_path": "~/.agents/skills/web-search/SKILL.md", + "default": true + } + } + }, + "deploy": { + "name": "Deploy", + "description": "Deploy apps via SSH, Docker, or cloud CLI tools", + "type": "instruction", + "content": "---\nname: deploy\ndescription: Deploy applications using common deployment strategies\nallowed-tools: Bash\n---\n\n# Deploy\n\nCommon deployment patterns for this VM.\n\n## SSH Deploy (to another server)\n\n```bash\n# Copy files and restart\nrsync -avz --exclude node_modules --exclude .git ./ user@host:/app/\nssh user@host 'cd /app && npm install --production && pm2 restart all'\n```\n\n## Docker Deploy\n\n```bash\n# Build, tag, push, deploy\ndocker build -t registry.example.com/app:latest .\ndocker push registry.example.com/app:latest\nssh user@host 'docker pull registry.example.com/app:latest && docker compose up -d'\n```\n\n## Process Management (pm2)\n\n```bash\nnpm install -g pm2\npm2 start app.js --name myapp\npm2 save\npm2 startup # Auto-start on reboot\npm2 logs myapp # View logs\npm2 restart myapp # Restart\n```\n\n## Systemd Service\n\n```bash\n# Create /etc/systemd/system/myapp.service\nsudo systemctl daemon-reload\nsudo systemctl enable myapp\nsudo systemctl start myapp\nsudo journalctl -u myapp -f\n```\n", + "agents": { + "claude": { + "instruction_path": "~/.claude/skills/deploy/SKILL.md", + "default": false + }, + "openclaw": { + "instruction_path": "~/.openclaw/skills/deploy/SKILL.md", + "default": false + }, + "codex": { + "instruction_path": "~/.agents/skills/deploy/SKILL.md", + "default": false + } + } + } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 7bdf66e50..bbcb1c586 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.30.10", + "version": "0.31.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index c7e0826a3..fcd3608f5 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -244,6 +244,28 @@ async function promptSetupOptions(agentName: string): Promise | unde return stepSet; } +/** Show the skills picker if --beta skills is active and the agent has skills available. */ +async function maybePromptSkills(manifest: Manifest, agentName: string): Promise { + if (process.env.SPAWN_SELECTED_SKILLS) { + return; + } + const betaFeatures = (process.env.SPAWN_BETA ?? "").split(",").filter(Boolean); + if (!betaFeatures.includes("skills")) { + return; + } + const { promptSkillSelection, collectSkillEnvVars } = await import("../shared/skills.js"); + const selectedSkills = await promptSkillSelection(manifest, agentName); + if (selectedSkills && selectedSkills.length > 0) { + process.env.SPAWN_SELECTED_SKILLS = selectedSkills.join(","); + // Prompt for any missing env vars required by selected skills + const envPairs = await collectSkillEnvVars(manifest, selectedSkills); + if (envPairs.length > 0) { + const existing = process.env.SPAWN_SKILL_ENV_PAIRS ?? ""; + process.env.SPAWN_SKILL_ENV_PAIRS = existing ? `${existing},${envPairs.join(",")}` : envPairs.join(","); + } + } +} + export { getAndValidateCloudChoices, promptSetupOptions, promptSpawnName, selectCloud }; export async function cmdInteractive(): Promise { @@ -295,6 +317,9 @@ export async function cmdInteractive(): Promise { } } + // Skills picker (--beta skills) + await maybePromptSkills(manifest, agentChoice); + const spawnName = await promptSpawnName(); const agentName = manifest.agents[agentChoice].name; @@ -354,6 +379,9 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun } } + // Skills picker (--beta skills) + await maybePromptSkills(manifest, resolvedAgent); + const spawnName = await promptSpawnName(); const agentName = manifest.agents[resolvedAgent].name; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7e1a49a2c..968d09c4d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -900,6 +900,7 @@ async function main(): Promise { "docker", "recursive", "sandbox", + "skills", ]); const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn --beta parallel"); for (const flag of betaFeatures) { @@ -911,6 +912,7 @@ async function main(): Promise { console.error(` ${pc.cyan("parallel")} Parallelize server boot with setup prompts`); console.error(` ${pc.cyan("docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`); console.error(` ${pc.cyan("sandbox")} Run local agents in a Docker container (sandboxed)`); + console.error(` ${pc.cyan("skills")} Pre-install MCP servers and tools on the VM`); console.error(` ${pc.cyan("recursive")} Install spawn CLI on VM for recursive spawning`); process.exit(1); } diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index bda9e6ff6..6a082813f 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -62,10 +62,43 @@ export interface CloudDef { icon?: string; } +/** MCP server configuration (matches Claude Code settings.json mcpServers format). */ +export interface McpServerConfig { + command: string; + args: string[]; + env?: Record; +} + +/** Per-agent skill configuration. */ +export interface SkillAgentConfig { + mcp_config?: McpServerConfig; + /** Remote path for instruction-type skills (e.g. ~/.openclaw/skills/git-workflow/SKILL.md). */ + instruction_path?: string; + /** Whether this skill is pre-selected in the picker for this agent. */ + default: boolean; +} + +/** A skill that can be pre-installed on a remote VM. */ +export interface SkillDef { + name: string; + description: string; + type: "mcp" | "instruction"; + /** npm package name (for MCP-type skills). */ + package?: string; + /** YAML frontmatter + markdown content (for instruction-type skills). */ + content?: string; + /** Env vars required by this skill (shown as hints in picker). */ + env_vars?: string[]; + /** Per-agent installation config. Only agents listed here support this skill. */ + agents: Record; +} + export interface Manifest { agents: Record; clouds: Record; matrix: Record; + /** Skill catalog — MCP servers and tools that can be pre-installed on VMs. */ + skills?: Record; } // ── Constants ────────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index ff13d3c82..fe368d237 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -85,7 +85,7 @@ async function installAgent( /** * Upload a config file to the remote machine via a temp file and mv. */ -async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise { +export async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise { const safePath = validateRemotePath(remotePath); const tmpFile = join(getTmpDir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 72faaccb5..b3d327dbc 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -611,6 +611,40 @@ async function postInstall( await injectSpawnSkill(cloud.runner, agentName); } + // Skill installation (--beta skills) + const selectedSkillsEnv = process.env.SPAWN_SELECTED_SKILLS; + if (selectedSkillsEnv && cloud.cloudName !== "local") { + const skillIds = selectedSkillsEnv.split(",").filter(Boolean); + if (skillIds.length > 0) { + const { loadManifest } = await import("../manifest.js"); + const manifest = await loadManifest(); + if (manifest.skills) { + const { installSkills } = await import("./skills.js"); + await installSkills(cloud.runner, manifest, agentName, skillIds); + + // Append skill env vars (e.g. GITHUB_TOKEN, DATABASE_URL) to .spawnrc + // so MCP servers can resolve ${VAR} references at runtime. + const skillEnvPairs = (process.env.SPAWN_SKILL_ENV_PAIRS ?? "").split(",").filter(Boolean); + if (skillEnvPairs.length > 0) { + const envLines = skillEnvPairs + .map((pair) => { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) { + return ""; + } + const key = pair.slice(0, eqIdx); + const val = pair.slice(eqIdx + 1); + return `export ${key}=${shellQuote(val)}`; + }) + .filter(Boolean); + if (envLines.length > 0) { + await cloud.runner.runServer(`printf '\\n# [spawn:skills-env]\\n${envLines.join("\\n")}\\n' >> ~/.spawnrc`); + } + } + } + } + } + // Pre-launch hooks (retry loop) if (agent.preLaunch) { for (;;) { diff --git a/packages/cli/src/shared/skills.ts b/packages/cli/src/shared/skills.ts new file mode 100644 index 000000000..97b3e2141 --- /dev/null +++ b/packages/cli/src/shared/skills.ts @@ -0,0 +1,269 @@ +// shared/skills.ts — Skill installation for --beta skills +// Pre-installs MCP servers and tools on remote VMs during agent setup. + +import type { Manifest, McpServerConfig } from "../manifest.js"; +import type { CloudRunner } from "./agent-setup.js"; + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import { toRecord } from "@openrouter/spawn-shared"; +import { uploadConfigFile } from "./agent-setup.js"; +import { parseJsonObj } from "./parse.js"; +import { getTmpDir } from "./paths.js"; +import { asyncTryCatch } from "./result.js"; +import { logInfo, logStep, logWarn } from "./ui.js"; + +// ─── Skill Filtering ─────────────────────────────────────────────────────────── + +interface AvailableSkill { + id: string; + name: string; + description: string; + isDefault: boolean; + envVars: string[]; +} + +/** Get skills available for a given agent from the manifest. */ +export function getAvailableSkills(manifest: Manifest, agentName: string): AvailableSkill[] { + if (!manifest.skills) { + return []; + } + + const skills: AvailableSkill[] = []; + for (const [id, def] of Object.entries(manifest.skills)) { + const agentConfig = def.agents[agentName]; + if (!agentConfig) { + continue; + } + skills.push({ + id, + name: def.name, + description: def.description, + isDefault: agentConfig.default, + envVars: def.env_vars ?? [], + }); + } + return skills; +} + +// ─── Skill Picker ─────────────────────────────────────────────────────────────── + +/** Show a multiselect prompt for skills. Returns skill IDs or undefined if none available. */ +export async function promptSkillSelection(manifest: Manifest, agentName: string): Promise { + const skills = getAvailableSkills(manifest, agentName); + if (skills.length === 0) { + return undefined; + } + + const defaultIds = skills.filter((s) => s.isDefault).map((s) => s.id); + + const selected = await p.multiselect({ + message: "Skills (↑/↓ navigate, space=toggle, enter=confirm)", + options: skills.map((s) => { + const envHint = s.envVars.length > 0 ? ` (needs ${s.envVars.join(", ")})` : ""; + return { + value: s.id, + label: s.name, + hint: s.description + envHint, + }; + }), + initialValues: defaultIds.length > 0 ? defaultIds : undefined, + required: false, + }); + + if (p.isCancel(selected)) { + return []; + } + + return selected; +} + +// ─── Env Var Collection ───────────────────────────────────────────────────────── + +/** Prompt for missing env vars required by selected skills. Returns env pairs for .spawnrc. */ +export async function collectSkillEnvVars(manifest: Manifest, selectedSkills: string[]): Promise { + if (!manifest.skills) { + return []; + } + + // Collect all required env vars across selected skills + const neededVars = new Set(); + for (const skillId of selectedSkills) { + const def = manifest.skills[skillId]; + if (def?.env_vars) { + for (const v of def.env_vars) { + neededVars.add(v); + } + } + } + + const envPairs: string[] = []; + for (const varName of neededVars) { + // Skip if already set in environment + if (process.env[varName]) { + envPairs.push(`${varName}=${process.env[varName]}`); + continue; + } + + const value = await p.text({ + message: `${varName} (required by selected skills)`, + placeholder: `Enter ${varName}`, + validate: (val) => { + if (!val?.trim()) { + return `${varName} is required`; + } + return undefined; + }, + }); + + if (p.isCancel(value) || !value?.trim()) { + continue; + } + + process.env[varName] = value.trim(); + envPairs.push(`${varName}=${value.trim()}`); + } + + return envPairs; +} + +// ─── Skill Installation ───────────────────────────────────────────────────────── + +/** Install selected skills on the remote VM. */ +export async function installSkills( + runner: CloudRunner, + manifest: Manifest, + agentName: string, + skillIds: string[], +): Promise { + if (!manifest.skills || skillIds.length === 0) { + return; + } + + // Separate MCP and instruction skills + const mcpServers: Record = {}; + const instructionSkills: Array<{ + id: string; + path: string; + content: string; + }> = []; + + for (const skillId of skillIds) { + const def = manifest.skills[skillId]; + if (!def) { + continue; + } + const agentConfig = def.agents[agentName]; + if (!agentConfig) { + continue; + } + + if (def.type === "mcp" && agentConfig.mcp_config) { + mcpServers[skillId] = agentConfig.mcp_config; + } else if (def.type === "instruction" && agentConfig.instruction_path && def.content) { + instructionSkills.push({ + id: skillId, + path: agentConfig.instruction_path, + content: def.content, + }); + } + } + + const totalCount = Object.keys(mcpServers).length + instructionSkills.length; + if (totalCount === 0) { + return; + } + + logStep(`Installing ${totalCount} skill(s)...`); + + // Install MCP skills + if (Object.keys(mcpServers).length > 0) { + if (agentName === "claude") { + await installClaudeMcpServers(runner, mcpServers); + } else if (agentName === "cursor") { + await installCursorMcpServers(runner, mcpServers); + } else { + logWarn(`MCP skills not supported for agent: ${agentName}`); + } + } + + // Install instruction skills (SKILL.md files) + for (const skill of instructionSkills) { + await injectInstructionSkill(runner, skill.id, skill.path, skill.content); + } + + logInfo(`Skills installed: ${skillIds.join(", ")}`); +} + +/** Merge MCP servers into Claude Code's ~/.claude/settings.json. */ +async function installClaudeMcpServers(runner: CloudRunner, servers: Record): Promise { + // Download existing settings.json from remote + const tmpLocal = join(getTmpDir(), `claude_settings_${Date.now()}.json`); + const dlResult = await asyncTryCatch(() => runner.downloadFile("$HOME/.claude/settings.json", tmpLocal)); + + let settings: Record = {}; + if (dlResult.ok) { + const parsed = parseJsonObj(readFileSync(tmpLocal, "utf-8")); + if (parsed) { + settings = parsed; + } + } + + // Merge mcpServers into existing settings + const existingMcp = toRecord(settings.mcpServers) ?? {}; + settings.mcpServers = { + ...existingMcp, + ...servers, + }; + + // Re-upload merged settings + await uploadConfigFile(runner, JSON.stringify(settings, null, 2), "$HOME/.claude/settings.json"); +} + +/** Write MCP servers to Cursor's ~/.cursor/mcp.json. */ +async function installCursorMcpServers(runner: CloudRunner, servers: Record): Promise { + // Download existing mcp.json if it exists + const tmpLocal = join(getTmpDir(), `cursor_mcp_${Date.now()}.json`); + const dlResult = await asyncTryCatch(() => runner.downloadFile("$HOME/.cursor/mcp.json", tmpLocal)); + + let config: Record = {}; + if (dlResult.ok) { + const parsed = parseJsonObj(readFileSync(tmpLocal, "utf-8")); + if (parsed) { + config = parsed; + } + } + + const existingMcp = toRecord(config.mcpServers) ?? {}; + config.mcpServers = { + ...existingMcp, + ...servers, + }; + + await uploadConfigFile(runner, JSON.stringify(config, null, 2), "$HOME/.cursor/mcp.json"); +} + +/** Inject an instruction skill (SKILL.md) onto the remote VM via base64 encoding. */ +async function injectInstructionSkill( + runner: CloudRunner, + skillId: string, + remotePath: string, + content: string, +): Promise { + const b64 = Buffer.from(content).toString("base64"); + if (!/^[A-Za-z0-9+/=]+$/.test(b64)) { + logWarn(`Skill ${skillId}: unexpected characters in base64 output, skipping`); + return; + } + + const remoteDir = remotePath.slice(0, remotePath.lastIndexOf("/")); + const cmd = `mkdir -p ${remoteDir} && printf '%s' '${b64}' | base64 -d > ${remotePath} && chmod 644 ${remotePath}`; + + const result = await asyncTryCatch(() => runner.runServer(cmd)); + if (result.ok) { + logInfo(`Skill injected: ${remotePath}`); + } else { + logWarn(`Skill ${skillId} injection failed — agent will work without it`); + } +}