diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b96b8d3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(npm pack:*)", + "Bash(npm install:*)", + "Bash(cursor-api-proxy:*)", + "Bash(node:*)", + "Bash(npm:*)" + ] + } +} diff --git a/.env.example b/.env.example index 637ae45..e90d0b4 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,7 @@ # Optional: timeout per completion in ms # CURSOR_BRIDGE_TIMEOUT_MS=300000 + +# Optional: enable Cursor Max Mode (larger context, more tool calls). Works when using +# CURSOR_AGENT_NODE/SCRIPT or Windows .cmd layout (agent.cmd with node.exe + index.js in same dir). +# CURSOR_BRIDGE_MAX_MODE=true diff --git a/.gitignore b/.gitignore index 6f951cc..f8e79ae 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist/ *.ts.net.key # Session/request log (optional; path configurable via CURSOR_BRIDGE_SESSIONS_LOG) sessions.log +# Local Cursor rules (no-pii etc.) — keep out of public fork +.cursor/ diff --git a/README.md b/README.md index 81db11c..888c7b8 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ OpenAI-compatible proxy for Cursor CLI. Expose Cursor models on localhost so any LLM client (OpenAI SDK, LiteLLM, LangChain, etc.) can call them as a standard chat API. -## Prerequisites +This package works as **one npm dependency**: use it as an **SDK** in your app to call the proxy API, and/or run the **CLI** to start the proxy server. Core behavior is unchanged. + +## Prerequisites (required for the proxy to work) - **Node.js** 18+ -- **Cursor CLI** (`agent`). This project is developed and tested with `agent` version **2026.02.27-e7d2ef6**. Install and log in: +- **Cursor agent CLI** (`agent`). This package does **not** install or bundle the CLI. You must install and set it up separately. This project is developed and tested with `agent` version **2026.02.27-e7d2ef6**. ```bash curl https://cursor.com/install -fsS | bash @@ -17,29 +19,37 @@ OpenAI-compatible proxy for Cursor CLI. Expose Cursor models on localhost so any ## Install +**From npm (use as SDK in another project):** + +```bash +npm install cursor-api-proxy +``` + +**From source (develop or run CLI locally):** + ```bash -cd ~/personal/cursor-api-proxy +git clone +cd cursor-api-proxy npm install npm run build ``` -## Run +## Run the proxy (CLI) + +Start the server so the API is available (e.g. for the SDK or any HTTP client): ```bash -npm start -# or: node dist/cli.js -# or: npx cursor-api-proxy (if linked globally) +npx cursor-api-proxy +# or from repo: npm start / node dist/cli.js ``` -By default the server listens on **http://127.0.0.1:8765**. - -To expose it to your Tailscale network: +To expose on your network (e.g. Tailscale): ```bash -npm start -- --tailscale +npx cursor-api-proxy --tailscale ``` -This binds to `0.0.0.0` unless `CURSOR_BRIDGE_HOST` is explicitly set. Optionally set `CURSOR_BRIDGE_API_KEY` to require `Authorization: Bearer ` on requests. +By default the server listens on **http://127.0.0.1:8765**. Optionally set `CURSOR_BRIDGE_API_KEY` to require `Authorization: Bearer ` on requests. ### HTTPS with Tailscale (MagicDNS) @@ -78,20 +88,25 @@ To serve over HTTPS so browsers and clients trust the connection (e.g. `https:// - Base URL: `https://macbook.tail4048eb.ts.net:8765/v1` (use your MagicDNS name and port) - Browsers will show a padlock; no certificate warnings when using Tailscale-issued certs. -## Usage from other services +## Use as SDK in another project + +Install the package and ensure the **Cursor agent CLI is installed and set up** (see Prerequisites). When you use the SDK with the default URL, **the proxy starts in the background automatically** if it is not already running. You can still start it yourself with `npx cursor-api-proxy` or set `CURSOR_PROXY_URL` to point at an existing proxy (then the SDK will not start another). + +- **Base URL**: `http://127.0.0.1:8765/v1` (override with `CURSOR_PROXY_URL` or options). +- **API key**: Use any value (e.g. `unused`), or set `CURSOR_BRIDGE_API_KEY` and pass it in options or env. +- **Disable auto-start**: Pass `startProxy: false` (or use a custom `baseUrl`) if you run the proxy yourself and don’t want the SDK to start it. +- **Shutdown behavior**: When the SDK starts the proxy, it also stops it automatically when the Node.js process exits or receives normal termination signals. `stopManagedProxy()` is still available if you want to shut it down earlier. `SIGKILL` cannot be intercepted. -- **Base URL**: `http://127.0.0.1:8765/v1` -- **API key**: Use any value (e.g. `unused`), or set `CURSOR_BRIDGE_API_KEY` and send it as `Authorization: Bearer `. +### Option A: OpenAI SDK + helper (recommended) -### Example (OpenAI client) +This is an optional consumer-side example. `openai` is not a dependency of `cursor-api-proxy`; install it only in the app where you want to use this example. ```js import OpenAI from "openai"; +import { getOpenAIOptionsAsync } from "cursor-api-proxy"; -const client = new OpenAI({ - baseURL: "http://127.0.0.1:8765/v1", - apiKey: process.env.CURSOR_BRIDGE_API_KEY || "unused", -}); +const opts = await getOpenAIOptionsAsync(); // starts proxy if needed +const client = new OpenAI(opts); const completion = await client.chat.completions.create({ model: "gpt-5.2", @@ -100,6 +115,33 @@ const completion = await client.chat.completions.create({ console.log(completion.choices[0].message.content); ``` +For a sync config without auto-start, use `getOpenAIOptions()` and ensure the proxy is already running. + +### Option B: Minimal client (no OpenAI SDK) + +```js +import { createCursorProxyClient } from "cursor-api-proxy"; + +const proxy = createCursorProxyClient(); // proxy starts on first request if needed +const data = await proxy.chatCompletionsCreate({ + model: "auto", + messages: [{ role: "user", content: "Hello" }], +}); +console.log(data.choices?.[0]?.message?.content); +``` + +### Option C: Raw OpenAI client (no SDK import from this package) + +```js +import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: "http://127.0.0.1:8765/v1", + apiKey: process.env.CURSOR_BRIDGE_API_KEY || "unused", +}); +// Start the proxy yourself (npx cursor-api-proxy) or use Option A/B for auto-start. +``` + ### Endpoints | Method | Path | Description | @@ -111,6 +153,8 @@ console.log(completion.choices[0].message.content); ## Environment variables +Environment handling is centralized in one module. Aliases, defaults, path resolution, platform fallbacks, and `--tailscale` host behavior are resolved consistently before the server starts. + | Variable | Default | Description | |----------|---------|-------------| | `CURSOR_BRIDGE_HOST` | `127.0.0.1` | Bind address | @@ -126,8 +170,35 @@ console.log(completion.choices[0].message.content); | `CURSOR_BRIDGE_TLS_CERT` | — | Path to TLS certificate file (e.g. Tailscale cert). Use with `CURSOR_BRIDGE_TLS_KEY` for HTTPS. | | `CURSOR_BRIDGE_TLS_KEY` | — | Path to TLS private key file. Use with `CURSOR_BRIDGE_TLS_CERT` for HTTPS. | | `CURSOR_BRIDGE_SESSIONS_LOG` | `~/.cursor-api-proxy/sessions.log` | Path to log file; each request is appended as a line (timestamp, method, path, IP, status). | -| `CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE` | `true` | When `true` (default), the CLI runs in an empty temp dir so it **cannot read or write your project**; pure chat only. Set to `false` to pass the real workspace (e.g. for `X-Cursor-Workspace`). | -| `CURSOR_AGENT_BIN` | `agent` | Path to Cursor CLI binary | +| `CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE` | `true` | When `true` (default), the CLI runs in an empty temp dir so it **cannot read or write your project**; pure chat only. The proxy also overrides `HOME`, `USERPROFILE`, and `CURSOR_CONFIG_DIR` so the agent cannot load rules from `~/.cursor` or project rules from elsewhere. Set to `false` to pass the real workspace (e.g. for `X-Cursor-Workspace`). | +| `CURSOR_BRIDGE_VERBOSE` | `false` | When `true`, print full request messages and response content to stdout for every completion (both stream and sync). | +| `CURSOR_BRIDGE_MAX_MODE` | `false` | When `true`, enable Cursor **Max Mode** for all requests (larger context window, higher tool-call limits). The proxy writes `maxMode: true` to `cli-config.json` before each run. Works when using `CURSOR_AGENT_NODE`/`CURSOR_AGENT_SCRIPT`, the versioned layout (`versions/YYYY.MM.DD-commit/`), or node.exe + index.js next to agent.cmd. | +| `CURSOR_BRIDGE_PROMPT_VIA_STDIN` | `false` | When `true`, the proxy sends the user prompt to the agent via **stdin** instead of as a command-line argument. Use this on Windows if the agent does not receive the full prompt when it is passed in argv (e.g. “no headlines” / truncated). The Cursor agent must support reading the prompt from stdin when no positional prompt is given; if it does not, this option has no effect or may cause errors. | +| `CURSOR_BRIDGE_USE_ACP` | `false` | When `true`, the proxy uses **ACP (Agent Client Protocol)** to talk to the Cursor CLI: it spawns `agent acp` and sends the prompt via JSON-RPC over stdio. This avoids Windows argv limits and quoting issues entirely. On Windows with `agent.cmd`, the proxy auto-detects the versioned layout and spawns Node directly. See [Cursor ACP docs](https://cursor.com/docs/cli/acp). To debug ACP hangs, set `NODE_DEBUG=cursor-api-proxy:acp` to log each ACP step. | +| `CURSOR_BRIDGE_ACP_SKIP_AUTHENTICATE` | auto | When `CURSOR_API_KEY` is set, the proxy skips the ACP authenticate step (avoids hangs). Set to `true` to skip authenticate when using `agent login` instead. | +| `CURSOR_BRIDGE_ACP_RAW_DEBUG` | `false` | When `1` or `true`, log every raw JSON-RPC line from ACP stdout (very verbose). Requires `NODE_DEBUG=cursor-api-proxy:acp` to see output. | +| `CURSOR_AGENT_BIN` | `agent` | Path to Cursor CLI binary. Alias precedence: `CURSOR_AGENT_BIN`, then `CURSOR_CLI_BIN`, then `CURSOR_CLI_PATH`. | +| `CURSOR_AGENT_NODE` | — | **(Windows)** Path to Node.js executable. When set together with `CURSOR_AGENT_SCRIPT`, spawns Node directly instead of going through cmd.exe, bypassing the ~8191 character command line limit. | +| `CURSOR_AGENT_SCRIPT` | — | **(Windows)** Path to the agent script (e.g. `agent.cmd` or the underlying `.js`). Use with `CURSOR_AGENT_NODE` to bypass cmd.exe for long prompts. | + +Notes: +- `--tailscale` changes the default host to `0.0.0.0` only when `CURSOR_BRIDGE_HOST` is not already set. +- ACP `session/request_permission` uses `reject-once` (least-privilege) so the agent cannot grant file/tool access; intentional for chat-only mode. +- Relative paths such as `CURSOR_BRIDGE_WORKSPACE`, `CURSOR_BRIDGE_SESSIONS_LOG`, `CURSOR_BRIDGE_TLS_CERT`, and `CURSOR_BRIDGE_TLS_KEY` are resolved from the current working directory. + +#### Windows command line limit bypass + +On Windows, cmd.exe has a ~8191 character limit on the command line. Long prompts passed as arguments can exceed this and cause the agent to fail. When `agent.cmd` is used (e.g. from `%LOCALAPPDATA%\cursor-agent\`), the proxy **auto-detects the versioned layout** (`versions/YYYY.MM.DD-commit/`) and spawns `node.exe` + `index.js` from the latest version directly, bypassing cmd.exe and PowerShell. If auto-detection does not apply, set both `CURSOR_AGENT_NODE` (path to `node.exe`) and `CURSOR_AGENT_SCRIPT` (path to the agent script). The proxy will then spawn Node directly with the script and args instead of using cmd.exe, avoiding the limit. + +Example (adjust paths to your install): + +```bash +set CURSOR_AGENT_NODE=C:\Program Files\nodejs\node.exe +set CURSOR_AGENT_SCRIPT=C:\path\to\Cursor\resources\agent\agent.cmd +# or for cursor-agent versioned layout: +# set CURSOR_AGENT_NODE=%LOCALAPPDATA%\cursor-agent\versions\2026.03.11-6dfa30c\node.exe +# set CURSOR_AGENT_SCRIPT=%LOCALAPPDATA%\cursor-agent\versions\2026.03.11-6dfa30c\index.js +``` CLI flags: @@ -140,7 +211,7 @@ Optional per-request override: send header `X-Cursor-Workspace: ` to use a ## Streaming -The proxy supports `stream: true` on `POST /v1/chat/completions`. It returns Server-Sent Events (SSE) in OpenAI’s streaming format. Cursor CLI returns the full response in one go, so the proxy sends that response as a single content delta (clients still receive a valid SSE stream). +The proxy supports `stream: true` on `POST /v1/chat/completions` and `POST /v1/messages`. It returns Server-Sent Events (SSE) in OpenAI’s streaming format. Cursor CLI emits incremental deltas plus a final full message; the proxy deduplicates output so clients receive each chunk only once. **Test streaming:** from repo root, with the proxy running: diff --git a/examples/README.md b/examples/README.md index 28532d0..0b9738c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,25 +1,57 @@ # Examples -## test-stream.mjs +**Prerequisites for all examples:** Cursor CLI installed and authenticated (`agent login`). The SDK examples **start the proxy in the background automatically** if it is not already running. -Tests **streaming** (`stream: true`) against the proxy. +Optional: set `CURSOR_PROXY_URL` to use a different proxy URL (default `http://127.0.0.1:8765`). Set `startProxy: false` when creating the client if you run the proxy yourself. -**Prerequisites** +--- -1. Start the proxy from the repo root: `npm start` -2. Cursor CLI installed and authenticated: `agent login` +## SDK examples (using the cursor-api-proxy package) -**Run** +### sdk-client.mjs + +Uses the **minimal client** (`createCursorProxyClient`). Proxy starts automatically on first request. No extra dependencies. ```bash -# From repo root -node examples/test-stream.mjs +npm run build # if running from repo +node examples/sdk-client.mjs ``` -Optional: use a different proxy URL: +### sdk-openai.mjs + +Uses **getOpenAIOptionsAsync** with the **OpenAI SDK**. Proxy starts automatically. This is an optional example; `openai` is not part of this package and only needs to be installed in the project where you run the example. ```bash -CURSOR_PROXY_URL=http://127.0.0.1:8765 node examples/test-stream.mjs +npm install openai +node examples/sdk-openai.mjs +``` + +### sdk-stream.mjs + +Uses the **minimal client**’s **fetch** for streaming. Proxy starts automatically on first request. + +```bash +node examples/sdk-stream.mjs +``` + +--- + +## Raw fetch examples (no SDK) + +### test.mjs + +Non-streaming chat completion via raw `fetch` (no cursor-api-proxy SDK import). + +```bash +node examples/test.mjs +``` + +### test-stream.mjs + +Streaming chat completion via raw `fetch`. + +```bash +node examples/test-stream.mjs ``` -The script sends a short prompt and prints each streamed chunk as it arrives, then the total character count. +Prints each streamed chunk and the total character count. diff --git a/examples/sdk-client.mjs b/examples/sdk-client.mjs new file mode 100644 index 0000000..da55a9d --- /dev/null +++ b/examples/sdk-client.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Example: use the SDK minimal client (createCursorProxyClient). + * The proxy starts in the background automatically if not already running, + * and the SDK stops it when this script exits. + * + * Prereq: Cursor CLI installed and logged in (agent login) + * + * Run: node examples/sdk-client.mjs + */ + +import { createCursorProxyClient } from "cursor-api-proxy"; + +async function main() { + const proxy = createCursorProxyClient(); + + console.log("Proxy will start automatically if needed. Base URL:", proxy.baseUrl); + console.log("---"); + + const data = await proxy.chatCompletionsCreate({ + model: "auto", + messages: [{ role: "user", content: "Say hello in one short sentence." }], + }); + + const content = data.choices?.[0]?.message?.content ?? "(no content)"; + console.log("Response:", content); + console.log("---"); + console.log("Full response (choices):", JSON.stringify(data.choices, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/sdk-openai.mjs b/examples/sdk-openai.mjs new file mode 100644 index 0000000..08517ea --- /dev/null +++ b/examples/sdk-openai.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Example: use the SDK with the OpenAI client (getOpenAIOptionsAsync). + * The proxy starts in the background automatically if not already running, + * and the SDK stops it when this script exits. + * + * Prereqs: Cursor CLI installed and logged in (agent login). Install OpenAI SDK: npm install openai + * + * Run: node examples/sdk-openai.mjs + */ + +import OpenAI from "openai"; +import { getOpenAIOptionsAsync } from "cursor-api-proxy"; + +async function main() { + const opts = await getOpenAIOptionsAsync(); + const client = new OpenAI(opts); + + console.log("Chat completion via OpenAI SDK + cursor-api-proxy (proxy starts automatically if needed)"); + console.log("---"); + + const completion = await client.chat.completions.create({ + model: "auto", + messages: [{ role: "user", content: "Say hello in one short sentence." }], + }); + + const content = completion.choices[0]?.message?.content ?? "(no content)"; + console.log("Response:", content); + console.log("---"); + console.log("Usage:", completion.usage ?? "N/A"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/sdk-stream.mjs b/examples/sdk-stream.mjs new file mode 100644 index 0000000..70e54be --- /dev/null +++ b/examples/sdk-stream.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Example: stream chat completion using the SDK minimal client. + * The proxy starts in the background automatically if not already running, + * and the SDK stops it when this script exits. + * + * Prereq: Cursor CLI installed and logged in (agent login) + * + * Run: node examples/sdk-stream.mjs + */ + +import { createCursorProxyClient } from "cursor-api-proxy"; + +async function main() { + const proxy = createCursorProxyClient(); + + console.log("Streaming (proxy will start automatically if needed)"); + console.log("---"); + + const res = await proxy.fetch("/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "auto", + messages: [{ role: "user", content: "Count from 1 to 5, one number per line." }], + stream: true, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Proxy error ${res.status}: ${err}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fullContent = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") { + console.log("\n--- [DONE]"); + continue; + } + try { + const obj = JSON.parse(data); + const delta = obj.choices?.[0]?.delta?.content; + if (delta) { + process.stdout.write(delta); + fullContent += delta; + } + } catch (_) {} + } + } + } + + if (buffer.startsWith("data: ") && buffer.slice(6) !== "[DONE]") { + try { + const obj = JSON.parse(buffer.slice(6)); + const delta = obj.choices?.[0]?.delta?.content; + if (delta) { + process.stdout.write(delta); + fullContent += delta; + } + } catch (_) {} + } + + console.log("\n---\nStreamed", fullContent.length, "chars total."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 109934d..008e3e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-api-proxy", - "version": "0.1.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-api-proxy", - "version": "0.1.0", + "version": "0.4.0", "license": "MIT", "bin": { "cursor-api-proxy": "dist/cli.js" diff --git a/package.json b/package.json index bb8a61e..9729117 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,35 @@ { "name": "cursor-api-proxy", - "version": "0.2.0", + "version": "0.4.0", "description": "OpenAI-compatible proxy for Cursor CLI — use Cursor models from any LLM client on localhost", "private": false, "type": "module", + "files": [ + "dist", + "!dist/**/*.test.*", + "package.json" + ], + "main": "./dist/client.js", + "types": "./dist/client.d.ts", + "exports": { + ".": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js", + "default": "./dist/client.js" + }, + "./package.json": "./package.json" + }, "bin": { "cursor-api-proxy": "./dist/cli.js" }, "scripts": { + "prepare": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json", "start": "node ./dist/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build && npm test" }, "engines": { "node": ">=18" diff --git a/src/cli.ts b/src/cli.ts index e955a4b..0a20e58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import fs from "node:fs"; import { fileURLToPath } from "node:url"; import pkg from "../package.json" with { type: "json" }; @@ -7,8 +8,10 @@ import { loadBridgeConfig } from "./lib/config.js"; import { startBridgeServer } from "./lib/server.js"; const __filename = fileURLToPath(import.meta.url); -const isMainModule = - typeof process.argv[1] === "string" && process.argv[1] === __filename; +const realArgv1 = process.argv[1] + ? fs.realpathSync(process.argv[1]) + : ""; +const isMainModule = realArgv1 === fs.realpathSync(__filename); export function parseArgs(argv: string[]) { let tailscale = false; @@ -48,11 +51,7 @@ async function main() { return; } - if (args.tailscale && !process.env.CURSOR_BRIDGE_HOST) { - process.env.CURSOR_BRIDGE_HOST = "0.0.0.0"; - } - - const config = loadBridgeConfig(); + const config = loadBridgeConfig({ tailscale: args.tailscale }); startBridgeServer({ version: pkg.version, config }); } diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..428823a --- /dev/null +++ b/src/client.ts @@ -0,0 +1,402 @@ +/** + * SDK for calling cursor-api-proxy from another project. + * + * When startProxy is true (default), the SDK will start the proxy in the + * background if it is not already reachable. Prerequisites: Cursor agent CLI + * must be installed and set up separately (see README). + */ + +const DEFAULT_BASE_URL = "http://127.0.0.1:8765"; +const HEALTH_PATH = "/health"; +const PROXY_START_TIMEOUT_MS = 15_000; +const PROXY_POLL_MS = 200; +const PROXY_STOP_TIMEOUT_MS = 5_000; +const SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"] as const; + +export type CursorProxyClientOptions = { + /** Proxy base URL (e.g. http://127.0.0.1:8765). Default: env CURSOR_PROXY_URL or http://127.0.0.1:8765 */ + baseUrl?: string; + /** Optional API key; if the proxy is started with CURSOR_BRIDGE_API_KEY, pass it here. */ + apiKey?: string; + /** + * When true (default), start the proxy in the background if it is not reachable. + * Only applies when using the default base URL. Set to false if you run the proxy yourself. + */ + startProxy?: boolean; +}; + +let _proxyProcess: import("node:child_process").ChildProcess | null = null; +let _managedProxyStartupPromise: Promise | null = null; +let _managedProxyStartedBySdk = false; +let _shutdownHandlersInstalled = false; +let _signalCleanupInProgress = false; + +function killManagedProxySync(): void { + const child = _proxyProcess; + if (!child || !_managedProxyStartedBySdk) return; + if (child.exitCode == null) { + try { + child.kill("SIGTERM"); + } catch {} + } + _proxyProcess = null; + _managedProxyStartedBySdk = false; + _managedProxyStartupPromise = null; +} + +function installShutdownHandlers(): void { + if ( + _shutdownHandlersInstalled || + typeof process === "undefined" || + !process?.on + ) { + return; + } + + _shutdownHandlersInstalled = true; + + process.on("exit", () => { + killManagedProxySync(); + }); + + const handleSignal = async (signal: (typeof SHUTDOWN_SIGNALS)[number]) => { + if (_signalCleanupInProgress) { + return; + } + _signalCleanupInProgress = true; + + try { + await stopManagedProxy(); + } finally { + for (const value of SHUTDOWN_SIGNALS) { + process.removeListener(value, signalHandlers[value]); + } + _signalCleanupInProgress = false; + try { + process.kill(process.pid, signal); + } catch { + process.exit(1); + } + } + }; + + const signalHandlers = { + SIGINT: () => { + void handleSignal("SIGINT"); + }, + SIGTERM: () => { + void handleSignal("SIGTERM"); + }, + SIGHUP: () => { + void handleSignal("SIGHUP"); + }, + SIGBREAK: () => { + void handleSignal("SIGBREAK"); + }, + } satisfies Record<(typeof SHUTDOWN_SIGNALS)[number], () => void>; + + for (const signal of SHUTDOWN_SIGNALS) { + process.on(signal, signalHandlers[signal]); + } +} + +function isDefaultBaseUrl(baseUrl: string): boolean { + const u = baseUrl.replace(/\/$/, ""); + return u === DEFAULT_BASE_URL || u === "http://127.0.0.1:8765" || u === "http://localhost:8765"; +} + +async function pingHealth(baseUrl: string): Promise { + const url = `${baseUrl.replace(/\/$/, "")}${HEALTH_PATH}`; + try { + const res = await fetch(url, { method: "GET" }); + return res.ok; + } catch { + return false; + } +} + +/** + * Ensures the proxy is running at the given base URL. If the URL is the default + * and the proxy is not reachable, starts it in the background (Node.js only). + * Resolves when /health returns 200 or rejects on timeout. + */ +export async function ensureProxyRunning( + options: { baseUrl?: string; timeoutMs?: number } = {}, +): Promise { + const baseUrl = + options.baseUrl ?? + ((typeof process !== "undefined" && process.env?.CURSOR_PROXY_URL) || + DEFAULT_BASE_URL); + const root = baseUrl.replace(/\/$/, ""); + const timeoutMs = options.timeoutMs ?? PROXY_START_TIMEOUT_MS; + + if (await pingHealth(root)) { + return root; + } + + if (!isDefaultBaseUrl(root)) { + throw new Error( + `cursor-api-proxy is not reachable at ${root}. Start it manually (e.g. npx cursor-api-proxy) or use the default URL for auto-start.`, + ); + } + + const isNode = + typeof process !== "undefined" && + process.versions?.node && + typeof globalThis.fetch !== "undefined"; + + if (!isNode) { + throw new Error( + "cursor-api-proxy is not reachable. Start it manually (e.g. npx cursor-api-proxy). Auto-start is only available in Node.js.", + ); + } + + if (_managedProxyStartupPromise) { + return _managedProxyStartupPromise; + } + + const startupPromise = (async () => { + const { spawn } = await import("node:child_process"); + const pathMod = await import("node:path"); + const path = pathMod.default; + const { fileURLToPath } = await import("node:url"); + + const clientDir = path.dirname(fileURLToPath(import.meta.url)); + const cliPath = path.join(clientDir, "cli.js"); + + const child = spawn(process.execPath, [cliPath], { + stdio: "ignore", + detached: false, + cwd: process.cwd(), + env: process.env, + }); + child.unref(); + installShutdownHandlers(); + _proxyProcess = child; + _managedProxyStartedBySdk = true; + + let exitCode: number | null = null; + child.on("error", () => { + _proxyProcess = null; + _managedProxyStartedBySdk = false; + }); + child.on("exit", (code) => { + exitCode = code ?? null; + _proxyProcess = null; + _managedProxyStartedBySdk = false; + }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, PROXY_POLL_MS)); + if (await pingHealth(root)) { + return root; + } + if (exitCode != null) { + _proxyProcess = null; + _managedProxyStartedBySdk = false; + throw new Error( + `cursor-api-proxy process exited with code ${exitCode} before becoming ready. Ensure Cursor CLI is installed (agent login).`, + ); + } + } + + if (_proxyProcess) { + _proxyProcess.kill(); + _proxyProcess = null; + } + _managedProxyStartedBySdk = false; + throw new Error( + `cursor-api-proxy did not become ready within ${timeoutMs}ms. Ensure Cursor CLI is installed (agent login).`, + ); + })(); + + _managedProxyStartupPromise = startupPromise; + try { + return await startupPromise; + } finally { + if (_managedProxyStartupPromise === startupPromise) { + _managedProxyStartupPromise = null; + } + } +} + +export async function stopManagedProxy( + options: { timeoutMs?: number } = {}, +): Promise { + const child = _proxyProcess; + if (!child || !_managedProxyStartedBySdk) { + return false; + } + + const timeoutMs = options.timeoutMs ?? PROXY_STOP_TIMEOUT_MS; + _managedProxyStartupPromise = null; + + if (child.exitCode != null) { + _proxyProcess = null; + _managedProxyStartedBySdk = false; + return true; + } + + const exitPromise = new Promise((resolve) => { + child.once("exit", () => resolve()); + child.once("error", () => resolve()); + }); + + child.kill("SIGTERM"); + + const exited = await Promise.race([ + exitPromise.then(() => true), + new Promise((resolve) => { + setTimeout(() => resolve(false), timeoutMs); + }), + ]); + + if (!exited && child.exitCode == null) { + child.kill("SIGKILL"); + await exitPromise; + } + + _proxyProcess = null; + _managedProxyStartedBySdk = false; + return true; +} + +/** + * Options suitable for the OpenAI SDK constructor. + * Use: new OpenAI(getOpenAIOptions()) + * For auto-starting the proxy first, use getOpenAIOptionsAsync() and await it. + */ +export function getOpenAIOptions( + options: CursorProxyClientOptions = {}, +): { baseURL: string; apiKey: string } { + const baseUrl = + options.baseUrl ?? + ((typeof process !== "undefined" && process.env?.CURSOR_PROXY_URL) || + DEFAULT_BASE_URL); + const baseURL = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl.replace(/\/$/, "")}/v1`; + const apiKey = + options.apiKey ?? + ((typeof process !== "undefined" && process.env?.CURSOR_BRIDGE_API_KEY) || + "unused"); + return { baseURL, apiKey }; +} + +/** + * Like getOpenAIOptions but ensures the proxy is running first (starts it in the background if needed). + * Use: new OpenAI(await getOpenAIOptionsAsync()) + */ +export async function getOpenAIOptionsAsync( + options: CursorProxyClientOptions & { timeoutMs?: number } = {}, +): Promise<{ baseURL: string; apiKey: string }> { + const startProxy = options.startProxy !== false; + const baseUrl = + options.baseUrl ?? + ((typeof process !== "undefined" && process.env?.CURSOR_PROXY_URL) || + DEFAULT_BASE_URL); + const root = baseUrl.replace(/\/$/, ""); + + if (startProxy && isDefaultBaseUrl(root)) { + await ensureProxyRunning({ baseUrl: root, timeoutMs: options.timeoutMs }); + } + return getOpenAIOptions(options); +} + +/** + * Minimal client to call the proxy HTTP API. + * When startProxy is true (default), the proxy is started in the background on first request if not reachable. + */ +export function createCursorProxyClient(options: CursorProxyClientOptions = {}) { + const startProxy = options.startProxy !== false; + const baseUrl = + options.baseUrl ?? + ((typeof process !== "undefined" && process.env?.CURSOR_PROXY_URL) || + DEFAULT_BASE_URL); + const root = baseUrl.replace(/\/$/, ""); + const apiKeyRaw = + options.apiKey ?? + (typeof process !== "undefined" ? process.env?.CURSOR_BRIDGE_API_KEY : undefined); + const apiKey = typeof apiKeyRaw === "string" ? apiKeyRaw : undefined; + + const headers: Record = { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }; + + async function ensureThenRequest( + path: string, + init: RequestInit, + ): Promise { + const url = path.startsWith("http") + ? path + : `${root}${path.startsWith("/") ? "" : "/"}${path}`; + if (startProxy && isDefaultBaseUrl(root)) { + await ensureProxyRunning({ baseUrl: root }); + } + return fetch(url, init); + } + + return { + /** Base URL of the proxy (no /v1 suffix) */ + baseUrl: root, + /** Headers to send (Content-Type and optional Authorization) */ + headers, + /** Get options for the OpenAI SDK constructor */ + getOpenAIOptions: () => + getOpenAIOptions({ baseUrl: root, apiKey: apiKey ?? "unused" }), + + /** + * POST to a path (e.g. /v1/chat/completions). Body is JSON-serialized. + */ + async request( + path: string, + body: unknown, + ): Promise<{ data: T; ok: boolean; status: number }> { + const url = path.startsWith("http") + ? path + : `${root}${path.startsWith("/") ? "" : "/"}${path}`; + const res = await ensureThenRequest(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as T; + return { data, ok: res.ok, status: res.status }; + }, + + /** OpenAI-style chat completions (non-streaming). */ + async chatCompletionsCreate(params: { + model?: string; + messages: Array<{ role: string; content: string }>; + stream?: false; + }) { + const { data, ok, status } = await this.request<{ + choices?: Array<{ message?: { content?: string } }>; + error?: { message?: string }; + }>("/v1/chat/completions", { + model: params.model ?? "auto", + messages: params.messages, + stream: false, + }); + if (!ok) { + const err = data?.error?.message ?? JSON.stringify(data); + throw new Error(`cursor-api-proxy error (${status}): ${err}`); + } + return data; + }, + + /** + * Use for streaming: returns a fetch Response so you can read the body stream. + * Ensures the proxy is running first when startProxy is true. + */ + async fetch(path: string, init: RequestInit = {}): Promise { + const url = path.startsWith("http") + ? path + : `${root}${path.startsWith("/") ? "" : "/"}${path}`; + const merged = new Headers(init.headers); + merged.set("Content-Type", "application/json"); + if (apiKey) merged.set("Authorization", `Bearer ${apiKey}`); + return ensureThenRequest(url, { ...init, headers: merged }); + }, + }; +} diff --git a/src/lib/__tests__/fake-acp-server.mjs b/src/lib/__tests__/fake-acp-server.mjs new file mode 100644 index 0000000..c76c6c6 --- /dev/null +++ b/src/lib/__tests__/fake-acp-server.mjs @@ -0,0 +1,36 @@ +/** + * Minimal fake ACP server for integration tests. + * Reads JSON-RPC from stdin, responds to initialize, authenticate, session/new, session/prompt. + */ +import { createInterface } from "node:readline"; + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + try { + const msg = JSON.parse(line); + if (msg.id != null && msg.method) { + let result = {}; + if (msg.method === "initialize") result = { protocolVersion: 1 }; + else if (msg.method === "authenticate") result = {}; + else if (msg.method === "session/new") result = { sessionId: "sess-1" }; + else if (msg.method === "session/prompt") { + result = {}; + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "Hello from fake ACP" }, + }, + }, + }) + "\n", + ); + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n"); + } + } catch { + /* ignore */ + } +}); diff --git a/src/lib/acp-client.test.ts b/src/lib/acp-client.test.ts new file mode 100644 index 0000000..d1b9f4b --- /dev/null +++ b/src/lib/acp-client.test.ts @@ -0,0 +1,44 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runAcpSync } from "./acp-client.js"; + +const node = process.execPath; +const cwd = process.cwd(); +const fakeServerPath = join(cwd, "src", "lib", "__tests__", "fake-acp-server.mjs"); +const fakeServerSlowPath = join(cwd, "src", "lib", "__tests__", "fake-acp-server-slow.mjs"); + +describe("runAcpSync", () => { + it("returns stdout content from session/update agent_message_chunk", async () => { + const resultPromise = runAcpSync(node, [fakeServerPath], "test prompt", { + cwd, + timeoutMs: 5000, + skipAuthenticate: true, + }); + const result = await resultPromise; + expect(result.code).toBe(0); + expect(result.stdout).toContain("Hello from fake ACP"); + }); + + it("skips authenticate when skipAuthenticate is true", async () => { + const resultPromise = runAcpSync(node, [fakeServerPath], "test", { + cwd, + timeoutMs: 5000, + skipAuthenticate: true, + }); + const result = await resultPromise; + expect(result.code).toBe(0); + expect(result.stdout).toBeTruthy(); + }); + + it("sends authenticate when skipAuthenticate is false", async () => { + const resultPromise = runAcpSync(node, [fakeServerPath], "test", { + cwd, + timeoutMs: 5000, + skipAuthenticate: false, + }); + const result = await resultPromise; + expect(result.code).toBe(0); + expect(result.stdout).toBeTruthy(); + }); + +}); diff --git a/src/lib/acp-client.ts b/src/lib/acp-client.ts new file mode 100644 index 0000000..75c2839 --- /dev/null +++ b/src/lib/acp-client.ts @@ -0,0 +1,539 @@ +/** + * ACP (Agent Client Protocol) client for Cursor CLI. + * Spawns `agent acp` and communicates via JSON-RPC over stdio. + * See https://cursor.com/docs/cli/acp and https://agentclientprotocol.com/ + */ + +import * as readline from "node:readline"; +import { spawn } from "node:child_process"; +import { debuglog } from "node:util"; + +const debugAcp = debuglog("cursor-api-proxy:acp"); + +export type AcpRunOptions = { + cwd: string; + timeoutMs: number; + env?: Record; + /** When set, call session/set_config_option for "model" after session/new (ACP session config). */ + model?: string; + /** Per-request timeout in ms (default 60000). Rejects and clears pending on timeout. */ + requestTimeoutMs?: number; + /** Spawn options (e.g. windowsVerbatimArguments for cmd.exe fallback on Windows). */ + spawnOptions?: { windowsVerbatimArguments?: boolean }; + /** When true, skip authenticate step (use when pre-authenticated via --api-key or agent login). */ + skipAuthenticate?: boolean; + /** When true, log every raw JSON-RPC line from ACP stdout (very verbose). */ + rawDebug?: boolean; +}; + +export type AcpSyncResult = { + code: number; + stdout: string; + stderr: string; +}; + +export type AcpStreamResult = { + code: number; + stderr: string; +}; + +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; + +function sendRequest( + stdin: NodeJS.WritableStream, + nextId: { current: number }, + method: string, + params: object, + pending: Map< + number, + { resolve: (value: unknown) => void; reject: (err: Error) => void; timerId?: ReturnType } + >, + requestTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, +): Promise { + const id = nextId.current++; + const line = + JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + stdin.write(line, "utf8"); + return new Promise((resolve, reject) => { + let timerId: ReturnType | undefined; + if (requestTimeoutMs > 0) { + timerId = setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`ACP ${method} timed out after ${requestTimeoutMs}ms`)); + } + }, requestTimeoutMs); + } + pending.set(id, { + resolve: (v) => { + if (timerId) clearTimeout(timerId); + resolve(v); + }, + reject: (e) => { + if (timerId) clearTimeout(timerId); + reject(e); + }, + timerId, + }); + }); +} + +function respond(stdin: NodeJS.WritableStream, id: number, result: object): void { + const line = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"; + stdin.write(line, "utf8"); +} + +/** + * Run a single prompt via ACP and return the full response (sync). + * Uses pre-resolved command + args (e.g. node + script on Windows) to avoid spawn EINVAL and DEP0190. + */ +export function runAcpSync( + command: string, + args: string[], + prompt: string, + opts: AcpRunOptions, +): Promise { + const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: ["pipe", "pipe", "pipe"], + windowsVerbatimArguments: opts.spawnOptions?.windowsVerbatimArguments, + }); + + let stderr = ""; + let accumulated = ""; + let resolved = false; + + const finish = (code: number) => { + if (resolved) return; + resolved = true; + const exitErr = new Error(`ACP child exited with code ${code}`); + for (const [id, waiter] of Array.from(pending.entries())) { + pending.delete(id); + if (waiter.timerId) clearTimeout(waiter.timerId); + waiter.reject(exitErr); + } + try { + child.stdin?.end(); + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + resolve({ + code, + stdout: accumulated.trim(), + stderr: stderr.trim(), + }); + }; + + const timeout = + opts.timeoutMs > 0 + ? setTimeout(() => { + finish(124); // timeout exit code + }, opts.timeoutMs) + : undefined; + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => (stderr += chunk)); + + const nextId = { current: 1 }; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (err: Error) => void; timerId?: ReturnType } + >(); + + const rl = readline.createInterface({ input: child.stdout! }); + rl.on("line", (line: string) => { + try { + if (opts.rawDebug) { + debugAcp("ACP raw: %s", line); + } + const msg = JSON.parse(line) as { + id?: number; + method?: string; + params?: { update?: { sessionUpdate?: string; content?: { text?: string } } }; + result?: unknown; + error?: { message?: string }; + }; + + if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) { + const waiter = pending.get(msg.id); + if (waiter) { + pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(msg.error.message ?? "ACP error")); + } else { + waiter.resolve(msg.result); + } + } + return; + } + + if (msg.method === "session/update") { + const update = (msg.params?.update ?? msg.params) as { + sessionUpdate?: string; + content?: { text?: string } | Array<{ content?: { text?: string }; text?: string }>; + } | undefined; + const content = update?.content; + const text = + typeof content === "object" && content !== null && !Array.isArray(content) && typeof (content as { text?: string }).text === "string" + ? (content as { text: string }).text + : Array.isArray(content) + ? content + .map((c: { content?: { text?: string }; text?: string }) => + typeof c?.content?.text === "string" + ? c.content.text + : typeof c?.text === "string" + ? c.text + : "", + ) + .join("") + : ""; + const sessionUpdate = update?.sessionUpdate; + if ( + (sessionUpdate === "agent_message_chunk" || sessionUpdate === "agent_thought_chunk") && + text + ) { + accumulated += text; + } else if ( + sessionUpdate && + sessionUpdate !== "agent_thought_chunk" && + sessionUpdate !== "available_commands_update" && + sessionUpdate !== "tool_call" && + sessionUpdate !== "tool_call_update" + ) { + debugAcp( + "session/update (unhandled): %s", + JSON.stringify({ + sessionUpdate, + hasContent: !!content, + contentKeys: content && typeof content === "object" && !Array.isArray(content) ? Object.keys(content) : [], + }), + ); + } + return; + } + + if (msg.method === "session/request_permission") { + if (msg.id != null && child.stdin) { + respond(child.stdin, msg.id, { + outcome: { outcome: "selected", optionId: "reject-once" }, + }); + } + return; + } + + if (msg.id != null && msg.method && child.stdin) { + const method = String(msg.method); + if (method.startsWith("cursor/")) { + const params = msg.params as Record | undefined; + if (method === "cursor/ask_question" && params?.options && Array.isArray(params.options)) { + const options = params.options as Array<{ id?: string; label?: string }>; + const first = options[0]; + console.warn( + "[cursor-api-proxy:acp] cursor/ask_question auto-selecting first option: id=%s (total=%d)", + first?.id ?? "(none)", + options.length, + ); + respond(child.stdin, msg.id, { selectedId: first?.id ?? "" }); + } else if (method === "cursor/create_plan") { + respond(child.stdin, msg.id, { approved: true }); + } else { + respond(child.stdin, msg.id, {}); + } + } + } + } catch { + /* ignore parse errors */ + } + }); + + child.on("error", (err) => { + if (timeout) clearTimeout(timeout); + if (!resolved) { + resolved = true; + reject(err); + } + }); + + child.on("close", (code) => { + if (timeout) clearTimeout(timeout); + finish(code ?? 1); + }); + + const run = async () => { + if (!child.stdin) { + finish(1); + return; + } + try { + debugAcp("ACP step: initialize"); + await sendRequest(child.stdin, nextId, "initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "cursor-api-proxy", version: "0.1.0" }, + }, pending, requestTimeoutMs); + + if (!opts.skipAuthenticate) { + debugAcp("ACP step: authenticate"); + await sendRequest(child.stdin, nextId, "authenticate", { + methodId: "cursor_login", + }, pending, requestTimeoutMs); + } else { + debugAcp("ACP step: authenticate (skipped, pre-authenticated)"); + } + + debugAcp("ACP step: session/new"); + const sessionResult = (await sendRequest( + child.stdin, + nextId, + "session/new", + { cwd: opts.cwd, mcpServers: [] }, + pending, + requestTimeoutMs, + )) as { sessionId?: string }; + const sessionId = sessionResult?.sessionId; + if (!sessionId) { + finish(1); + return; + } + + debugAcp("ACP step: session/prompt"); + await sendRequest(child.stdin, nextId, "session/prompt", { + sessionId, + prompt: [{ type: "text", text: prompt }], + }, pending, requestTimeoutMs); + if (accumulated.length === 0) { + debugAcp("ACP sync: no content accumulated; stderr tail: %s", stderr.slice(-500)); + } + finish(0); + } catch { + if (timeout) clearTimeout(timeout); + if (!resolved) { + resolved = true; + finish(1); + } + } + }; + + run(); + }); +} + +/** + * Run a single prompt via ACP and stream response chunks via onChunk. + */ +export function runAcpStream( + command: string, + args: string[], + prompt: string, + opts: AcpRunOptions, + onChunk: (text: string) => void, +): Promise { + const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: ["pipe", "pipe", "pipe"], + windowsVerbatimArguments: opts.spawnOptions?.windowsVerbatimArguments, + }); + + let stderr = ""; + let resolved = false; + + const finish = (code: number) => { + if (resolved) return; + resolved = true; + const exitErr = new Error(`ACP child exited with code ${code}`); + for (const [id, waiter] of Array.from(pending.entries())) { + pending.delete(id); + if (waiter.timerId) clearTimeout(waiter.timerId); + waiter.reject(exitErr); + } + try { + child.stdin?.end(); + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + resolve({ code, stderr: stderr.trim() }); + }; + + const timeout = + opts.timeoutMs > 0 + ? setTimeout(() => finish(124), opts.timeoutMs) + : undefined; + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => (stderr += chunk)); + + const nextId = { current: 1 }; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (err: Error) => void; timerId?: ReturnType } + >(); + + const rl = readline.createInterface({ input: child.stdout! }); + rl.on("line", (line: string) => { + try { + if (opts.rawDebug) { + debugAcp("ACP raw: %s", line); + } + const msg = JSON.parse(line) as { + id?: number; + method?: string; + params?: { update?: { sessionUpdate?: string; content?: { text?: string } } }; + result?: unknown; + error?: { message?: string }; + }; + + if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) { + const waiter = pending.get(msg.id); + if (waiter) { + pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(msg.error.message ?? "ACP error")); + } else { + waiter.resolve(msg.result); + } + } + return; + } + + if (msg.method === "session/update") { + const update = (msg.params?.update ?? msg.params) as { + sessionUpdate?: string; + content?: { text?: string } | Array<{ content?: { text?: string }; text?: string }>; + } | undefined; + const content = update?.content; + const text = + typeof content === "object" && content !== null && !Array.isArray(content) && typeof (content as { text?: string }).text === "string" + ? (content as { text: string }).text + : Array.isArray(content) + ? content + .map((c: { content?: { text?: string }; text?: string }) => + typeof c?.content?.text === "string" + ? c.content.text + : typeof c?.text === "string" + ? c.text + : "", + ) + .join("") + : ""; + const sessionUpdate = update?.sessionUpdate; + if ( + (sessionUpdate === "agent_message_chunk" || sessionUpdate === "agent_thought_chunk") && + text + ) { + onChunk(text); + } else if ( + sessionUpdate && + sessionUpdate !== "agent_thought_chunk" && + sessionUpdate !== "available_commands_update" && + sessionUpdate !== "tool_call" && + sessionUpdate !== "tool_call_update" + ) { + debugAcp( + "session/update (unhandled): %s", + JSON.stringify({ + sessionUpdate, + hasContent: !!content, + contentKeys: content && typeof content === "object" && !Array.isArray(content) ? Object.keys(content) : [], + }), + ); + } + return; + } + + if (msg.method === "session/request_permission") { + if (msg.id != null && child.stdin) { + respond(child.stdin, msg.id, { + outcome: { outcome: "selected", optionId: "reject-once" }, + }); + } + return; + } + } catch { + /* ignore */ + } + }); + + child.on("error", (err) => { + if (timeout) clearTimeout(timeout); + if (!resolved) { + resolved = true; + reject(err); + } + }); + + child.on("close", (code) => { + if (timeout) clearTimeout(timeout); + finish(code ?? 1); + }); + + const run = async () => { + if (!child.stdin) { + finish(1); + return; + } + try { + debugAcp("ACP step: initialize"); + await sendRequest(child.stdin, nextId, "initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "cursor-api-proxy", version: "0.1.0" }, + }, pending, requestTimeoutMs); + + if (!opts.skipAuthenticate) { + debugAcp("ACP step: authenticate"); + await sendRequest(child.stdin, nextId, "authenticate", { + methodId: "cursor_login", + }, pending, requestTimeoutMs); + } else { + debugAcp("ACP step: authenticate (skipped, pre-authenticated)"); + } + + debugAcp("ACP step: session/new"); + const sessionResult = (await sendRequest( + child.stdin, + nextId, + "session/new", + { cwd: opts.cwd, mcpServers: [] }, + pending, + requestTimeoutMs, + )) as { sessionId?: string }; + const sessionId = sessionResult?.sessionId; + if (!sessionId) { + finish(1); + return; + } + + debugAcp("ACP step: session/prompt"); + await sendRequest(child.stdin, nextId, "session/prompt", { + sessionId, + prompt: [{ type: "text", text: prompt }], + }, pending, requestTimeoutMs); + finish(0); + } catch { + if (timeout) clearTimeout(timeout); + if (!resolved) { + resolved = true; + finish(1); + } + } + }; + + run(); + }); +} diff --git a/src/lib/agent-cmd-args.ts b/src/lib/agent-cmd-args.ts index 4214d30..26d3f1b 100644 --- a/src/lib/agent-cmd-args.ts +++ b/src/lib/agent-cmd-args.ts @@ -22,6 +22,8 @@ export function buildAgentCmdArgs( } else { args.push("--output-format", "text"); } - args.push(prompt); + if (!config.promptViaStdin) { + args.push(prompt); + } return args; } diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 826c2ea..23d3e92 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -1,7 +1,9 @@ import * as fs from "node:fs"; +import { runAcpStream, runAcpSync } from "./acp-client.js"; import type { BridgeConfig } from "./config.js"; import { run, runStreaming } from "./process.js"; +import { getChatOnlyEnvOverrides } from "./workspace.js"; export type AgentRunResult = { code: number; @@ -9,15 +11,67 @@ export type AgentRunResult = { stderr: string; }; +function acpArgsWithModel(acpArgs: string[], model: string): string[] { + const i = acpArgs.indexOf("acp"); + if (i === -1) return acpArgs; + return [...acpArgs.slice(0, i + 1), "--model", model, ...acpArgs.slice(i + 1)]; +} + +function acpArgsWithWorkspace(acpArgs: string[], workspaceDir: string): string[] { + const i = acpArgs.indexOf("acp"); + if (i === -1) return acpArgs; + return [...acpArgs.slice(0, i), "--workspace", workspaceDir, ...acpArgs.slice(i)]; +} + +function extractModelFromCmdArgs(cmdArgs: string[]): string | undefined { + const i = cmdArgs.indexOf("--model"); + return i >= 0 && i + 1 < cmdArgs.length ? cmdArgs[i + 1] : undefined; +} + export function runAgentSync( config: BridgeConfig, workspaceDir: string, cmdArgs: string[], tempDir?: string, + stdinPrompt?: string, ): Promise { + if (config.useAcp && typeof stdinPrompt === "string") { + const acpModel = extractModelFromCmdArgs(cmdArgs); + let args = acpArgsWithWorkspace(config.acpArgs, workspaceDir); + args = acpModel ? acpArgsWithModel(args, acpModel) : args; + const acpEnv = { ...config.acpEnv }; + if (config.chatOnlyWorkspace) { + Object.assign(acpEnv, getChatOnlyEnvOverrides(workspaceDir)); + } + return runAcpSync(config.acpCommand, args, stdinPrompt, { + cwd: workspaceDir, + timeoutMs: config.timeoutMs, + env: acpEnv, + model: acpModel, + requestTimeoutMs: 60_000, + spawnOptions: config.acpSpawnOptions, + skipAuthenticate: config.acpSkipAuthenticate, + rawDebug: config.acpRawDebug, + }).then((out) => { + if (tempDir) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + return out; + }); + } + const runEnvOverrides = config.chatOnlyWorkspace + ? getChatOnlyEnvOverrides(workspaceDir) + : undefined; return run(config.agentBin, cmdArgs, { cwd: workspaceDir, timeoutMs: config.timeoutMs, + maxMode: config.maxMode, + stdinContent: stdinPrompt, + envOverrides: runEnvOverrides, }).then((out) => { if (tempDir) { try { @@ -38,11 +92,52 @@ export function runAgentStream( cmdArgs: string[], onLine: StreamLineHandler, tempDir?: string, + stdinPrompt?: string, ): Promise<{ code: number; stderr: string }> { + if (config.useAcp && typeof stdinPrompt === "string") { + const acpModel = extractModelFromCmdArgs(cmdArgs); + let args = acpArgsWithWorkspace(config.acpArgs, workspaceDir); + args = acpModel ? acpArgsWithModel(args, acpModel) : args; + const acpEnv = { ...config.acpEnv }; + if (config.chatOnlyWorkspace) { + Object.assign(acpEnv, getChatOnlyEnvOverrides(workspaceDir)); + } + return runAcpStream( + config.acpCommand, + args, + stdinPrompt, + { + cwd: workspaceDir, + timeoutMs: config.timeoutMs, + env: acpEnv, + model: acpModel, + requestTimeoutMs: 60_000, + spawnOptions: config.acpSpawnOptions, + skipAuthenticate: config.acpSkipAuthenticate, + rawDebug: config.acpRawDebug, + }, + onLine, + ).then((result) => { + if (tempDir) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + return result; + }); + } + const streamEnvOverrides = config.chatOnlyWorkspace + ? getChatOnlyEnvOverrides(workspaceDir) + : undefined; return runStreaming(config.agentBin, cmdArgs, { cwd: workspaceDir, timeoutMs: config.timeoutMs, + maxMode: config.maxMode, onLine, + stdinContent: stdinPrompt, + envOverrides: streamEnvOverrides, }).then((result) => { if (tempDir) { try { diff --git a/src/lib/cli-stream-parser.test.ts b/src/lib/cli-stream-parser.test.ts new file mode 100644 index 0000000..995ecec --- /dev/null +++ b/src/lib/cli-stream-parser.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import { createStreamParser } from "./cli-stream-parser.js"; + +describe("createStreamParser", () => { + it("emits incremental text deltas", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hello" }] }, + })); + expect(onText).toHaveBeenCalledTimes(1); + expect(onText).toHaveBeenCalledWith("Hello"); + + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hello world" }] }, + })); + expect(onText).toHaveBeenCalledTimes(2); + expect(onText).toHaveBeenLastCalledWith(" world"); + }); + + it("deduplicates final full message (skip when text === accumulated)", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hi" }] }, + })); + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hi there" }] }, + })); + expect(onText).toHaveBeenCalledTimes(2); + expect(onText).toHaveBeenNthCalledWith(1, "Hi"); + expect(onText).toHaveBeenNthCalledWith(2, " there"); + + // Final duplicate: full accumulated text again + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hi there" }] }, + })); + expect(onText).toHaveBeenCalledTimes(2); // no new call + }); + + it("calls onDone when result/success received", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ type: "result", subtype: "success" })); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onText).not.toHaveBeenCalled(); + }); + + it("ignores lines after done", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ type: "result", subtype: "success" })); + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "late" }] }, + })); + expect(onText).not.toHaveBeenCalled(); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it("ignores non-assistant lines", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ type: "user", message: {} })); + parse(JSON.stringify({ type: "assistant", message: { content: [] } })); + parse('{"type":"assistant","message":{"content":[{"type":"code","text":"x"}]}}'); + expect(onText).not.toHaveBeenCalled(); + }); + + it("ignores parse errors (non-JSON lines)", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse("not json"); + parse("{"); + parse(""); + expect(onText).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("handles first message as full text (no prefix match)", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Full response" }] }, + })); + expect(onText).toHaveBeenCalledTimes(1); + expect(onText).toHaveBeenCalledWith("Full response"); + }); + + it("joins multiple text parts in one message", () => { + const onText = vi.fn(); + const onDone = vi.fn(); + const parse = createStreamParser(onText, onDone); + + parse(JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "text", text: "Hello" }, + { type: "text", text: " " }, + { type: "text", text: "world" }, + ], + }, + })); + expect(onText).toHaveBeenCalledTimes(1); + expect(onText).toHaveBeenCalledWith("Hello world"); + }); +}); diff --git a/src/lib/cli-stream-parser.ts b/src/lib/cli-stream-parser.ts index d64774d..04a99ab 100644 --- a/src/lib/cli-stream-parser.ts +++ b/src/lib/cli-stream-parser.ts @@ -1,25 +1,51 @@ /** - * Parse a line from Cursor CLI stream-json output. - * Calls onText for each text chunk and onDone when the stream completes. + * Create a stateful stream parser for Cursor CLI stream-json output. + * + * The CLI emits individual assistant delta chunks and then a final assistant + * message containing the full accumulated text. We track what we've already + * emitted so the final duplicate is skipped. */ -export function parseCliStreamLine( - line: string, +export function createStreamParser( onText: (text: string) => void, onDone: () => void, -): void { - try { - const obj = JSON.parse(line) as { - type?: string; - subtype?: string; - message?: { content?: Array<{ type?: string; text?: string }> }; - }; - if (obj.type === "assistant" && obj.message?.content) { - for (const part of obj.message.content) { - if (part.type === "text" && part.text) onText(part.text); +): (line: string) => void { + let accumulated = ""; + let done = false; + + return (line: string) => { + if (done) return; + try { + const obj = JSON.parse(line) as { + type?: string; + subtype?: string; + message?: { content?: Array<{ type?: string; text?: string }> }; + }; + + if (obj.type === "assistant" && obj.message?.content) { + const text = obj.message.content + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text!) + .join(""); + if (!text) return; + + if (text === accumulated) return; + + if (text.startsWith(accumulated) && accumulated.length > 0) { + const delta = text.slice(accumulated.length); + if (delta) onText(delta); + accumulated = text; + } else { + onText(text); + accumulated += text; + } } + + if (obj.type === "result" && obj.subtype === "success") { + done = true; + onDone(); + } + } catch { + /* ignore parse errors for non-JSON lines */ } - if (obj.type === "result" && obj.subtype === "success") onDone(); - } catch { - /* ignore parse errors for non-JSON lines */ - } + }; } diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index ba749ca..759e892 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,52 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { loadBridgeConfig } from "./config.js"; import * as path from "node:path"; +import { describe, expect, it } from "vitest"; -const ENV_BACKUP: Record = {}; - -function setEnv(key: string, value: string | undefined) { - ENV_BACKUP[key] = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; -} - -function restoreEnv() { - for (const [key, val] of Object.entries(ENV_BACKUP)) { - if (val === undefined) delete process.env[key]; - else process.env[key] = val; - } -} +import { loadBridgeConfig } from "./config.js"; describe("loadBridgeConfig", () => { - const configKeys = [ - "CURSOR_AGENT_BIN", - "CURSOR_CLI_BIN", - "CURSOR_BRIDGE_HOST", - "CURSOR_BRIDGE_PORT", - "CURSOR_BRIDGE_API_KEY", - "CURSOR_BRIDGE_DEFAULT_MODEL", - "CURSOR_BRIDGE_FORCE", - "CURSOR_BRIDGE_APPROVE_MCPS", - "CURSOR_BRIDGE_STRICT_MODEL", - "CURSOR_BRIDGE_WORKSPACE", - "CURSOR_BRIDGE_SESSIONS_LOG", - "CURSOR_BRIDGE_TIMEOUT_MS", - "CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", - "HOME", - "USERPROFILE", - ]; - - beforeEach(() => { - for (const key of configKeys) { - ENV_BACKUP[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(restoreEnv); - it("returns defaults when env is empty", () => { - const config = loadBridgeConfig(); + const config = loadBridgeConfig({ env: {}, cwd: "/workspace" }); + expect(config.agentBin).toBe("agent"); expect(config.host).toBe("127.0.0.1"); expect(config.port).toBe(8765); @@ -56,79 +16,85 @@ describe("loadBridgeConfig", () => { expect(config.approveMcps).toBe(false); expect(config.strictModel).toBe(true); expect(config.mode).toBe("ask"); + expect(config.workspace).toBe("/workspace"); expect(config.chatOnlyWorkspace).toBe(true); + expect(config.sessionsLogPath).toBe("/workspace/sessions.log"); }); - it("uses CURSOR_AGENT_BIN for agent path", () => { - setEnv("CURSOR_AGENT_BIN", "/usr/bin/agent"); - const config = loadBridgeConfig(); - expect(config.agentBin).toBe("/usr/bin/agent"); - }); + it("assembles config from the centralized env layer", () => { + const config = loadBridgeConfig({ + env: { + CURSOR_AGENT_BIN: "/usr/bin/agent", + CURSOR_BRIDGE_HOST: "0.0.0.0", + CURSOR_BRIDGE_PORT: "9999", + CURSOR_BRIDGE_API_KEY: "sk-secret", + CURSOR_BRIDGE_DEFAULT_MODEL: "org/claude-3-opus", + CURSOR_BRIDGE_FORCE: "true", + CURSOR_BRIDGE_APPROVE_MCPS: "yes", + CURSOR_BRIDGE_STRICT_MODEL: "false", + CURSOR_BRIDGE_WORKSPACE: "./my-workspace", + CURSOR_BRIDGE_TIMEOUT_MS: "60000", + CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE: "false", + CURSOR_BRIDGE_VERBOSE: "1", + CURSOR_BRIDGE_TLS_CERT: "./certs/test.crt", + CURSOR_BRIDGE_TLS_KEY: "./certs/test.key", + }, + cwd: "/tmp/project", + }); - it("uses CURSOR_BRIDGE_HOST for host", () => { - setEnv("CURSOR_BRIDGE_HOST", "0.0.0.0"); - const config = loadBridgeConfig(); + expect(config.agentBin).toBe("/usr/bin/agent"); expect(config.host).toBe("0.0.0.0"); - }); - - it("uses CURSOR_BRIDGE_PORT for port", () => { - setEnv("CURSOR_BRIDGE_PORT", "9999"); - const config = loadBridgeConfig(); expect(config.port).toBe(9999); - }); - - it("uses CURSOR_BRIDGE_API_KEY for requiredKey", () => { - setEnv("CURSOR_BRIDGE_API_KEY", "sk-secret"); - const config = loadBridgeConfig(); expect(config.requiredKey).toBe("sk-secret"); - }); - - it("normalizes default model id", () => { - setEnv("CURSOR_BRIDGE_DEFAULT_MODEL", "org/claude-3-opus"); - const config = loadBridgeConfig(); expect(config.defaultModel).toBe("claude-3-opus"); + expect(config.force).toBe(true); + expect(config.approveMcps).toBe(true); + expect(config.strictModel).toBe(false); + expect(path.isAbsolute(config.workspace)).toBe(true); + expect(config.workspace).toContain("my-workspace"); + expect(config.timeoutMs).toBe(60000); + expect(config.chatOnlyWorkspace).toBe(false); + expect(config.verbose).toBe(true); + expect(config.tlsCertPath).toBe("/tmp/project/certs/test.crt"); + expect(config.tlsKeyPath).toBe("/tmp/project/certs/test.key"); }); - it("parses CURSOR_BRIDGE_FORCE as boolean", () => { - setEnv("CURSOR_BRIDGE_FORCE", "true"); - expect(loadBridgeConfig().force).toBe(true); - setEnv("CURSOR_BRIDGE_FORCE", "1"); - expect(loadBridgeConfig().force).toBe(true); - setEnv("CURSOR_BRIDGE_FORCE", "false"); - expect(loadBridgeConfig().force).toBe(false); - setEnv("CURSOR_BRIDGE_FORCE", "0"); - expect(loadBridgeConfig().force).toBe(false); - }); - - it("parses CURSOR_BRIDGE_APPROVE_MCPS as boolean", () => { - setEnv("CURSOR_BRIDGE_APPROVE_MCPS", "yes"); - expect(loadBridgeConfig().approveMcps).toBe(true); - setEnv("CURSOR_BRIDGE_APPROVE_MCPS", "off"); - expect(loadBridgeConfig().approveMcps).toBe(false); + it("sets acpSkipAuthenticate and acpEnv when CURSOR_API_KEY is set", () => { + const config = loadBridgeConfig({ + env: { CURSOR_API_KEY: "sk-abc", CURSOR_AGENT_BIN: "agent" }, + cwd: "/workspace", + }); + expect(config.acpSkipAuthenticate).toBe(true); + expect(config.acpEnv.CURSOR_API_KEY).toBe("sk-abc"); + expect(config.acpEnv.CURSOR_AUTH_TOKEN).toBe("sk-abc"); }); - it("parses CURSOR_BRIDGE_STRICT_MODEL as boolean", () => { - setEnv("CURSOR_BRIDGE_STRICT_MODEL", "false"); - expect(loadBridgeConfig().strictModel).toBe(false); + it("allows CURSOR_BRIDGE_ACP_SKIP_AUTHENTICATE to force skip", () => { + const config = loadBridgeConfig({ + env: { + CURSOR_BRIDGE_ACP_SKIP_AUTHENTICATE: "true", + CURSOR_AGENT_BIN: "agent", + }, + cwd: "/workspace", + }); + expect(config.acpSkipAuthenticate).toBe(true); }); - it("resolves CURSOR_BRIDGE_WORKSPACE to absolute path", () => { - setEnv("CURSOR_BRIDGE_WORKSPACE", "./my-workspace"); - const config = loadBridgeConfig(); - expect(path.isAbsolute(config.workspace)).toBe(true); - expect(config.workspace).toContain("my-workspace"); + it("sets acpRawDebug when CURSOR_BRIDGE_ACP_RAW_DEBUG=1", () => { + const config = loadBridgeConfig({ + env: { CURSOR_BRIDGE_ACP_RAW_DEBUG: "1", CURSOR_AGENT_BIN: "agent" }, + cwd: "/workspace", + }); + expect(config.acpRawDebug).toBe(true); }); - it("uses CURSOR_BRIDGE_TIMEOUT_MS for timeout", () => { - setEnv("CURSOR_BRIDGE_TIMEOUT_MS", "60000"); - const config = loadBridgeConfig(); - expect(config.timeoutMs).toBe(60000); - }); + it("uses tailscale host fallback without mutating process.env", () => { + const config = loadBridgeConfig({ + env: {}, + tailscale: true, + cwd: "/workspace", + }); - it("parses CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", () => { - setEnv("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", "false"); - expect(loadBridgeConfig().chatOnlyWorkspace).toBe(false); - setEnv("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", "0"); - expect(loadBridgeConfig().chatOnlyWorkspace).toBe(false); + expect(config.host).toBe("0.0.0.0"); }); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index eeb6c5f..262e47e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,9 +1,15 @@ -import * as path from "node:path"; +import { loadEnvConfig, resolveAgentCommand, type EnvOptions } from "./env.js"; export type CursorExecutionMode = "agent" | "ask" | "plan"; export type BridgeConfig = { agentBin: string; + /** Resolved command for ACP (node + script on Windows when .cmd); avoids spawn EINVAL and DEP0190. */ + acpCommand: string; + /** Args for ACP (e.g. [scriptPath, "acp"] or ["acp"]). */ + acpArgs: string[]; + /** Env to use when spawning ACP (e.g. CURSOR_INVOKED_AS). */ + acpEnv: Record; host: string; port: number; requiredKey?: string; @@ -22,111 +28,69 @@ export type BridgeConfig = { sessionsLogPath: string; /** When true (default), run CLI in an empty temp dir so it cannot read or write the real project. Pure chat only. */ chatOnlyWorkspace: boolean; + /** When true, print full request/response content to stdout for each completion. */ + verbose: boolean; + /** When true, enable Cursor Max Mode (larger context, more tool calls) via cli-config.json preflight. */ + maxMode: boolean; + /** When true, pass the user prompt via stdin instead of argv (avoids Windows argv issues). */ + promptViaStdin: boolean; + /** When true, use ACP (Agent Client Protocol) over stdio; fixes prompt delivery on Windows. */ + useAcp: boolean; + /** Spawn options for ACP (e.g. windowsVerbatimArguments when using cmd.exe fallback). */ + acpSpawnOptions?: { windowsVerbatimArguments?: boolean }; + /** When true, skip ACP authenticate step (use when pre-authenticated via --api-key or agent login). */ + acpSkipAuthenticate: boolean; + /** When true, log every raw JSON-RPC line from ACP stdout (very verbose). Set CURSOR_BRIDGE_ACP_RAW_DEBUG=1 to enable. */ + acpRawDebug: boolean; }; -function envBool(name: string, defaultValue: boolean): boolean { - const raw = process.env[name]; - if (raw == null) return defaultValue; - const v = raw.trim().toLowerCase(); - if (v === "1" || v === "true" || v === "yes" || v === "on") return true; - if (v === "0" || v === "false" || v === "no" || v === "off") return false; - return defaultValue; -} - -function envNumber(name: string, defaultValue: number): number { - const raw = process.env[name]; - if (raw == null) return defaultValue; - const n = Number(raw); - return Number.isFinite(n) ? n : defaultValue; -} - -function normalizeMode(raw: string | undefined): CursorExecutionMode { - const m = (raw || "").trim().toLowerCase(); - if (m === "ask" || m === "plan" || m === "agent") return m; - return "ask"; -} - -function normalizeModelId(raw: string | undefined): string | undefined { - if (!raw) return undefined; - const trimmed = raw.trim(); - if (!trimmed) return undefined; - const parts = trimmed.split("/"); - return parts[parts.length - 1] || undefined; -} - -function getAgentBin(): string { - return ( - process.env.CURSOR_AGENT_BIN || - process.env.CURSOR_CLI_BIN || - process.env.CURSOR_CLI_PATH || - "agent" - ); -} - -function getHost(): string { - return process.env.CURSOR_BRIDGE_HOST || "127.0.0.1"; -} - -function getPort(): number { - const n = envNumber("CURSOR_BRIDGE_PORT", 8765); - return Number.isFinite(n) && n > 0 ? n : 8765; -} - -function getRequiredKey(): string | undefined { - return process.env.CURSOR_BRIDGE_API_KEY; -} - -function getTlsCertPath(): string | undefined { - const v = process.env.CURSOR_BRIDGE_TLS_CERT; - return v && v.trim() ? v.trim() : undefined; -} - -function getTlsKeyPath(): string | undefined { - const v = process.env.CURSOR_BRIDGE_TLS_KEY; - return v && v.trim() ? v.trim() : undefined; -} - -function getWorkspace(): string { - const raw = process.env.CURSOR_BRIDGE_WORKSPACE; - return raw ? path.resolve(raw) : process.cwd(); -} - -function getSessionsLogPath(): string { - const raw = process.env.CURSOR_BRIDGE_SESSIONS_LOG; - if (raw) return path.resolve(raw); - - const home = process.env.HOME || process.env.USERPROFILE; - if (home) { - return path.join(home, ".cursor-api-proxy", "sessions.log"); +export function loadBridgeConfig(opts: EnvOptions = {}): BridgeConfig { + const env = loadEnvConfig(opts); + const acpResolved = resolveAgentCommand(env.agentBin, ["acp"], opts); + const envSource = opts.env ?? process.env; + const apiKey = envSource.CURSOR_API_KEY ?? envSource.CURSOR_AUTH_TOKEN; + const acpArgs = acpResolved.args; + + const acpEnv = { ...acpResolved.env } as Record; + if (apiKey) { + acpEnv.CURSOR_API_KEY = apiKey; + acpEnv.CURSOR_AUTH_TOKEN = apiKey; } - return path.join(process.cwd(), "sessions.log"); -} - -function getChatOnlyWorkspace(): boolean { - const raw = process.env.CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE; - if (raw == null) return true; // default: isolate so CLI cannot touch real project - const v = raw.trim().toLowerCase(); - if (v === "0" || v === "false" || v === "no" || v === "off") return false; - return true; -} - -export function loadBridgeConfig(): BridgeConfig { return { - agentBin: getAgentBin(), - host: getHost(), - port: getPort(), - requiredKey: getRequiredKey(), - defaultModel: normalizeModelId(process.env.CURSOR_BRIDGE_DEFAULT_MODEL) || "auto", + agentBin: env.agentBin, + acpCommand: acpResolved.command, + acpArgs, + acpEnv, + host: env.host, + port: env.port, + requiredKey: env.requiredKey, + defaultModel: env.defaultModel, mode: "ask", // proxy is chat-only; CURSOR_BRIDGE_MODE is ignored - force: envBool("CURSOR_BRIDGE_FORCE", false), - approveMcps: envBool("CURSOR_BRIDGE_APPROVE_MCPS", false), - strictModel: envBool("CURSOR_BRIDGE_STRICT_MODEL", true), - workspace: getWorkspace(), - timeoutMs: envNumber("CURSOR_BRIDGE_TIMEOUT_MS", 300_000), - tlsCertPath: getTlsCertPath(), - tlsKeyPath: getTlsKeyPath(), - sessionsLogPath: getSessionsLogPath(), - chatOnlyWorkspace: getChatOnlyWorkspace(), + force: env.force, + approveMcps: env.approveMcps, + strictModel: env.strictModel, + workspace: env.workspace, + timeoutMs: env.timeoutMs, + tlsCertPath: env.tlsCertPath, + tlsKeyPath: env.tlsKeyPath, + sessionsLogPath: env.sessionsLogPath, + chatOnlyWorkspace: env.chatOnlyWorkspace, + verbose: env.verbose, + maxMode: env.maxMode, + promptViaStdin: env.promptViaStdin, + useAcp: env.useAcp, + acpSpawnOptions: + acpResolved.windowsVerbatimArguments != null + ? { windowsVerbatimArguments: acpResolved.windowsVerbatimArguments } + : undefined, + acpSkipAuthenticate: + !!apiKey || + /^(1|true|yes|on)$/i.test( + String(envSource.CURSOR_BRIDGE_ACP_SKIP_AUTHENTICATE ?? "").trim(), + ), + acpRawDebug: /^(1|true|yes|on)$/i.test( + String(envSource.CURSOR_BRIDGE_ACP_RAW_DEBUG ?? "").trim(), + ), }; } diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..b0c4f40 --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,232 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { loadEnvConfig, resolveAgentCommand } from "./env.js"; + +describe("loadEnvConfig", () => { + it("returns defaults when env is empty", () => { + const loaded = loadEnvConfig({ env: {}, cwd: "/workspace" }); + + expect(loaded.agentBin).toBe("agent"); + expect(loaded.host).toBe("127.0.0.1"); + expect(loaded.port).toBe(8765); + expect(loaded.defaultModel).toBe("auto"); + expect(loaded.force).toBe(false); + expect(loaded.approveMcps).toBe(false); + expect(loaded.strictModel).toBe(true); + expect(loaded.workspace).toBe("/workspace"); + expect(loaded.sessionsLogPath).toBe("/workspace/sessions.log"); + expect(loaded.chatOnlyWorkspace).toBe(true); + expect(loaded.verbose).toBe(false); + expect(loaded.commandShell).toBe("cmd.exe"); + }); + + it("applies env aliases with expected precedence", () => { + expect( + loadEnvConfig({ + env: { + CURSOR_CLI_PATH: "/path/from-cli-path", + CURSOR_CLI_BIN: "/path/from-cli-bin", + CURSOR_AGENT_BIN: "/path/from-agent-bin", + }, + }).agentBin, + ).toBe("/path/from-agent-bin"); + + expect( + loadEnvConfig({ + env: { + CURSOR_CLI_PATH: "/path/from-cli-path", + CURSOR_CLI_BIN: "/path/from-cli-bin", + }, + }).agentBin, + ).toBe("/path/from-cli-bin"); + }); + + it("parses booleans, numbers, and model normalization", () => { + const loaded = loadEnvConfig({ + env: { + CURSOR_BRIDGE_FORCE: "yes", + CURSOR_BRIDGE_APPROVE_MCPS: "on", + CURSOR_BRIDGE_STRICT_MODEL: "off", + CURSOR_BRIDGE_TIMEOUT_MS: "60000", + CURSOR_BRIDGE_DEFAULT_MODEL: "org/claude-3-opus", + }, + }); + + expect(loaded.force).toBe(true); + expect(loaded.approveMcps).toBe(true); + expect(loaded.strictModel).toBe(false); + expect(loaded.timeoutMs).toBe(60000); + expect(loaded.defaultModel).toBe("claude-3-opus"); + }); + + it("resolves workspace and explicit paths from cwd", () => { + const loaded = loadEnvConfig({ + env: { + CURSOR_BRIDGE_WORKSPACE: "./repo", + CURSOR_BRIDGE_SESSIONS_LOG: "./logs/sessions.log", + CURSOR_BRIDGE_TLS_CERT: "./certs/dev.crt", + CURSOR_BRIDGE_TLS_KEY: "./certs/dev.key", + }, + cwd: "/tmp/project", + }); + + expect(loaded.workspace).toBe("/tmp/project/repo"); + expect(loaded.sessionsLogPath).toBe("/tmp/project/logs/sessions.log"); + expect(loaded.tlsCertPath).toBe("/tmp/project/certs/dev.crt"); + expect(loaded.tlsKeyPath).toBe("/tmp/project/certs/dev.key"); + }); + + it("uses HOME before USERPROFILE for default sessions log path", () => { + const loaded = loadEnvConfig({ + env: { + HOME: "/home/alice", + USERPROFILE: "C:\\Users\\alice", + }, + cwd: "/tmp/project", + }); + + expect(loaded.sessionsLogPath).toBe( + path.join("/home/alice", ".cursor-api-proxy", "sessions.log"), + ); + }); + + it("uses USERPROFILE when HOME is not set", () => { + const loaded = loadEnvConfig({ + env: { + USERPROFILE: "C:\\Users\\alice", + }, + cwd: "/tmp/project", + }); + + expect(loaded.sessionsLogPath).toBe( + path.join("C:\\Users\\alice", ".cursor-api-proxy", "sessions.log"), + ); + }); + + it("applies tailscale host fallback only when host is unset", () => { + expect(loadEnvConfig({ env: {}, tailscale: true }).host).toBe("0.0.0.0"); + + expect( + loadEnvConfig({ + env: { CURSOR_BRIDGE_HOST: "10.0.0.5" }, + tailscale: true, + }).host, + ).toBe("10.0.0.5"); + }); +}); + +describe("resolveAgentCommand", () => { + it("uses CURSOR_AGENT_NODE and CURSOR_AGENT_SCRIPT on Windows", () => { + const command = resolveAgentCommand("agent.cmd", ["--print", "hello"], { + platform: "win32", + env: { + CURSOR_AGENT_NODE: "C:\\node\\node.exe", + CURSOR_AGENT_SCRIPT: "C:\\cursor\\agent.js", + }, + }); + + expect(command.command).toBe("C:\\node\\node.exe"); + expect(command.args).toEqual(["C:\\cursor\\agent.js", "--print", "hello"]); + expect(command.env.CURSOR_INVOKED_AS).toBe("agent.cmd"); + expect(command.windowsVerbatimArguments).toBeUndefined(); + }); + + it("uses COMSPEC for .cmd invocations on Windows when direct node launch is unavailable", () => { + const command = resolveAgentCommand("C:\\cursor\\agent.cmd", ["--prompt", "hello world"], { + platform: "win32", + env: { + COMSPEC: "C:\\Windows\\System32\\cmd.exe", + }, + }); + + expect(command.command).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(command.args).toEqual([ + "/d", + "/s", + "/c", + "\"\"C:\\cursor\\agent.cmd\" --prompt \"hello world\"\"", + ]); + expect(command.windowsVerbatimArguments).toBe(true); + }); + + it("returns the original command on non-Windows platforms", () => { + const command = resolveAgentCommand("agent", ["--help"], { + platform: "darwin", + env: { CURSOR_AGENT_NODE: "/ignored/node" }, + }); + + expect(command.command).toBe("agent"); + expect(command.args).toEqual(["--help"]); + expect(command.windowsVerbatimArguments).toBeUndefined(); + }); + + it("uses versioned layout (versions/YYYY.MM.DD-commit) when node.exe/index.js not in agent dir", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-agent-")); + try { + const agentCmd = path.join(tmp, "agent.cmd"); + const versionDir = path.join(tmp, "versions", "2026.03.11-6dfa30c"); + fs.mkdirSync(versionDir, { recursive: true }); + fs.writeFileSync(path.join(versionDir, "node.exe"), ""); + fs.writeFileSync(path.join(versionDir, "index.js"), ""); + fs.writeFileSync(agentCmd, ""); + + const command = resolveAgentCommand(agentCmd, ["acp"], { + platform: "win32", + env: {}, + cwd: tmp, + }); + + expect(command.command).toBe(path.join(versionDir, "node.exe")); + expect(command.args).toEqual([path.join(versionDir, "index.js"), "acp"]); + expect(command.windowsVerbatimArguments).toBeUndefined(); + expect(command.env.CURSOR_INVOKED_AS).toBe("agent.cmd"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("falls back to cmd when versions dir does not exist", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-agent-")); + try { + const agentCmd = path.join(tmp, "agent.cmd"); + fs.writeFileSync(agentCmd, ""); + + const command = resolveAgentCommand(agentCmd, ["acp"], { + platform: "win32", + env: { COMSPEC: "C:\\Windows\\System32\\cmd.exe" }, + cwd: tmp, + }); + + expect(command.command).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(command.windowsVerbatimArguments).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("falls back to cmd when versions dir has no valid version subdirs", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-agent-")); + try { + const agentCmd = path.join(tmp, "agent.cmd"); + const versionsDir = path.join(tmp, "versions"); + fs.mkdirSync(versionsDir, { recursive: true }); + fs.writeFileSync(agentCmd, ""); + fs.mkdirSync(path.join(versionsDir, "not-a-version"), { recursive: true }); + + const command = resolveAgentCommand(agentCmd, ["acp"], { + platform: "win32", + env: { COMSPEC: "C:\\Windows\\System32\\cmd.exe" }, + cwd: tmp, + }); + + expect(command.command).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(command.windowsVerbatimArguments).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..66681b2 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,248 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export type EnvSource = Record; + +export type EnvOptions = { + tailscale?: boolean; + env?: EnvSource; + cwd?: string; + platform?: NodeJS.Platform; +}; + +export type LoadedEnv = { + agentBin: string; + agentNode?: string; + agentScript?: string; + commandShell: string; + host: string; + port: number; + requiredKey?: string; + defaultModel: string; + force: boolean; + approveMcps: boolean; + strictModel: boolean; + workspace: string; + timeoutMs: number; + tlsCertPath?: string; + tlsKeyPath?: string; + sessionsLogPath: string; + chatOnlyWorkspace: boolean; + verbose: boolean; + /** When true, set maxMode in cli-config.json before each run (larger context, more tools). */ + maxMode: boolean; + /** When true, pass the user prompt via stdin instead of argv (avoids Windows argv truncation). */ + promptViaStdin: boolean; + /** When true, use ACP (Agent Client Protocol) over stdio instead of CLI argv (fixes prompt delivery on Windows). */ + useAcp: boolean; +}; + +export type AgentCommand = { + command: string; + args: string[]; + env: EnvSource; + windowsVerbatimArguments?: boolean; + /** Path to agent entry script (e.g. index.js). Set when using node+script so max-mode preflight can find config. */ + agentScriptPath?: string; + /** Cursor config dir (cli-config.json). Set so CLI reads the same config preflight wrote to. */ + configDir?: string; +}; + +function getEnvSource(env?: EnvSource): EnvSource { + return env ?? process.env; +} + +function getCwd(cwd?: string): string { + return cwd ?? process.cwd(); +} + +function firstDefined(env: EnvSource, names: string[]): string | undefined { + for (const name of names) { + const value = env[name]; + if (value != null) return value; + } + return undefined; +} + +function envString(env: EnvSource, names: string[]): string | undefined { + const value = firstDefined(env, names); + if (value == null) return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function envBool(env: EnvSource, names: string[], defaultValue: boolean): boolean { + const raw = envString(env, names); + if (raw == null) return defaultValue; + const value = raw.toLowerCase(); + if (value === "1" || value === "true" || value === "yes" || value === "on") return true; + if (value === "0" || value === "false" || value === "no" || value === "off") return false; + return defaultValue; +} + +function envNumber(env: EnvSource, names: string[], defaultValue: number): number { + const raw = envString(env, names); + if (raw == null) return defaultValue; + const value = Number(raw); + return Number.isFinite(value) ? value : defaultValue; +} + +function normalizeModelId(raw: string | undefined): string { + if (!raw) return "auto"; + const parts = raw.split("/"); + return parts[parts.length - 1] || "auto"; +} + +function resolveAbsolutePath(raw: string | undefined, cwd: string): string | undefined { + if (!raw) return undefined; + return path.resolve(cwd, raw); +} + +/** Version dir name format: YYYY.MM.DD-commit (matches cursor-agent.ps1). */ +const VERSION_DIR_REGEX = /^(\d{4})\.(\d{1,2})\.(\d{1,2})-[a-f0-9]+$/; + +function parseVersionToInt(name: string): number { + const m = name.match(VERSION_DIR_REGEX); + if (!m) return 0; + const [, year, month, day] = m; + const y = year!.padStart(4, "0"); + const mo = month!.padStart(2, "0"); + const d = day!.padStart(2, "0"); + return parseInt(y + mo + d, 10); +} + +/** + * Find the latest version directory under dir/versions/ (e.g. cursor-agent/versions/2026.03.11-6dfa30c). + * Returns the full path to the version dir, or undefined if none found. + */ +function findLatestVersionDir(dir: string): string | undefined { + const versionsDir = path.join(dir, "versions"); + if (!fs.existsSync(versionsDir) || !fs.statSync(versionsDir).isDirectory()) { + return undefined; + } + const entries = fs.readdirSync(versionsDir, { withFileTypes: true }); + const versionDirs = entries + .filter((e) => e.isDirectory() && VERSION_DIR_REGEX.test(e.name)) + .sort((a, b) => parseVersionToInt(b.name) - parseVersionToInt(a.name)); + if (versionDirs.length === 0) return undefined; + return path.join(versionsDir, versionDirs[0]!.name); +} + +export function loadEnvConfig(opts: EnvOptions = {}): LoadedEnv { + const env = getEnvSource(opts.env); + const cwd = getCwd(opts.cwd); + + const host = envString(env, ["CURSOR_BRIDGE_HOST"]) ?? (opts.tailscale ? "0.0.0.0" : "127.0.0.1"); + const portValue = envNumber(env, ["CURSOR_BRIDGE_PORT"], 8765); + const port = Number.isFinite(portValue) && portValue > 0 ? portValue : 8765; + + const sessionsLogPath = (() => { + const explicit = resolveAbsolutePath( + envString(env, ["CURSOR_BRIDGE_SESSIONS_LOG"]), + cwd, + ); + if (explicit) return explicit; + + const home = envString(env, ["HOME", "USERPROFILE"]); + if (home) return path.join(home, ".cursor-api-proxy", "sessions.log"); + + return path.join(cwd, "sessions.log"); + })(); + + return { + agentBin: + envString(env, ["CURSOR_AGENT_BIN", "CURSOR_CLI_BIN", "CURSOR_CLI_PATH"]) ?? "agent", + agentNode: envString(env, ["CURSOR_AGENT_NODE"]), + agentScript: envString(env, ["CURSOR_AGENT_SCRIPT"]), + commandShell: envString(env, ["COMSPEC"]) ?? "cmd.exe", + host, + port, + requiredKey: envString(env, ["CURSOR_BRIDGE_API_KEY"]), + defaultModel: normalizeModelId(envString(env, ["CURSOR_BRIDGE_DEFAULT_MODEL"])), + force: envBool(env, ["CURSOR_BRIDGE_FORCE"], false), + approveMcps: envBool(env, ["CURSOR_BRIDGE_APPROVE_MCPS"], false), + strictModel: envBool(env, ["CURSOR_BRIDGE_STRICT_MODEL"], true), + workspace: + resolveAbsolutePath(envString(env, ["CURSOR_BRIDGE_WORKSPACE"]), cwd) ?? cwd, + timeoutMs: envNumber(env, ["CURSOR_BRIDGE_TIMEOUT_MS"], 300_000), + tlsCertPath: resolveAbsolutePath(envString(env, ["CURSOR_BRIDGE_TLS_CERT"]), cwd), + tlsKeyPath: resolveAbsolutePath(envString(env, ["CURSOR_BRIDGE_TLS_KEY"]), cwd), + sessionsLogPath, + chatOnlyWorkspace: envBool(env, ["CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"], true), + verbose: envBool(env, ["CURSOR_BRIDGE_VERBOSE"], false), + maxMode: envBool(env, ["CURSOR_BRIDGE_MAX_MODE"], false), + promptViaStdin: envBool(env, ["CURSOR_BRIDGE_PROMPT_VIA_STDIN"], false), + useAcp: envBool(env, ["CURSOR_BRIDGE_USE_ACP"], false), + }; +} + +export function resolveAgentCommand( + cmd: string, + args: string[], + opts: EnvOptions = {}, +): AgentCommand { + const env = getEnvSource(opts.env); + const loaded = loadEnvConfig(opts); + const platform = opts.platform ?? process.platform; + const cwd = getCwd(opts.cwd); + + if (platform === "win32") { + if (loaded.agentNode && loaded.agentScript) { + const agentScriptPath = path.isAbsolute(loaded.agentScript) + ? loaded.agentScript + : path.resolve(cwd, loaded.agentScript); + const agentDir = path.dirname(agentScriptPath); + const configDir = path.join(agentDir, "..", "data", "config"); + const out: AgentCommand = { + command: loaded.agentNode, + args: [loaded.agentScript, ...args], + env: { ...env, CURSOR_INVOKED_AS: "agent.cmd" }, + agentScriptPath, + configDir: fs.existsSync(path.join(configDir, "cli-config.json")) ? configDir : undefined, + }; + return out; + } + + if (/\.cmd$/i.test(cmd)) { + const cmdResolved = path.resolve(cwd, cmd); + const dir = path.dirname(cmdResolved); + const nodeBin = path.join(dir, "node.exe"); + const script = path.join(dir, "index.js"); + if (fs.existsSync(nodeBin) && fs.existsSync(script)) { + const configDir = path.join(dir, "..", "data", "config"); + return { + command: nodeBin, + args: [script, ...args], + env: { ...env, CURSOR_INVOKED_AS: "agent.cmd" }, + agentScriptPath: script, + configDir: fs.existsSync(path.join(configDir, "cli-config.json")) ? configDir : undefined, + }; + } + const versionDir = findLatestVersionDir(dir); + if (versionDir) { + const versionNode = path.join(versionDir, "node.exe"); + const versionScript = path.join(versionDir, "index.js"); + if (fs.existsSync(versionNode) && fs.existsSync(versionScript)) { + const configDir = path.join(dir, "..", "data", "config"); + return { + command: versionNode, + args: [versionScript, ...args], + env: { ...env, CURSOR_INVOKED_AS: "agent.cmd" }, + agentScriptPath: versionScript, + configDir: fs.existsSync(path.join(configDir, "cli-config.json")) ? configDir : undefined, + }; + } + } + const quotedArgs = args.map((arg) => (arg.includes(" ") ? `"${arg}"` : arg)).join(" "); + const cmdLine = `""${cmd}" ${quotedArgs}"`; + return { + command: loaded.commandShell, + args: ["/d", "/s", "/c", cmdLine], + env, + windowsVerbatimArguments: true, + }; + } + } + + return { command: cmd, args, env }; +} diff --git a/src/lib/handlers/anthropic-messages.ts b/src/lib/handlers/anthropic-messages.ts index b8e0f9f..a312291 100644 --- a/src/lib/handlers/anthropic-messages.ts +++ b/src/lib/handlers/anthropic-messages.ts @@ -5,12 +5,17 @@ import type { AnthropicMessagesRequest } from "../anthropic.js"; import { buildPromptFromAnthropicMessages } from "../anthropic.js"; import { buildAgentCmdArgs } from "../agent-cmd-args.js"; import { runAgentStream, runAgentSync } from "../agent-runner.js"; -import { parseCliStreamLine } from "../cli-stream-parser.js"; +import { createStreamParser } from "../cli-stream-parser.js"; import type { BridgeConfig } from "../config.js"; import { json, writeSseHeaders } from "../http.js"; import { resolveToCursorModel } from "../model-map.js"; import { normalizeModelId } from "../openai.js"; -import { logAgentError } from "../request-log.js"; +import { + logAgentError, + logTrafficRequest, + logTrafficResponse, + type TrafficMessage, +} from "../request-log.js"; import { resolveModel } from "../resolve-model.js"; import { resolveWorkspace } from "../workspace.js"; @@ -32,6 +37,11 @@ export async function handleAnthropicMessages( const body = JSON.parse(rawBody || "{}") as AnthropicMessagesRequest; const requested = normalizeModelId(body.model); const model = resolveModel(requested, lastRequestedModelRef, config); + // When request is "auto", use defaultModel for response display (dashboard) if set; else echo "auto" + const displayModel = + requested === "auto" && config.defaultModel !== "auto" + ? config.defaultModel + : model; if (body.max_tokens == null || typeof body.max_tokens !== "number") { json(res, 400, { @@ -46,6 +56,35 @@ export async function handleAnthropicMessages( const cursorModel = resolveToCursorModel(model) ?? model; const prompt = buildPromptFromAnthropicMessages(body.messages, body.system); + const trafficMessages: TrafficMessage[] = []; + if (body.system) { + const sys = + typeof body.system === "string" + ? body.system + : (body.system as Array<{ type?: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("\n"); + if (sys.trim()) + trafficMessages.push({ role: "system", content: sys.trim() }); + } + for (const m of body.messages ?? []) { + const text = + typeof m.content === "string" + ? m.content + : (m.content as Array<{ type?: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + if (text) trafficMessages.push({ role: m.role, content: text }); + } + logTrafficRequest( + config.verbose, + model ?? cursorModel, + trafficMessages, + !!body.stream, + ); + const headerWs = req.headers["x-cursor-workspace"]; const { workspaceDir, tempDir } = resolveWorkspace(config, headerWs); @@ -72,7 +111,7 @@ export async function handleAnthropicMessages( id: msgId, type: "message", role: "assistant", - model: model ?? cursorModel, + model: displayModel ?? cursorModel, content: [], }, }); @@ -82,25 +121,39 @@ export async function handleAnthropicMessages( content_block: { type: "text", text: "" }, }); - runAgentStream(config, workspaceDir, cmdArgs, (line) => { - parseCliStreamLine( - line, - (text) => - writeEvent({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text }, - }), - () => { - writeEvent({ type: "content_block_stop", index: 0 }); - writeEvent({ - type: "message_delta", - delta: { stop_reason: "end_turn" }, - }); - writeEvent({ type: "message_stop" }); - }, - ); - }, tempDir) + let accumulated = ""; + const parseLine = createStreamParser( + (text) => { + accumulated += text; + writeEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text }, + }); + }, + () => { + logTrafficResponse( + config.verbose, + model ?? cursorModel, + accumulated, + true, + ); + writeEvent({ type: "content_block_stop", index: 0 }); + writeEvent({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + }); + writeEvent({ type: "message_stop" }); + }, + ); + runAgentStream( + config, + workspaceDir, + cmdArgs, + parseLine, + tempDir, + (config.promptViaStdin || config.useAcp) ? prompt : undefined, + ) .then(({ code, stderr: stderrOut }) => { if (code !== 0) { logAgentError( @@ -121,7 +174,13 @@ export async function handleAnthropicMessages( return; } - const out = await runAgentSync(config, workspaceDir, cmdArgs, tempDir); + const out = await runAgentSync( + config, + workspaceDir, + cmdArgs, + tempDir, + (config.promptViaStdin || config.useAcp) ? prompt : undefined, + ); if (out.code !== 0) { const errMsg = logAgentError( @@ -139,12 +198,13 @@ export async function handleAnthropicMessages( } const content = out.stdout.trim(); + logTrafficResponse(config.verbose, model ?? cursorModel, content, false); json(res, 200, { id: msgId, type: "message", role: "assistant", content: [{ type: "text", text: content }], - model: model ?? cursorModel, + model: displayModel ?? cursorModel, stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 }, }); diff --git a/src/lib/handlers/chat-completions.ts b/src/lib/handlers/chat-completions.ts index 106aa5a..8d0bc61 100644 --- a/src/lib/handlers/chat-completions.ts +++ b/src/lib/handlers/chat-completions.ts @@ -4,7 +4,7 @@ import * as http from "node:http"; import type { BridgeConfig } from "../config.js"; import { buildAgentCmdArgs } from "../agent-cmd-args.js"; import { runAgentStream, runAgentSync } from "../agent-runner.js"; -import { parseCliStreamLine } from "../cli-stream-parser.js"; +import { createStreamParser } from "../cli-stream-parser.js"; import { json, writeSseHeaders } from "../http.js"; import { resolveToCursorModel } from "../model-map.js"; import { @@ -12,7 +12,12 @@ import { normalizeModelId, type OpenAiChatCompletionRequest, } from "../openai.js"; -import { logAgentError } from "../request-log.js"; +import { + logAgentError, + logTrafficRequest, + logTrafficResponse, + type TrafficMessage, +} from "../request-log.js"; import { resolveModel } from "../resolve-model.js"; import { resolveWorkspace } from "../workspace.js"; @@ -35,8 +40,34 @@ export async function handleChatCompletions( const requested = normalizeModelId(body.model); const model = resolveModel(requested, lastRequestedModelRef, config); const cursorModel = resolveToCursorModel(model) ?? model; + // When request is "auto", use defaultModel for response display (dashboard) if set; else echo "auto" + const displayModel = + requested === "auto" && config.defaultModel !== "auto" + ? config.defaultModel + : model; const prompt = buildPromptFromMessages(body.messages ?? []); + const trafficMessages: TrafficMessage[] = (body.messages ?? []).map( + (m: any) => { + const content = + typeof m?.content === "string" + ? m.content + : Array.isArray(m?.content) + ? (m.content as Array<{ type?: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("") + : ""; + return { role: String(m?.role ?? "user"), content }; + }, + ); + logTrafficRequest( + config.verbose, + model ?? cursorModel, + trafficMessages, + !!body.stream, + ); + const headerWs = req.headers["x-cursor-workspace"]; const { workspaceDir, tempDir } = resolveWorkspace(config, headerWs); @@ -51,39 +82,127 @@ export async function handleChatCompletions( const id = `chatcmpl_${randomUUID().replace(/-/g, "")}`; const created = Math.floor(Date.now() / 1000); + const promptForAgent = (config.promptViaStdin || config.useAcp) ? prompt : undefined; + if (body.stream) { writeSseHeaders(res); - runAgentStream(config, workspaceDir, cmdArgs, (line) => { - parseCliStreamLine( - line, - (text) => { + if (config.useAcp && typeof promptForAgent === "string") { + let accumulated = ""; + runAgentStream( + config, + workspaceDir, + cmdArgs, + (chunk) => { + accumulated += chunk; res.write( `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, - model, + model: displayModel, choices: [ - { index: 0, delta: { content: text }, finish_reason: null }, + { index: 0, delta: { content: chunk }, finish_reason: null }, ], })}\n\n`, ); }, - () => { + tempDir, + promptForAgent, + ) + .then(({ code, stderr: stderrOut }) => { + if (code !== 0) { + logAgentError( + config.sessionsLogPath, + method, + pathname, + remoteAddress, + code, + stderrOut, + ); + } + logTrafficResponse( + config.verbose, + model ?? cursorModel, + accumulated, + true, + ); + const promptTokens = Math.max(1, Math.round(prompt.length / 4)); + const completionTokens = Math.max(1, Math.round(accumulated.length / 4)); res.write( `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, - model, + model: displayModel, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, })}\n\n`, ); res.write("data: [DONE]\n\n"); - }, - ); - }, tempDir) + res.end(); + }) + .catch((err) => { + console.error(`[${new Date().toISOString()}] Agent stream error:`, err); + res.end(); + }); + return; + } + + let accumulated = ""; + const parseLine = createStreamParser( + (text) => { + accumulated += text; + res.write( + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model: displayModel, + choices: [ + { index: 0, delta: { content: text }, finish_reason: null }, + ], + })}\n\n`, + ); + }, + () => { + logTrafficResponse( + config.verbose, + model ?? cursorModel, + accumulated, + true, + ); + const promptTokens = Math.max(1, Math.round(prompt.length / 4)); + const completionTokens = Math.max(1, Math.round(accumulated.length / 4)); + res.write( + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model: displayModel, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + })}\n\n`, + ); + res.write("data: [DONE]\n\n"); + }, + ); + runAgentStream( + config, + workspaceDir, + cmdArgs, + parseLine, + tempDir, + promptForAgent, + ) .then(({ code, stderr: stderrOut }) => { if (code !== 0) { logAgentError( @@ -104,7 +223,13 @@ export async function handleChatCompletions( return; } - const out = await runAgentSync(config, workspaceDir, cmdArgs, tempDir); + const out = await runAgentSync( + config, + workspaceDir, + cmdArgs, + tempDir, + promptForAgent, + ); if (out.code !== 0) { const errMsg = logAgentError( @@ -122,11 +247,18 @@ export async function handleChatCompletions( } const content = out.stdout.trim(); + logTrafficResponse(config.verbose, model ?? cursorModel, content, false); + + // Estimate tokens (chars/4 heuristic; Cursor CLI does not expose usage) + const promptTokens = Math.max(1, Math.round(prompt.length / 4)); + const completionTokens = Math.max(1, Math.round(content.length / 4)); + const totalTokens = promptTokens + completionTokens; + json(res, 200, { id, object: "chat.completion", created, - model, + model: displayModel, choices: [ { index: 0, @@ -134,6 +266,10 @@ export async function handleChatCompletions( finish_reason: "stop", }, ], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens, + }, }); } diff --git a/src/lib/max-mode-preflight.ts b/src/lib/max-mode-preflight.ts new file mode 100644 index 0000000..fb8b467 --- /dev/null +++ b/src/lib/max-mode-preflight.ts @@ -0,0 +1,61 @@ +/** + * Sets maxMode=true in Cursor CLI's cli-config.json before spawning the agent. + * Config resolution order (same as Cursor CLI): + * 1. CURSOR_CONFIG_DIR/cli-config.json + * 2. /../data/config/cli-config.json (CursorToolkit layout) + * 3. Platform default (LOCALAPPDATA / Library / XDG) + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +function getCandidates(agentScriptPath?: string): string[] { + const result: string[] = []; + + if (process.env.CURSOR_CONFIG_DIR) { + result.push(path.join(process.env.CURSOR_CONFIG_DIR, "cli-config.json")); + } + + if (agentScriptPath) { + const agentDir = path.dirname(path.resolve(agentScriptPath)); + result.push(path.join(agentDir, "..", "data", "config", "cli-config.json")); + } + + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + + if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"); + result.push(path.join(local, "cursor-agent", "cli-config.json")); + } else if (process.platform === "darwin") { + result.push( + path.join(home, "Library", "Application Support", "cursor-agent", "cli-config.json"), + ); + } else { + const xdg = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"); + result.push(path.join(xdg, "cursor-agent", "cli-config.json")); + } + + return result; +} + +/** + * Write maxMode: true to the first writable cli-config.json. + * Best-effort: ignores errors (e.g. missing or read-only config). + */ +export function runMaxModePreflight(agentScriptPath?: string): void { + for (const candidate of getCandidates(agentScriptPath)) { + try { + const rawStr = fs.readFileSync(candidate, "utf-8"); + const raw = JSON.parse(rawStr.replace(/^\uFEFF/, "")) as Record; + if (!raw || typeof raw !== "object" || Object.keys(raw).length <= 1) continue; + + raw.maxMode = true; + if (typeof raw.model === "object" && raw.model && raw.model !== null) { + (raw.model as Record).maxMode = true; + } + fs.writeFileSync(candidate, JSON.stringify(raw, null, 2), "utf-8"); + return; + } catch { + /* candidate not found or unreadable — try next */ + } + } +} diff --git a/src/lib/process.test.ts b/src/lib/process.test.ts new file mode 100644 index 0000000..bd86668 --- /dev/null +++ b/src/lib/process.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { run, runStreaming } from "./process.js"; + +const node = process.execPath; + +describe("run", () => { + it("returns stdout and stderr", async () => { + const result = await run(node, [ + "-e", + "console.log('hello'); console.error('world')", + ]); + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe("hello"); + expect(result.stderr.trim()).toBe("world"); + }); + + it("uses spawnChild on all platforms (non-Windows uses normal spawn path)", async () => { + const result = await run(node, ["-e", "process.stdout.write('ok')"]); + expect(result.code).toBe(0); + expect(result.stdout).toBe("ok"); + }); + + it("passes stdinContent to child stdin", async () => { + const result = await run(node, ["-e", "process.stdin.on('data', d => process.stdout.write(d))"], { + stdinContent: "hello from stdin", + }); + expect(result.code).toBe(0); + expect(result.stdout).toBe("hello from stdin"); + }); +}); + +describe("runStreaming", () => { + it("calls onLine for each line of stdout", async () => { + const onLine = vi.fn(); + const result = await runStreaming( + node, + ["-e", "console.log('a'); console.log('b'); console.log('c')"], + { + onLine, + }, + ); + expect(result.code).toBe(0); + expect(onLine).toHaveBeenCalledTimes(3); + expect(onLine).toHaveBeenNthCalledWith(1, "a"); + expect(onLine).toHaveBeenNthCalledWith(2, "b"); + expect(onLine).toHaveBeenNthCalledWith(3, "c"); + }); + + it("passes lines to parser (createStreamParser-compatible shape)", async () => { + const lines: string[] = []; + const onLine = (line: string) => lines.push(line); + await runStreaming(node, [ + "-e", + `console.log('{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}'); + console.log('{"type":"result","subtype":"success"}');`, + ], { onLine }); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]).type).toBe("assistant"); + expect(JSON.parse(lines[1]).type).toBe("result"); + }); + + it("flushes the final buffered line even without a trailing newline", async () => { + const onLine = vi.fn(); + + const result = await runStreaming(node, ["-e", "process.stdout.write('tail')"], { + onLine, + }); + + expect(result.code).toBe(0); + expect(onLine).toHaveBeenCalledTimes(1); + expect(onLine).toHaveBeenCalledWith("tail"); + }); +}); diff --git a/src/lib/process.ts b/src/lib/process.ts index 0311add..d0ee5e5 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { resolveAgentCommand } from "./env.js"; +import { runMaxModePreflight } from "./max-mode-preflight.js"; export type RunResult = { code: number; @@ -9,22 +11,64 @@ export type RunResult = { export type RunOptions = { cwd?: string; timeoutMs?: number; + /** Enable Cursor Max Mode (preflight writes maxMode to cli-config.json). */ + maxMode?: boolean; + /** When set, pass this string to the child process stdin and close it (avoids long prompt in argv on Windows). */ + stdinContent?: string; + /** Env overrides for the child (e.g. HOME, CURSOR_CONFIG_DIR to isolate from global rules). */ + envOverrides?: Record; }; export type RunStreamingOptions = RunOptions & { onLine: (line: string) => void; }; +function spawnChild( + cmd: string, + args: string[], + opts?: { cwd?: string; maxMode?: boolean; stdinContent?: string; envOverrides?: Record }, +) { + const resolved = resolveAgentCommand(cmd, args); + + if (opts?.maxMode && resolved.agentScriptPath) { + runMaxModePreflight(resolved.agentScriptPath); + } + + const env = { ...resolved.env }; + if (resolved.configDir && !env.CURSOR_CONFIG_DIR) { + env.CURSOR_CONFIG_DIR = resolved.configDir; + } + if (opts?.envOverrides) { + Object.assign(env, opts.envOverrides); + } + + const useStdin = typeof opts?.stdinContent === "string"; + const child = spawn(resolved.command, resolved.args, { + cwd: opts?.cwd, + env, + stdio: useStdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: resolved.windowsVerbatimArguments, + }); + + if (useStdin && opts.stdinContent !== undefined && child.stdin) { + child.stdin.write(opts.stdinContent, "utf8"); + child.stdin.end(); + } + + return child; +} + export function runStreaming( cmd: string, args: string[], opts: RunStreamingOptions, ): Promise<{ code: number; stderr: string }> { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { + const child = spawnChild(cmd, args, { cwd: opts.cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], + maxMode: opts.maxMode, + stdinContent: opts.stdinContent, + envOverrides: opts.envOverrides, }); const timeoutMs = opts.timeoutMs; @@ -38,11 +82,11 @@ export function runStreaming( let stderr = ""; let lineBuffer = ""; - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (c) => (stderr += c)); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (c) => (stderr += c)); - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { + child.stdout!.setEncoding("utf8"); + child.stdout!.on("data", (chunk: string) => { lineBuffer += chunk; const lines = lineBuffer.split("\n"); lineBuffer = lines.pop() ?? ""; @@ -74,10 +118,11 @@ export function runStreaming( export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { + const child = spawnChild(cmd, args, { cwd: opts.cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], + maxMode: opts.maxMode, + stdinContent: opts.stdinContent, + envOverrides: opts.envOverrides, }); const timeoutMs = opts.timeoutMs; @@ -91,10 +136,10 @@ export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise let stdout = ""; let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (c) => (stdout += c)); - child.stderr.on("data", (c) => (stderr += c)); + child.stdout!.setEncoding("utf8"); + child.stderr!.setEncoding("utf8"); + child.stdout!.on("data", (c) => (stdout += c)); + child.stderr!.on("data", (c) => (stderr += c)); child.on("error", (err: NodeJS.ErrnoException) => { if (timeout) clearTimeout(timeout); diff --git a/src/lib/request-log.ts b/src/lib/request-log.ts index 9fd61cf..c97d22a 100644 --- a/src/lib/request-log.ts +++ b/src/lib/request-log.ts @@ -1,8 +1,112 @@ import * as fs from "node:fs"; import * as path from "node:path"; -export function logIncoming(method: string, pathname: string, remoteAddress: string): void { - console.log(`[${new Date().toISOString()}] Incoming: ${method} ${pathname} (from ${remoteAddress})`); +export function logIncoming( + method: string, + pathname: string, + remoteAddress: string, +): void { + console.log( + `[${new Date().toISOString()}] Incoming: ${method} ${pathname} (from ${remoteAddress})`, + ); +} + +export type TrafficMessage = { role: string; content: string }; + +// ANSI color helpers +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + bCyan: "\x1b[1;96m", + green: "\x1b[32m", + bGreen: "\x1b[1;92m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", + bMagenta: "\x1b[1;95m", + red: "\x1b[31m", + gray: "\x1b[90m", + white: "\x1b[97m", +}; + +const ROLE_STYLE: Record = { + system: C.yellow, + user: C.cyan, + assistant: C.green, +}; + +const ROLE_EMOJI: Record = { + system: "🔧", + user: "👤", + assistant: "🤖", +}; + +function ts(): string { + return `${C.gray}${new Date().toISOString()}${C.reset}`; +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + const head = Math.floor(max * 0.6); + const tail = max - head; + const omitted = s.length - head - tail; + return ( + s.slice(0, head) + + `${C.dim} … (${omitted} chars omitted) … ` + + s.slice(s.length - tail) + + C.reset + ); +} + +function hr(char = "─", len = 60): string { + return C.gray + char.repeat(len) + C.reset; +} + +export function logTrafficRequest( + verbose: boolean, + model: string, + messages: TrafficMessage[], + isStream: boolean, +): void { + if (!verbose) return; + const modeTag = isStream + ? `${C.bCyan}⚡ stream${C.reset}` + : `${C.dim}sync${C.reset}`; + const modelStr = `${C.bMagenta}✦ ${model}${C.reset}`; + console.log(hr()); + console.log( + `${ts()} 📤 ${C.bCyan}${C.bold}REQUEST${C.reset} ${modelStr} ${modeTag}`, + ); + for (const m of messages) { + const roleColor = ROLE_STYLE[m.role] ?? C.white; + const emoji = ROLE_EMOJI[m.role] ?? "💬"; + const label = `${roleColor}${C.bold}[${m.role}]${C.reset}`; + const charCount = `${C.dim}(${m.content.length} chars)${C.reset}`; + const preview = truncate(m.content.replace(/\n/g, "↵ "), 280); + console.log(` ${emoji} ${label} ${charCount}`); + console.log(` ${C.dim}${preview}${C.reset}`); + } +} + +export function logTrafficResponse( + verbose: boolean, + model: string, + text: string, + isStream: boolean, +): void { + if (!verbose) return; + const modeTag = isStream + ? `${C.bGreen}⚡ stream${C.reset}` + : `${C.dim}sync${C.reset}`; + const modelStr = `${C.bMagenta}✦ ${model}${C.reset}`; + const charCount = `${C.bold}${text.length}${C.reset}${C.dim} chars${C.reset}`; + const preview = truncate(text.replace(/\n/g, "↵ "), 480); + console.log( + `${ts()} 📥 ${C.bGreen}${C.bold}RESPONSE${C.reset} ${modelStr} ${modeTag} ${charCount}`, + ); + console.log(` 🤖 ${C.green}${preview}${C.reset}`); + console.log(hr("─", 60)); } export function appendSessionLine( diff --git a/src/lib/resolve-model.ts b/src/lib/resolve-model.ts index 06ef1ac..4008437 100644 --- a/src/lib/resolve-model.ts +++ b/src/lib/resolve-model.ts @@ -9,14 +9,16 @@ export function resolveModel( lastRequestedModelRef: { current?: string }, config: BridgeConfig, ): string { - const explicitModel = - requested && requested !== "auto" ? requested : undefined; + const isAuto = requested === "auto"; + const explicitModel = requested && !isAuto ? requested : undefined; if (explicitModel) lastRequestedModelRef.current = explicitModel; + // "auto" is a valid Cursor model identifier — pass it through directly + if (isAuto) return "auto"; + return ( explicitModel ?? (config.strictModel ? lastRequestedModelRef.current : undefined) ?? - requested ?? lastRequestedModelRef.current ?? config.defaultModel ); diff --git a/src/lib/server.test.ts b/src/lib/server.test.ts index 84a6f42..eddca70 100644 --- a/src/lib/server.test.ts +++ b/src/lib/server.test.ts @@ -19,10 +19,12 @@ vi.mock("./process.js", () => ({ runStreaming: vi.fn().mockImplementation((_cmd, _args, opts) => { // Simulate streaming response if (opts.onLine) { - opts.onLine(JSON.stringify({ - type: "assistant", - message: { content: [{ type: "text", text: "Hello" }] }, - })); + opts.onLine( + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hello" }] }, + }), + ); opts.onLine(JSON.stringify({ type: "result", subtype: "success" })); } return Promise.resolve({ code: 0, stderr: "" }); @@ -31,6 +33,9 @@ vi.mock("./process.js", () => ({ vi.mock("./request-log.js", () => ({ logIncoming: vi.fn(), + logTrafficRequest: vi.fn(), + logTrafficResponse: vi.fn(), + logAgentError: vi.fn().mockReturnValue("agent error"), appendSessionLine: vi.fn(), })); @@ -39,6 +44,9 @@ const tmpLogPath = "/tmp/cursor-proxy-test-sessions.log"; function createTestConfig(overrides: Partial = {}): BridgeConfig { return { agentBin: "agent", + acpCommand: "agent", + acpArgs: ["acp"], + acpEnv: {}, host: "127.0.0.1", port: 0, // Let OS assign a free port defaultModel: "auto", @@ -50,6 +58,12 @@ function createTestConfig(overrides: Partial = {}): BridgeConfig { timeoutMs: 30_000, sessionsLogPath: tmpLogPath, chatOnlyWorkspace: true, + verbose: false, + maxMode: false, + promptViaStdin: false, + useAcp: false, + acpSkipAuthenticate: false, + acpRawDebug: false, ...overrides, }; } @@ -57,7 +71,11 @@ function createTestConfig(overrides: Partial = {}): BridgeConfig { async function fetchServer( server: http.Server, path: string, - options: { method?: string; body?: string; headers?: Record } = {}, + options: { + method?: string; + body?: string; + headers?: Record; + } = {}, ): Promise<{ status: number; body: string }> { const port = (server.address() as { port: number })?.port; const url = `http://127.0.0.1:${port}${path}`; @@ -100,7 +118,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig(), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status, body } = await fetchServer(server, "/health"); expect(status).toBe(200); @@ -115,7 +135,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig(), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status, body } = await fetchServer(server, "/v1/models"); expect(status).toBe(200); @@ -130,7 +152,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig({ requiredKey: "sk-secret" }), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status, body } = await fetchServer(server, "/health"); expect(status).toBe(401); @@ -143,7 +167,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig({ requiredKey: "sk-secret" }), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status } = await fetchServer(server, "/health", { headers: { Authorization: "Bearer sk-secret" }, @@ -156,7 +182,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig(), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status, body } = await fetchServer(server, "/unknown"); expect(status).toBe(404); @@ -169,7 +197,9 @@ describe("startBridgeServer", () => { version: "0.1.0", config: createTestConfig(), }); - await new Promise((resolve) => server.on("listening", () => resolve())); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); const { status, body } = await fetchServer(server, "/v1/chat/completions", { method: "POST", @@ -183,4 +213,46 @@ describe("startBridgeServer", () => { expect(data.object).toBe("chat.completion"); expect(data.choices[0].message.content).toBe("Hello from agent"); }); + + it("returns display model when request is auto and defaultModel is set", async () => { + server = startBridgeServer({ + version: "0.1.0", + config: createTestConfig({ defaultModel: "composer-1.5" }), + }); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); + + const { status, body } = await fetchServer(server, "/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "auto", + messages: [{ role: "user", content: "Hi" }], + }), + }); + expect(status).toBe(200); + const data = JSON.parse(body); + expect(data.model).toBe("composer-1.5"); + }); + + it("echoes auto when request is auto and defaultModel is unset", async () => { + server = startBridgeServer({ + version: "0.1.0", + config: createTestConfig({ defaultModel: "auto" }), + }); + await new Promise((resolve) => + server.on("listening", () => resolve()), + ); + + const { status, body } = await fetchServer(server, "/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "auto", + messages: [{ role: "user", content: "Hi" }], + }), + }); + expect(status).toBe(200); + const data = JSON.parse(body); + expect(data.model).toBe("auto"); + }); }); diff --git a/src/lib/server.ts b/src/lib/server.ts index 677b3f4..569407e 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -5,6 +5,12 @@ import * as https from "node:https"; import type { BridgeConfig } from "./config.js"; import { createRequestListener } from "./request-listener.js"; +function acpLauncherLabel(acpArgs: string[]): string { + const first = acpArgs[0]; + if (first && /\.[cm]?js$/i.test(first)) return "node + script"; + return "cmd"; +} + export type BridgeServerOptions = { version: string; config: BridgeConfig; @@ -33,18 +39,25 @@ export function startBridgeServer( `cursor-api-proxy listening on ${scheme}://${config.host}:${config.port}`, ); console.log(`- agent bin: ${config.agentBin}`); + console.log( + `- ACP: ${config.useAcp ? "yes" : "no"}${config.useAcp ? ` (launcher: ${acpLauncherLabel(config.acpArgs)})` : ""}`, + ); console.log(`- workspace: ${config.workspace}`); console.log(`- mode: ${config.mode}`); console.log(`- default model: ${config.defaultModel}`); console.log(`- force: ${config.force}`); console.log(`- approve mcps: ${config.approveMcps}`); - console.log( - `- required api key: ${config.requiredKey ? "yes" : "no"}`, - ); + console.log(`- required api key: ${config.requiredKey ? "yes" : "no"}`); console.log(`- sessions log: ${config.sessionsLogPath}`); console.log( `- chat-only workspace: ${config.chatOnlyWorkspace ? "yes (isolated temp dir)" : "no"}`, ); + console.log( + `- verbose traffic: ${config.verbose ? "yes (CURSOR_BRIDGE_VERBOSE=true)" : "no"}`, + ); + console.log( + `- max mode: ${config.maxMode ? "yes (CURSOR_BRIDGE_MAX_MODE=true)" : "no"}`, + ); }); return server; diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index c56d488..7194db2 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -9,12 +9,53 @@ export type WorkspaceResult = { tempDir?: string; }; +/** + * Env overrides for chat-only (isolated) workspace so the agent cannot load + * rules from ~/.cursor or other user config paths. + */ +export function getChatOnlyEnvOverrides(workspaceDir: string): Record { + const cursorDir = path.join(workspaceDir, ".cursor"); + const overrides: Record = { + CURSOR_CONFIG_DIR: cursorDir, + HOME: workspaceDir, + USERPROFILE: workspaceDir, + }; + if (process.platform === "win32") { + const appDataRoaming = path.join(workspaceDir, "AppData", "Roaming"); + const appDataLocal = path.join(workspaceDir, "AppData", "Local"); + overrides.APPDATA = appDataRoaming; + overrides.LOCALAPPDATA = appDataLocal; + } else { + overrides.XDG_CONFIG_HOME = path.join(workspaceDir, ".config"); + } + return overrides; +} + export function resolveWorkspace( config: BridgeConfig, workspaceHeader?: string | string[] | null, ): WorkspaceResult { if (config.chatOnlyWorkspace) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-proxy-")); + const cursorDir = path.join(tempDir, ".cursor"); + fs.mkdirSync(cursorDir, { recursive: true }); + fs.mkdirSync(path.join(cursorDir, "rules"), { recursive: true }); + const minimalConfig = { + version: 1, + editor: { vimMode: false }, + permissions: { allow: [], deny: [] }, + }; + fs.writeFileSync( + path.join(cursorDir, "cli-config.json"), + JSON.stringify(minimalConfig, null, 0), + "utf8", + ); + if (process.platform === "win32") { + fs.mkdirSync(path.join(tempDir, "AppData", "Roaming"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "AppData", "Local"), { recursive: true }); + } else { + fs.mkdirSync(path.join(tempDir, ".config"), { recursive: true }); + } return { workspaceDir: tempDir, tempDir }; } const headerWs =