diff --git a/.claude/rules/agent-default-models.md b/.claude/rules/agent-default-models.md index c299689d..8a0d7f70 100644 --- a/.claude/rules/agent-default-models.md +++ b/.claude/rules/agent-default-models.md @@ -15,6 +15,7 @@ Last verified: 2026-03-13 | Kilo Code | _(provider default)_ | `KILO_PROVIDER_TYPE=openrouter` — model selection handled by Kilo Code natively | | Hermes | _(provider default)_ | `OPENAI_BASE_URL=https://openrouter.ai/api/v1` + `OPENAI_API_KEY` — model selection handled by Hermes | | Junie | _(provider default)_ | `JUNIE_OPENROUTER_API_KEY` — model selection handled by Junie natively | +| Cursor CLI | _(provider default)_ | `--endpoint https://openrouter.ai/api/v1` + `CURSOR_API_KEY` — model selection via `--model` flag or `/model` in-session | ## When to update diff --git a/assets/agents/.sources.json b/assets/agents/.sources.json index d3752465..bc49a705 100644 --- a/assets/agents/.sources.json +++ b/assets/agents/.sources.json @@ -30,5 +30,9 @@ "junie": { "url": "custom:Junie_Icon.svg (official JetBrains Junie icon, converted to PNG)", "ext": "png" + }, + "cursor": { + "url": "https://cursor.com/apple-touch-icon.png", + "ext": "png" } } diff --git a/assets/agents/cursor.png b/assets/agents/cursor.png new file mode 100644 index 00000000..86fe0427 Binary files /dev/null and b/assets/agents/cursor.png differ diff --git a/manifest.json b/manifest.json index c46fd9da..a1418d21 100644 --- a/manifest.json +++ b/manifest.json @@ -304,6 +304,55 @@ "jetbrains", "byok" ] + }, + "cursor": { + "name": "Cursor CLI", + "description": "Cursor's terminal-based AI coding agent — autonomous coding with plan, agent, and ask modes", + "url": "https://cursor.com/cli", + "install": "curl https://cursor.com/install -fsS | bash", + "launch": "agent", + "env": { + "OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}", + "CURSOR_API_KEY": "${OPENROUTER_API_KEY}" + }, + "config_files": { + "~/.cursor/cli-config.json": { + "version": 1, + "permissions": { + "allow": [ + "Shell(*)", + "Read(*)", + "Write(*)", + "WebFetch(*)", + "Mcp(*)" + ], + "deny": [] + } + } + }, + "notes": "Works with OpenRouter via --endpoint flag pointing to openrouter.ai/api/v1 and CURSOR_API_KEY set to OpenRouter key. Binary installs to ~/.cursor/bin/agent.", + "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/cursor.png", + "featured_cloud": [ + "digitalocean", + "sprite" + ], + "creator": "Anysphere", + "repo": "anysphere/cursor", + "license": "Proprietary", + "created": "2025-01", + "added": "2026-03", + "github_stars": 10000, + "stars_updated": "2026-03-26", + "language": "TypeScript", + "runtime": "binary", + "category": "cli", + "tagline": "Cursor's AI coding agent — plan, build, and ship from the terminal", + "tags": [ + "coding", + "terminal", + "agentic", + "cursor" + ] } }, "clouds": { @@ -450,6 +499,12 @@ "aws/junie": "implemented", "digitalocean/junie": "implemented", "gcp/junie": "implemented", - "sprite/junie": "implemented" + "sprite/junie": "implemented", + "local/cursor": "implemented", + "hetzner/cursor": "implemented", + "aws/cursor": "implemented", + "digitalocean/cursor": "implemented", + "gcp/cursor": "implemented", + "sprite/cursor": "implemented" } } diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index f1793900..12a31c92 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -169,6 +169,71 @@ async function setupClaudeCodeConfig(runner: CloudRunner, apiKey: string): Promi logInfo("Claude Code configured"); } +// ─── Cursor CLI Config ──────────────────────────────────────────────────────── + +async function setupCursorConfig(runner: CloudRunner, _apiKey: string): Promise { + logStep("Configuring Cursor CLI for OpenRouter..."); + + const configJson = `{ + "version": 1, + "permissions": { + "allow": ["Shell(*)", "Read(*)", "Write(*)", "WebFetch(*)", "Mcp(*)"], + "deny": [] + } +}`; + + // Spawn skill rule — injected into .cursor/rules/ so the agent knows + // it can provision child VMs via the spawn CLI. + const spawnRule = [ + "---", + "description: Spawn CLI — provision child cloud VMs with AI coding agents", + "alwaysApply: true", + "---", + "", + "# Spawn — Create Child VMs", + "", + "You have the `spawn` CLI installed. Use it to provision cloud VMs with AI agents.", + "", + "## Usage", + "", + "```bash", + 'spawn --headless --output json --prompt "task description"', + "```", + "", + "## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie, cursor", + "## Clouds: hetzner, digitalocean, aws, gcp, sprite", + "", + "The command returns JSON with connection details. Use this to delegate subtasks", + "to specialized agents running on separate cloud VMs.", + "", + ].join("\n"); + + const configB64 = Buffer.from(configJson).toString("base64"); + if (!/^[A-Za-z0-9+/=]+$/.test(configB64)) { + throw new Error("Unexpected characters in base64 output"); + } + + const ruleB64 = Buffer.from(spawnRule).toString("base64"); + if (!/^[A-Za-z0-9+/=]+$/.test(ruleB64)) { + throw new Error("Unexpected characters in base64 output"); + } + + const script = [ + "mkdir -p ~/.cursor ~/.cursor/rules", + `printf '%s' '${configB64}' | base64 -d > ~/.cursor/cli-config.json`, + "chmod 600 ~/.cursor/cli-config.json", + // Inject spawn skill as a Cursor rule + `printf '%s' '${ruleB64}' | base64 -d > ~/.cursor/rules/spawn.mdc`, + "chmod 644 ~/.cursor/rules/spawn.mdc", + // Persist PATH so agent binary is available + 'grep -q ".cursor/bin" ~/.bashrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.cursor/bin:$PATH"\\n\' >> ~/.bashrc', + 'grep -q ".cursor/bin" ~/.zshrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.cursor/bin:$PATH"\\n\' >> ~/.zshrc', + ].join(" && "); + + await runner.runServer(script); + logInfo("Cursor CLI configured"); +} + // ─── GitHub Auth ───────────────────────────────────────────────────────────── let githubAuthRequested = false; @@ -1115,6 +1180,28 @@ function createAgents(runner: CloudRunner): Record { 'export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$PATH"; ' + "npm install -g ${_NPM_G_FLAGS:-} @jetbrains/junie-cli@latest", }, + + cursor: { + name: "Cursor CLI", + cloudInitTier: "minimal", + preProvision: detectGithubAuth, + install: () => + installAgent( + runner, + "Cursor CLI", + "curl https://cursor.com/install -fsS | bash && " + + 'export PATH="$HOME/.cursor/bin:$PATH" && ' + + "agent --version", + ), + envVars: (apiKey) => [ + `OPENROUTER_API_KEY=${apiKey}`, + `CURSOR_API_KEY=${apiKey}`, + ], + configure: (apiKey) => setupCursorConfig(runner, apiKey), + launchCmd: () => + 'source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.cursor/bin:$PATH"; agent --endpoint https://openrouter.ai/api/v1', + updateCmd: 'export PATH="$HOME/.cursor/bin:$PATH"; agent update', + }, }; } diff --git a/sh/aws/README.md b/sh/aws/README.md index 592f7415..962c9258 100644 --- a/sh/aws/README.md +++ b/sh/aws/README.md @@ -60,6 +60,12 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/junie.sh) ``` +#### Cursor CLI + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/cursor.sh) +``` + ## Non-Interactive Mode ```bash diff --git a/sh/aws/cursor.sh b/sh/aws/cursor.sh new file mode 100644 index 00000000..f0bdbc0e --- /dev/null +++ b/sh/aws/cursor.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled aws.js (local or from GitHub release) + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/aws/main.ts" ]]; then + exec bun run "$SPAWN_CLI_DIR/packages/cli/src/aws/main.ts" cursor "$@" +fi + +# Remote — download and run compiled TypeScript bundle +AWS_JS=$(mktemp) +trap 'rm -f "$AWS_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/aws-latest/aws.js" -o "$AWS_JS" \ + || { printf '\033[0;31mFailed to download aws.js\033[0m\n' >&2; exit 1; } +exec bun run "$AWS_JS" cursor "$@" diff --git a/sh/digitalocean/README.md b/sh/digitalocean/README.md index 8ef181d9..d8672dc0 100644 --- a/sh/digitalocean/README.md +++ b/sh/digitalocean/README.md @@ -52,6 +52,12 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/junie.sh) ``` +#### Cursor CLI + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/digitalocean/cursor.sh) +``` + ## Environment Variables | Variable | Description | Default | diff --git a/sh/digitalocean/cursor.sh b/sh/digitalocean/cursor.sh new file mode 100644 index 00000000..d17594be --- /dev/null +++ b/sh/digitalocean/cursor.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled digitalocean.js (local or from GitHub release) +# Includes restart loop for SIGTERM recovery on DigitalOcean + +_AGENT_NAME="cursor" +_MAX_RETRIES=3 + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +# Run command in the foreground so bun gets full terminal access (raw mode, +# arrow keys for interactive prompts). The old pattern backgrounded the child +# with & + wait so a SIGTERM trap could forward the signal, but that removed +# bun from the foreground process group and broke @clack/prompts multiselect. +# Now SIGTERM is detected from exit code 143 (128 + 15) after the child exits. +_run_with_restart() { + # In headless mode (E2E / --headless), skip the restart loop entirely. + # Restarting in headless mode creates duplicate droplets, exhausting the + # account's droplet quota and causing all subsequent agents to fail. + if [ "${SPAWN_HEADLESS:-}" = "1" ]; then + "$@" + return $? + fi + + local attempt=0 + local backoff=2 + while [ "$attempt" -lt "$_MAX_RETRIES" ]; do + attempt=$((attempt + 1)) + + "$@" + local exit_code=$? + + # Normal exit + if [ "$exit_code" -eq 0 ]; then + return 0 + fi + + # SIGTERM (143) or SIGKILL (137) — attempt restart + if [ "$exit_code" -eq 143 ] || [ "$exit_code" -eq 137 ]; then + printf '\033[0;33m[spawn/%s] Agent process terminated (exit %s). The droplet is likely still running.\033[0m\n' \ + "$_AGENT_NAME" "$exit_code" >&2 + printf '\033[0;33m[spawn/%s] Check your DigitalOcean dashboard: https://cloud.digitalocean.com/droplets\033[0m\n' \ + "$_AGENT_NAME" >&2 + if [ "$attempt" -lt "$_MAX_RETRIES" ]; then + printf '\033[0;33m[spawn/%s] Restarting (attempt %s/%s, backoff %ss)...\033[0m\n' \ + "$_AGENT_NAME" "$((attempt + 1))" "$_MAX_RETRIES" "$backoff" >&2 + sleep "$backoff" + backoff=$((backoff * 2)) + continue + else + printf '\033[0;31m[spawn/%s] Max restart attempts reached (%s). Giving up.\033[0m\n' \ + "$_AGENT_NAME" "$_MAX_RETRIES" >&2 + return "$exit_code" + fi + fi + + # Other failure — exit with the original code + return "$exit_code" + done +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/digitalocean/main.ts" ]]; then + _run_with_restart bun run "$SPAWN_CLI_DIR/packages/cli/src/digitalocean/main.ts" "$_AGENT_NAME" "$@" + exit $? +fi + +# Remote — download bundled digitalocean.js from GitHub release +DO_JS=$(mktemp) +trap 'rm -f "$DO_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/digitalocean-latest/digitalocean.js" -o "$DO_JS" \ + || { printf '\033[0;31mFailed to download digitalocean.js\033[0m\n' >&2; exit 1; } + +_run_with_restart bun run "$DO_JS" "$_AGENT_NAME" "$@" +exit $? diff --git a/sh/gcp/README.md b/sh/gcp/README.md index 38efeef2..3e94a857 100644 --- a/sh/gcp/README.md +++ b/sh/gcp/README.md @@ -54,6 +54,12 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcp/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcp/junie.sh) ``` +#### Cursor CLI + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcp/cursor.sh) +``` + ## Non-Interactive Mode ```bash diff --git a/sh/gcp/cursor.sh b/sh/gcp/cursor.sh new file mode 100644 index 00000000..f1cd13f9 --- /dev/null +++ b/sh/gcp/cursor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled gcp.js (local or from GitHub release) + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/gcp/main.ts" ]]; then + exec bun run "$SPAWN_CLI_DIR/packages/cli/src/gcp/main.ts" cursor "$@" +fi + +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } + +exec bun run "$GCP_JS" cursor "$@" diff --git a/sh/hetzner/README.md b/sh/hetzner/README.md index 11dcacbe..919be65a 100644 --- a/sh/hetzner/README.md +++ b/sh/hetzner/README.md @@ -52,6 +52,12 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/hetzner/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/hetzner/junie.sh) ``` +#### Cursor CLI + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/hetzner/cursor.sh) +``` + ## Non-Interactive Mode ```bash diff --git a/sh/hetzner/cursor.sh b/sh/hetzner/cursor.sh new file mode 100644 index 00000000..47474454 --- /dev/null +++ b/sh/hetzner/cursor.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled hetzner.js (local or from GitHub release) + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/hetzner/main.ts" ]]; then + exec bun run "$SPAWN_CLI_DIR/packages/cli/src/hetzner/main.ts" cursor "$@" +fi + +# Remote — download and run compiled TypeScript bundle +HETZNER_JS=$(mktemp) +trap 'rm -f "$HETZNER_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/hetzner-latest/hetzner.js" -o "$HETZNER_JS" \ + || { printf '\033[0;31mFailed to download hetzner.js\033[0m\n' >&2; exit 1; } +exec bun run "$HETZNER_JS" cursor "$@" diff --git a/sh/local/README.md b/sh/local/README.md index c1b2f203..082db3cf 100644 --- a/sh/local/README.md +++ b/sh/local/README.md @@ -17,6 +17,7 @@ spawn opencode local spawn kilocode local spawn hermes local spawn junie local +spawn cursor local ``` Or run directly without the CLI: @@ -30,6 +31,7 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/local/opencode.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/local/kilocode.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/local/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/local/junie.sh) +bash <(curl -fsSL https://openrouter.ai/labs/spawn/local/cursor.sh) ``` ## Non-Interactive Mode diff --git a/sh/local/cursor.sh b/sh/local/cursor.sh new file mode 100644 index 00000000..18be4ea8 --- /dev/null +++ b/sh/local/cursor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled local.js (local or from GitHub release) + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/local/main.ts" ]]; then + exec bun run "$SPAWN_CLI_DIR/packages/cli/src/local/main.ts" cursor "$@" +fi + +# Remote — download bundled local.js from GitHub release +LOCAL_JS=$(mktemp) +trap 'rm -f "$LOCAL_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/local-latest/local.js" -o "$LOCAL_JS" \ + || { printf '\033[0;31mFailed to download local.js\033[0m\n' >&2; exit 1; } + +exec bun run "$LOCAL_JS" cursor "$@" diff --git a/sh/sprite/README.md b/sh/sprite/README.md index 772cd3fc..23fb0b11 100644 --- a/sh/sprite/README.md +++ b/sh/sprite/README.md @@ -52,6 +52,12 @@ bash <(curl -fsSL https://openrouter.ai/labs/spawn/sprite/hermes.sh) bash <(curl -fsSL https://openrouter.ai/labs/spawn/sprite/junie.sh) ``` +#### Cursor CLI + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/sprite/cursor.sh) +``` + ## Non-Interactive Mode ```bash diff --git a/sh/sprite/cursor.sh b/sh/sprite/cursor.sh new file mode 100644 index 00000000..45b6bea6 --- /dev/null +++ b/sh/sprite/cursor.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eo pipefail + +# Thin shim: ensures bun is available, runs bundled sprite.js (local or from GitHub release) + +_ensure_bun() { + if command -v bun &>/dev/null; then return 0; fi + printf '\033[0;36mInstalling bun...\033[0m\n' >&2 + curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } + export PATH="$HOME/.bun/bin:$PATH" + command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } +} + +_ensure_bun + +# SPAWN_CLI_DIR override — force local source (used by e2e tests) +if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sprite/main.ts" ]]; then + exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sprite/main.ts" cursor "$@" +fi + +# Remote — download bundled sprite.js from GitHub release +SPRITE_JS=$(mktemp) +trap 'rm -f "$SPRITE_JS"' EXIT +curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sprite-latest/sprite.js" -o "$SPRITE_JS" \ + || { printf '\033[0;31mFailed to download sprite.js\033[0m\n' >&2; exit 1; } + +exec bun run "$SPRITE_JS" cursor "$@"