diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82cd022 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +.wrangler +.git +.dev.vars +.env +.env.* +!docker/env.example +!.env.example +*.log +.DS_Store +macos +sample-projects +agent-transcripts diff --git a/README.md b/README.md index f6c9098..9e290d1 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ import OpenAI from "openai"; const client = new OpenAI({ apiKey: "local", - baseURL: "http://127.0.0.1:8787/v1" + baseURL: "http://127.0.0.1:8787/v1", }); const completion = await client.chat.completions.create({ model: "composer-2.5", - messages: [{ role: "user", content: "Write a TypeScript debounce." }] + messages: [{ role: "user", content: "Write a TypeScript debounce." }], }); ``` @@ -120,6 +120,20 @@ The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default Release packages prefer a bundled Bun runtime for the local SDK bridge and fall back to Node when Bun is unavailable. +## Self-hosted Docker (Linux) + +For a headless Linux host, use Docker Compose to run a plain Node API server plus the SDK bridge (no Cloudflare runtime in the container): + +```bash +cp docker/env.example .env +docker compose up --build +``` + +For development, use `docker compose up --watch` to sync `worker/` and bridge +scripts into the running containers. See [docs/deploy-self-hosted.md](docs/deploy-self-hosted.md). + +The API listens on `http://127.0.0.1:8787/v1` by default. See [docs/deploy-self-hosted.md](docs/deploy-self-hosted.md) for configuration, security notes, and limitations compared to the macOS app. + ## Cloudflare The Worker uses Cloudflare Vite and D1. diff --git a/containers/cursor-sdk-bridge/Dockerfile b/containers/cursor-sdk-bridge/Dockerfile index 572d080..ae51228 100644 --- a/containers/cursor-sdk-bridge/Dockerfile +++ b/containers/cursor-sdk-bridge/Dockerfile @@ -1,8 +1,15 @@ -FROM node:22-alpine +FROM node:24-bookworm-slim WORKDIR /app +# Non-Composer models use the full Cursor SDK agent path, which spawns /bin/sh for +# tools like `date`. Alpine's minimal image and USER node often break that path. +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates coreutils git \ + && rm -rf /var/lib/apt/lists/* + ENV NODE_ENV=production +ENV SHELL=/bin/bash ENV CURSOR_SDK_BRIDGE_HOST=0.0.0.0 ENV CURSOR_SDK_BRIDGE_PORT=8792 @@ -10,9 +17,11 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY scripts/cursor-sdk-local-agent-bridge.mjs ./scripts/cursor-sdk-local-agent-bridge.mjs +COPY scripts/composer-tool-markers.mjs ./scripts/composer-tool-markers.mjs EXPOSE 8792 -USER node +HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=15s \ + CMD node -e "fetch('http://127.0.0.1:8792/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD ["node", "scripts/cursor-sdk-local-agent-bridge.mjs"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0bea090 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + bridge: + container_name: bridge + build: + context: . + dockerfile: containers/cursor-sdk-bridge/Dockerfile + develop: + watch: + - action: sync+restart + path: ./scripts/cursor-sdk-local-agent-bridge.mjs + target: /app/scripts/cursor-sdk-local-agent-bridge.mjs + - action: sync+restart + path: ./scripts/composer-tool-markers.mjs + target: /app/scripts/composer-tool-markers.mjs + - action: rebuild + path: ./package.json + - action: rebuild + path: ./package-lock.json + environment: + CURSOR_SDK_BRIDGE_TOKEN: ${CURSOR_SDK_BRIDGE_TOKEN:-} + CURSOR_SDK_WORKING_DIRECTORY: /workspace + CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS: ${CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS:-180000} + CURSOR_SDK_BRIDGE_MAX_JSON_BYTES: ${CURSOR_SDK_BRIDGE_MAX_JSON_BYTES:-16777216} + volumes: + - ${WORKSPACE:-.}:/workspace:rw + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:8792/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + networks: + - composer-api + restart: always + + api: + container_name: api + build: + context: . + dockerfile: docker/Dockerfile.api + develop: + watch: + - action: sync+restart + path: ./worker + target: /app/worker + - action: sync+restart + path: ./docker + target: /app/docker + - action: sync+restart + path: ./scripts/composer-tool-markers.mjs + target: /app/scripts/composer-tool-markers.mjs + - action: rebuild + path: ./package.json + - action: rebuild + path: ./package-lock.json + ports: + - "${CURSOR_API_PORT:-8787}:8787" + environment: + CURSOR_SDK_BRIDGE_URL: http://bridge:8792/sdk + CURSOR_SDK_BRIDGE_TOKEN: ${CURSOR_SDK_BRIDGE_TOKEN:-} + CURSOR_API_BASE: ${CURSOR_API_BASE:-https://api.cursor.com} + CURSOR_CLIENT_VERSION: ${CURSOR_CLIENT_VERSION:-2.6.22} + CURSOR_SDK_CLIENT_VERSION: ${CURSOR_SDK_CLIENT_VERSION:-sdk-1.0.13} + CURSOR_SDK_BRIDGE_TIMEOUT_MS: ${CURSOR_SDK_BRIDGE_TIMEOUT_MS:-180000} + depends_on: + bridge: + condition: service_healthy + networks: + - composer-api + restart: always +networks: + composer-api: diff --git a/docker/Dockerfile.all-in-one b/docker/Dockerfile.all-in-one new file mode 100644 index 0000000..0560b6f --- /dev/null +++ b/docker/Dockerfile.all-in-one @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache tini + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +RUN chmod +x docker/entrypoint-api.sh docker/entrypoint-all-in-one.sh + +EXPOSE 8787 8792 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["docker/entrypoint-all-in-one.sh"] diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..91705af --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache tini + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +RUN chmod +x docker/entrypoint-api.sh + +EXPOSE 8787 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["docker/entrypoint-api.sh"] diff --git a/docker/api-server.ts b/docker/api-server.ts new file mode 100644 index 0000000..a01ab71 --- /dev/null +++ b/docker/api-server.ts @@ -0,0 +1,173 @@ +import "./register-cloudflare-mock.mjs"; +import http from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import type { Env } from "../worker/types.js"; + +function parsePort(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +async function loadRuntime() { + const [{ handleRequest, defaultDeps }, { FakeD1, fakeCtx }] = + await Promise.all([ + import("../worker/index.ts"), + import("../worker/test-helpers.ts"), + ]); + return { handleRequest, defaultDeps, FakeD1, fakeCtx }; +} + +export function createSelfHostedEnv( + FakeD1: typeof import("../worker/test-helpers.ts").FakeD1, +): Env { + const bridgeUrl = process.env.CURSOR_SDK_BRIDGE_URL?.trim(); + if (!bridgeUrl) { + throw new Error( + "CURSOR_SDK_BRIDGE_URL is required (for example http://bridge:8792/sdk)", + ); + } + + return { + DB: new FakeD1() as unknown as D1Database, + ASSETS: { + fetch: async () => new Response("Not Found", { status: 404 }), + } as unknown as Fetcher, + CURSOR_API_BASE: + process.env.CURSOR_API_BASE?.trim() || "https://api.cursor.com", + CURSOR_CLIENT_VERSION: + process.env.CURSOR_CLIENT_VERSION?.trim() || "2.6.22", + CURSOR_SDK_CLIENT_VERSION: + process.env.CURSOR_SDK_CLIENT_VERSION?.trim() || "sdk-1.0.13", + CURSOR_SDK_BRIDGE_URL: bridgeUrl, + CURSOR_SDK_BRIDGE_TOKEN: process.env.CURSOR_SDK_BRIDGE_TOKEN, + CURSOR_SDK_BRIDGE_TIMEOUT_MS: process.env.CURSOR_SDK_BRIDGE_TIMEOUT_MS, + CURSOR_BACKEND_BASE_URL: process.env.CURSOR_BACKEND_BASE_URL?.trim(), + CURSOR_CHAT_ENDPOINT: process.env.CURSOR_CHAT_ENDPOINT?.trim(), + CURSOR_LOCAL_AGENT_ENDPOINT: + process.env.CURSOR_LOCAL_AGENT_ENDPOINT?.trim(), + }; +} + +function healthResponse(): Response { + return Response.json({ + ok: true, + service: "api-for-cursor-self-hosted", + bridgeConfigured: Boolean(process.env.CURSOR_SDK_BRIDGE_URL?.trim()), + }); +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +function nodeRequestToWeb( + request: IncomingMessage, + body: Buffer, + origin: string, +): Request { + const url = new URL(request.url || "/", origin); + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + + const method = request.method || "GET"; + const init: RequestInit = { method, headers }; + if (method !== "GET" && method !== "HEAD" && body.length > 0) { + init.body = new Uint8Array(body); + } + + return new Request(url.toString(), init); +} + +async function writeWebResponse( + response: Response, + res: ServerResponse, +): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + if (key.toLowerCase() === "transfer-encoding") return; + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const nodeStream = Readable.fromWeb( + response.body as ReadableStream, + ); + for await (const chunk of nodeStream) { + res.write(chunk); + } + res.end(); +} + +export async function startApiServer(): Promise { + const { handleRequest, defaultDeps, FakeD1, fakeCtx } = await loadRuntime(); + const env = createSelfHostedEnv(FakeD1); + const ctx = fakeCtx(); + const host = process.env.CURSOR_API_HOST?.trim() || "0.0.0.0"; + const port = parsePort(process.env.CURSOR_API_PORT, 8787); + const origin = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`; + + const server = http.createServer(async (req, res) => { + try { + const pathname = new URL(req.url || "/", origin).pathname; + if (req.method === "GET" && pathname === "/health") { + await writeWebResponse(healthResponse(), res); + return; + } + + const body = await readRequestBody(req); + const webRequest = nodeRequestToWeb(req, body, origin); + const webResponse = await handleRequest( + webRequest, + env, + ctx, + defaultDeps, + ); + await writeWebResponse(webResponse, res); + } catch (error) { + const message = + error instanceof Error ? error.message : "Internal Server Error"; + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: { message, type: "server_error" } })); + return; + } + res.end(); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => resolve()); + }); + + console.log( + `API for Cursor self-hosted server listening on http://${host}:${port}/v1`, + ); + return server; +} + +const isMainModule = process.argv[1] === fileURLToPath(import.meta.url); +if (isMainModule) { + startApiServer().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/docker/entrypoint-all-in-one.sh b/docker/entrypoint-all-in-one.sh new file mode 100755 index 0000000..2e0bd5c --- /dev/null +++ b/docker/entrypoint-all-in-one.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +export CURSOR_SDK_BRIDGE_HOST="${CURSOR_SDK_BRIDGE_HOST:-0.0.0.0}" +export CURSOR_SDK_BRIDGE_PORT="${CURSOR_SDK_BRIDGE_PORT:-8792}" +export CURSOR_SDK_BRIDGE_URL="${CURSOR_SDK_BRIDGE_URL:-http://127.0.0.1:${CURSOR_SDK_BRIDGE_PORT}/sdk}" + +node scripts/cursor-sdk-local-agent-bridge.mjs & +BRIDGE_PID=$! + +for _ in $(seq 1 60); do + if node -e "fetch('http://127.0.0.1:${CURSOR_SDK_BRIDGE_PORT}/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then + break + fi + if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "Cursor SDK bridge exited before becoming healthy." >&2 + exit 1 + fi + sleep 0.5 +done + +trap 'kill "$BRIDGE_PID" 2>/dev/null || true' EXIT INT TERM + +exec docker/entrypoint-api.sh diff --git a/docker/entrypoint-api.sh b/docker/entrypoint-api.sh new file mode 100755 index 0000000..c02e3b3 --- /dev/null +++ b/docker/entrypoint-api.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +: "${CURSOR_SDK_BRIDGE_URL:=http://bridge:8792/sdk}" + +exec npx tsx docker/api-server.ts diff --git a/docker/env.example b/docker/env.example new file mode 100644 index 0000000..4630116 --- /dev/null +++ b/docker/env.example @@ -0,0 +1,11 @@ +# Copy to the repo root as `.env` for docker compose: +# cp docker/env.example .env + +# Optional shared secret between the API and bridge containers. +CURSOR_SDK_BRIDGE_TOKEN= + +# Host port published for the OpenAI-compatible API. +CURSOR_API_PORT=8787 + +# Project directory mounted into the bridge for agent file tools. +WORKSPACE=. diff --git a/docker/hooks-cloudflare-mock.mjs b/docker/hooks-cloudflare-mock.mjs new file mode 100644 index 0000000..2d8be59 --- /dev/null +++ b/docker/hooks-cloudflare-mock.mjs @@ -0,0 +1,9 @@ +const stubUrl = new URL("./stub-cloudflare-containers.mjs", import.meta.url) + .href; + +export async function resolve(specifier, context, nextResolve) { + if (specifier === "@cloudflare/containers") { + return { url: stubUrl, shortCircuit: true }; + } + return nextResolve(specifier, context); +} diff --git a/docker/register-cloudflare-mock.mjs b/docker/register-cloudflare-mock.mjs new file mode 100644 index 0000000..82dd0e5 --- /dev/null +++ b/docker/register-cloudflare-mock.mjs @@ -0,0 +1,6 @@ +import { register } from "node:module"; + +register( + new URL("./hooks-cloudflare-mock.mjs", import.meta.url).href, + import.meta.url, +); diff --git a/docker/stub-cloudflare-containers.mjs b/docker/stub-cloudflare-containers.mjs new file mode 100644 index 0000000..6bd8884 --- /dev/null +++ b/docker/stub-cloudflare-containers.mjs @@ -0,0 +1,8 @@ +/** Stub used by the self-hosted API server so worker/index.ts can load without Cloudflare Containers. */ +export class Container { + defaultPort = 8792; + sleepAfter = "30m"; + pingEndpoint = "localhost/health"; + envVars = {}; + enableInternet = true; +} diff --git a/docs/deploy-self-hosted.md b/docs/deploy-self-hosted.md new file mode 100644 index 0000000..fb6d628 --- /dev/null +++ b/docs/deploy-self-hosted.md @@ -0,0 +1,160 @@ +# Self-hosted Docker (Linux) + +Run the OpenAI-compatible `/v1` API on Linux using **plain Node.js** and the **Cursor SDK bridge**. This path does not run Vite, Wrangler, or any Cloudflare Workers runtime in the container. + +The signed **macOS app** remains the recommended end-user install. Docker is for operators who want a headless Linux host (CI runner, remote dev machine, homelab). + +## Architecture + +```mermaid +flowchart LR + subgraph clients [Clients] + Client[OpenAI-compatible client] + end + + subgraph compose [docker compose] + API[Node API server :8787] + Bridge[SDK bridge :8792] + API -->|"POST /sdk"| Bridge + end + + subgraph cursor [Cursor] + CursorAPI[api.cursor.com] + end + + Client -->|"/v1/chat/completions"| API + Bridge --> CursorAPI +``` + +- **api** — [`docker/api-server.ts`](../docker/api-server.ts) loads the existing Worker `handleRequest` logic with a small module stub so `@cloudflare/containers` is never required at runtime. No `.dev.vars`, D1 migrations, or Cloudflare dev server. +- **bridge** — [`scripts/cursor-sdk-local-agent-bridge.mjs`](../scripts/cursor-sdk-local-agent-bridge.mjs) in [`containers/cursor-sdk-bridge/Dockerfile`](../containers/cursor-sdk-bridge/Dockerfile). + +## Prerequisites + +- Docker Compose v2 +- A [Cursor user API key](https://cursor.com/dashboard) (Integrations) +- Outbound HTTPS to Cursor + +## Quick start (two services) + +From the repository root: + +```bash +cp docker/env.example .env +# Optional: set CURSOR_SDK_BRIDGE_TOKEN in .env + +docker compose up --build +``` + +For local development, sync source changes into the running containers and restart +automatically (requires Docker Compose v2.22+): + +```bash +docker compose up --build --watch +# or, after the stack is already up: +npm run docker:watch +``` + +Watch syncs `worker/` and `docker/` into **api**, bridge scripts into **bridge**, +and rebuilds either service when `package.json` or `package-lock.json` changes. + +Default base URL: + +```txt +http://127.0.0.1:8787/v1 +``` + +Health check: + +```bash +curl -s http://127.0.0.1:8787/health +``` + +List models: + +```bash +curl -s http://127.0.0.1:8787/v1/models \ + -H "Authorization: Bearer YOUR_CURSOR_API_KEY" +``` + +Chat completion: + +```bash +curl -s http://127.0.0.1:8787/v1/chat/completions \ + -H "Authorization: Bearer YOUR_CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"composer-2.5","messages":[{"role":"user","content":"Say hello in one sentence."}]}' +``` + +Use your real Cursor API key as the Bearer token. Any non-`cmp_` token is passed through to Cursor (same as the macOS app’s direct mode). + +**Models:** `composer-2.5`, `composer-2.5-fast`, and GPT-family models (`gpt-*`, `*-codex`) have the best tool-call support. Gemini, Kimi, and Grok may emit legacy text tool markers; the bridge now parses those when possible, but Composer/GPT remain the most reliable choices for agent tools in Docker. + +## Configuration + +| Variable | Default | Description | +| ------------------------------ | ------------------------ | ------------------------------------------------------------------------- | +| `CURSOR_API_PORT` | `8787` | Host port mapped to the API container | +| `WORKSPACE` | `.` | Host path mounted at `/workspace` in the bridge (agent working directory) | +| `CURSOR_SDK_BRIDGE_TOKEN` | empty | Optional shared secret; set the same value for `api` and `bridge` | +| `CURSOR_API_BASE` | `https://api.cursor.com` | Cursor public API base | +| `CURSOR_SDK_BRIDGE_TIMEOUT_MS` | `180000` | Bridge request timeout | +| `CURSOR_CLIENT_VERSION` | `2.6.22` | Cursor client version header | +| `CURSOR_SDK_CLIENT_VERSION` | `sdk-1.0.13` | SDK client version | + +Bridge health (inside the compose network): + +```txt +http://bridge:8792/health +``` + +## Single-container alternative + +For one image (bridge + API): + +```bash +docker build -f docker/Dockerfile.all-in-one -t api-for-cursor:local . +docker run --rm -p 8787:8787 \ + -v "$(pwd):/workspace" \ + api-for-cursor:local +``` + +## Local run without Docker + +With the bridge already listening on port 8792: + +```bash +export CURSOR_SDK_BRIDGE_URL=http://127.0.0.1:8792/sdk +npm run sdk:opencode-bridge # terminal 1 +npm run docker:api # terminal 2 +``` + +## Limitations vs macOS app + +- No GUI, Keychain, Sparkle updates, or **Agent Setup** installers. +- **Client-local tools** (MCP callbacks to the host) are limited; chat-only use works out of the box with a mounted workspace. +- **Responses** multi-turn state is in-memory; container restarts clear it. +- Hosted signup / `cmp_...` proxy-key flows are not the intended Docker use case. +- Do not expose port `8787` on the public internet without TLS and access controls. + +## Security + +- Keep Cursor API keys in `.env` or your secrets manager, not in the image. +- Prefer `CURSOR_SDK_BRIDGE_TOKEN` when the bridge port could be reached from other containers or hosts. + +## Troubleshooting + +| Symptom | Likely cause | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api` waits on `bridge` | Bridge not healthy; check `docker compose logs bridge` | +| `401` on `/v1/*` | Missing or invalid `Authorization: Bearer` Cursor key | +| `504` / bridge timeout | Cursor or bridge slow; raise `CURSOR_SDK_BRIDGE_TIMEOUT_MS` | +| Bridge exits with `spawn /bin/sh ENOENT` | Non-Composer models need a shell; rebuild the bridge image (`docker compose build --no-cache bridge`) or use `composer-2.5` / `composer-2.5-fast` | +| Agent cannot see project files | `WORKSPACE` volume not mounted or wrong path | +| `CURSOR_SDK_BRIDGE_URL is required` | API container started without bridge URL env | + +## Related docs + +- macOS releases: [production.md](production.md) +- Local development (non-Docker): [README.md](../README.md#local-development) +- Cloudflare production deploy: [README.md](../README.md#cloudflare) diff --git a/index.html b/index.html index f42902a..929d91e 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,16 @@ - - + + API for Cursor - + @@ -30,7 +41,10 @@ name="twitter:description" content="A local macOS app that gives OpenCode, Codex, and OpenAI-compatible clients a local API for Cursor Composer." /> - + @@ -45,7 +59,13 @@
-

@@ -77,8 +107,9 @@

- Run a small local server that makes Cursor Composer models available to OpenCode, - Codex, VS Code extensions, and any OpenAI-compatible client. + Run a small local server that makes Cursor Composer models available + to OpenCode, Codex, VS Code extensions, and any OpenAI-compatible + client.

@@ -89,7 +120,11 @@

DMG installer, macOS 14+ -

- +

Local server

http://127.0.0.1:8787/v1

@@ -137,12 +177,21 @@

Local server

- OpenCode - Configured + + OpenCode + Configured
- Codex - Configured + Codex + Configured
@@ -152,19 +201,34 @@

Local server

- +

Local first

-

The server runs on your Mac, so agent tools keep working against your real project files instead of a hosted sandbox.

+

+ The server runs on your Mac, so agent tools keep working against + your real project files instead of a hosted sandbox. +

- +

OpenAI-compatible

-

Chat Completions, Responses, and Models endpoints are exposed at a standard local /v1 base URL.

+

+ Chat Completions, Responses, and Models endpoints are exposed at a + standard local /v1 base URL. +

- +

Auto-updating

-

Sparkle update feeds let signed releases roll forward without asking you to reinstall manually.

+

+ Sparkle update feeds let signed releases roll forward without asking + you to reinstall manually. +

@@ -173,19 +237,27 @@

Auto-updating

Works where agents already run

One local app. First-class agent setup.

- Paste your Cursor key, start the local API, then point any OpenAI-compatible - client at 127.0.0.1. No proxy hops, no hosted sandbox. + Paste your Cursor key, start the local API, then point any + OpenAI-compatible client at 127.0.0.1. No proxy hops, + no hosted sandbox.

- OpenCode selecting Composer 2.5 through API for Cursor + OpenCode selecting Composer 2.5 through API for Cursor

OpenCode sees Composer models

-

OpenCode keeps its local filesystem and shell loop while the model route runs through the local API with one-click setup.

+

+ OpenCode keeps its local filesystem and shell loop while the + model route runs through the local API with one-click setup. +

@@ -198,36 +270,57 @@

OpenCode sees Composer models

composer-2.5 - Ready + Ready
composer-2.5-fast - Fast + Fast
[model_providers.cursor-composer]
 base_url = "http://127.0.0.1:8787/v1"

Codex-compatible provider

-

Use the same OpenAI-compatible base URL in Codex and any tool that supports custom providers.

+

+ Use the same OpenAI-compatible base URL in Codex and any tool + that supports custom providers. +

-
+

Production release path

-

Signed installs, notarized, auto-updating.

+

+ Signed installs, notarized, auto-updating. +

- Releases are built by GitHub Actions, packaged as a DMG, notarized by Apple, - published to Cloudflare R2, and announced to the app through a Sparkle appcast. + Releases are built by GitHub Actions, packaged as a DMG, notarized + by Apple, published to Cloudflare R2, and announced to the app + through a Sparkle appcast.

    -
  • Notarized & signed
  • -
  • Background updates
  • -
  • macOS 14 Sonoma+
  • +
  • + Notarized & signed +
  • +
  • + Background + updates +
  • +
  • + macOS 14 Sonoma+ +
@@ -243,10 +336,18 @@

Signed installs, notarized, auto-updating.