From 663b2f4c63461ce655e09d423a755b8fe1a2f626 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 1 Apr 2026 04:32:17 +0000 Subject: [PATCH] add docker file and claude flow output --- .env.example | 40 ++++ Dockerfile | 24 +++ Makefile | 77 ++++++- README.md | 278 ++++++++++++++----------- docker-compose.yml | 27 +++ internal/agent/cmdargs.go | 2 +- internal/env/env.go | 2 +- internal/handlers/anthropic_handler.go | 75 +++++-- 8 files changed, 374 insertions(+), 151 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84e251a --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# ────────────────────────────────────────────────────────────── +# cursor-api-proxy 設定範例 +# 複製為 .env 後填入你的設定:cp .env.example .env +# ────────────────────────────────────────────────────────────── + +# ── 伺服器設定 ──────────────────────────────────────────────── +# Docker 模式固定使用 0.0.0.0;本機直接執行時可改 127.0.0.1 +CURSOR_BRIDGE_HOST=0.0.0.0 +CURSOR_BRIDGE_PORT=8766 +CURSOR_BRIDGE_API_KEY= +CURSOR_BRIDGE_TIMEOUT_MS=3600000 +CURSOR_BRIDGE_MULTI_PORT=false +CURSOR_BRIDGE_VERBOSE=false + +# ── Agent / 模型設定 ────────────────────────────────────────── +# Docker 模式:容器內 agent 路徑(預設掛載至 /usr/local/bin/agent) +CURSOR_AGENT_BIN=/usr/local/bin/agent +CURSOR_BRIDGE_DEFAULT_MODEL=auto +CURSOR_BRIDGE_STRICT_MODEL=true +CURSOR_BRIDGE_MAX_MODE=false +CURSOR_BRIDGE_FORCE=false +CURSOR_BRIDGE_APPROVE_MCPS=false + +# ── 工作區與帳號 ────────────────────────────────────────────── +CURSOR_BRIDGE_WORKSPACE=/workspace +CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE=true +# 多帳號設定目錄(逗號分隔),留空則自動探索 ~/.cursor-api-proxy/accounts/ +CURSOR_CONFIG_DIRS= + +# ── TLS / HTTPS(選用)──────────────────────────────────────── +CURSOR_BRIDGE_TLS_CERT= +CURSOR_BRIDGE_TLS_KEY= + +# ── Docker 專用:宿主機路徑對映 ────────────────────────────── +# 宿主機上 Cursor agent 二進位檔的實際路徑 +CURSOR_AGENT_HOST_BIN=/usr/local/bin/agent +# 宿主機上的帳號資料目錄(會掛載至容器的 /root/.cursor-api-proxy) +CURSOR_ACCOUNTS_DIR=~/.cursor-api-proxy +# 宿主機上要掛載進容器的工作區目錄 +WORKSPACE_DIR=/tmp/workspace diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e604226 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# ── Stage 1: 編譯 ───────────────────────────────────────────── +FROM golang:1.24-alpine AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +# 臨時降版 go directive 以符合 1.24 編譯器,不修改原始 go.mod +RUN go mod edit -go=1.24 && go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o cursor-api-proxy . + +# ── Stage 2: 執行環境 ────────────────────────────────────────── +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /build/cursor-api-proxy . + +EXPOSE 8766 + +ENTRYPOINT ["./cursor-api-proxy"] diff --git a/Makefile b/Makefile index 3fc33c4..c07c652 100644 --- a/Makefile +++ b/Makefile @@ -27,14 +27,12 @@ CHAT_ONLY_WORKSPACE ?= true CONFIG_DIRS ?= # ── Cursor / Claude Code(~/.claude)──────────────── -# 預設 id 須與 GET http://HOST:PORT/v1/models 回傳的 data[].id 一致;可於命令列覆寫 CLAUDE_SETTINGS ?= $(HOME)/.claude/settings.json CLAUDE_JSON ?= $(HOME)/.claude.json ANTHROPIC_AUTH_TOKEN ?= ANTHROPIC_DEFAULT_SONNET_MODEL ?= claude-4.6-sonnet-medium ANTHROPIC_DEFAULT_OPUS_MODEL ?= claude-4.6-opus-max ANTHROPIC_DEFAULT_HAIKU_MODEL ?= gemini-3-flash -# 僅影響 claude-settings 寫入的 ANTHROPIC_BASE_URL(預設等同 HOST;可設為 localhost) ANTHROPIC_BASE_HOST ?= $(HOST) # ── TLS / HTTPS ─────────────────────────────── @@ -50,8 +48,14 @@ ENV_FILE ?= .env OPENCODE_CONFIG ?= $(HOME)/.config/opencode/opencode.json +# ── Docker 設定 ─────────────────────────────── +DOCKER_IMAGE ?= cursor-api-proxy +DOCKER_TAG ?= latest +DOCKER_COMPOSE ?= docker compose + .PHONY: env run build clean help opencode opencode-models pm2 pm2-stop pm2-logs claude-code pm2-claude-code \ - claude-settings claude-onboarding claude-cursor-setup + claude-settings claude-onboarding claude-cursor-setup \ + docker-build docker-up docker-down docker-logs docker-restart docker-shell docker-env docker-setup ## 產生 .env 檔(預設輸出至 .env,可用 ENV_FILE=xxx 覆寫) env: @@ -186,6 +190,63 @@ pm2-stop: pm2-logs: pm2 logs cursor-api-proxy + +## ────────────────────────────────────────────────── +## Docker Compose 指令 +## ────────────────────────────────────────────────── + +## 複製 .env.example 為 .env(首次設定) +docker-env: + @if [ -f .env ]; then \ + echo ".env 已存在,若要重置請手動刪除後再執行"; \ + else \ + cp .env.example .env; \ + echo "已建立 .env,請編輯填入設定後執行 make docker-up"; \ + fi + +## 建置 Docker 映像檔 +docker-build: + $(DOCKER_COMPOSE) build + +## 啟動 Docker Compose(背景執行) +docker-up: + @if [ ! -f .env ]; then \ + echo "找不到 .env,請先執行:make docker-env"; exit 1; \ + fi + $(DOCKER_COMPOSE) up -d + @echo "cursor-api-proxy 已啟動(http://0.0.0.0:$(PORT))" + +## 首次設定並啟動(複製 .env + build + up,一步完成) +docker-setup: + @if [ ! -f .env ]; then \ + cp .env.example .env; \ + echo "已建立 .env,請先編輯填入必要設定(CURSOR_AGENT_HOST_BIN、CURSOR_ACCOUNTS_DIR),然後重新執行 make docker-setup"; \ + exit 1; \ + fi + $(DOCKER_COMPOSE) build + $(DOCKER_COMPOSE) up -d + @echo "cursor-api-proxy 已啟動(http://0.0.0.0:$(PORT))" + @echo "查看日誌:make docker-logs" + +## 停止並移除容器 +docker-down: + $(DOCKER_COMPOSE) down + +## 查看容器日誌(即時跟蹤) +docker-logs: + $(DOCKER_COMPOSE) logs -f cursor-api-proxy + +## 重新建置並啟動容器 +docker-restart: + $(DOCKER_COMPOSE) down + $(DOCKER_COMPOSE) build + $(DOCKER_COMPOSE) up -d + @echo "cursor-api-proxy 已重新啟動" + +## 進入容器 shell(除錯用) +docker-shell: + $(DOCKER_COMPOSE) exec cursor-api-proxy sh + ## 顯示說明 help: @echo "可用目標:" @@ -204,6 +265,16 @@ help: @echo " make claude-cursor-setup 同上兩步一次完成" @echo " make clean 刪除二進位檔與 .env" @echo "" + @echo "Docker Compose 指令:" + @echo " make docker-env 複製 .env.example 為 .env(首次設定)" + @echo " make docker-setup 首次設定並啟動(自動複製 .env + build + up)" + @echo " make docker-build 建置 Docker 映像檔" + @echo " make docker-up 啟動容器(背景執行,需已有 .env)" + @echo " make docker-down 停止並移除容器" + @echo " make docker-logs 查看容器即時日誌" + @echo " make docker-restart 重新建置並啟動容器" + @echo " make docker-shell 進入容器 shell(除錯用)" + @echo "" @echo "覆寫範例:" @echo " make env PORT=9000 API_KEY=mysecret TIMEOUT_MS=60000" @echo " make pm2-claude-code PORT=8765 API_KEY=mykey" diff --git a/README.md b/README.md index edaa297..24d037d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,101 @@ - **HWID 重置**:內建反偵測功能,可重置機器識別碼 - **連線池**:最佳化的連線管理 -## 快速開始 +--- + +## 🐳 Docker Compose 部署(推薦,跨電腦通用) + +使用 Docker Compose 可以讓代理在任何有 Docker 的電腦上一鍵啟動,**無需安裝 Go 環境**。 + +> **前提**:宿主機需已安裝 Cursor CLI(`agent` 二進位檔),且已登入至少一個帳號。 + +### 快速開始(三步驟) + +```bash +# 1. Clone 專案 +git clone https://github.com/your-repo/cursor-api-proxy-go.git +cd cursor-api-proxy-go + +# 2. 建立並編輯 .env(首次執行會提示你先填寫設定) +make docker-setup +``` + +第一次執行 `make docker-setup` 時,若尚未有 `.env`,它會自動幫你從 `.env.example` 複製,然後提示你填入必要設定後再執行一次。 + +```bash +# 3. 編輯 .env 後再次啟動 +vim .env +make docker-setup +``` + +啟動後確認運作: + +```bash +curl http://localhost:8766/health +``` + +### .env 關鍵設定 + +| 變數 | 說明 | 範例 | +|------|------|------| +| `CURSOR_AGENT_HOST_BIN` | 宿主機 Cursor agent 二進位檔的**完整路徑** | `/usr/local/bin/agent` | +| `CURSOR_ACCOUNTS_DIR` | 宿主機帳號資料目錄 | `~/.cursor-api-proxy` | +| `WORKSPACE_DIR` | 要掛載進容器的工作區目錄 | `/home/user/projects` | +| `CURSOR_BRIDGE_PORT` | 監聽連接埠(預設 8766) | `8766` | +| `CURSOR_BRIDGE_API_KEY` | API 鑑別金鑰(選用,建議設定) | `my-secret-key` | + +### 尋找 Cursor agent 路徑 + +```bash +# macOS / Linux +which agent 2>/dev/null || find /usr/local -name agent 2>/dev/null | head -5 + +# 如果 Cursor 是透過 AppImage 或自訂路徑安裝 +find ~/.cursor -name agent 2>/dev/null | head -5 +``` + +### Docker Compose 常用指令 + +```bash +make docker-setup # 首次設定並啟動(自動複製 .env + build + up) +make docker-env # 僅複製 .env.example 為 .env +make docker-build # 重新建置映像檔 +make docker-up # 啟動容器(需已有 .env) +make docker-down # 停止並移除容器 +make docker-logs # 查看即時日誌 +make docker-restart # 重新建置並啟動 +make docker-shell # 進入容器 shell(除錯用) +``` + +### 接入 Claude Code(Docker 模式) + +```bash +# 代理啟動後,設定 Claude Code 使用代理 +export ANTHROPIC_BASE_URL=http://localhost:8766 +export ANTHROPIC_API_KEY=my-secret-key # 若有設定 CURSOR_BRIDGE_API_KEY + +# 或一鍵寫入 ~/.claude/settings.json +make claude-settings PORT=8766 ANTHROPIC_BASE_HOST=localhost +make claude-onboarding + +claude +``` + +### 在其他電腦(或同區網)使用 + +1. 在一台電腦上啟動 Docker Compose(`CURSOR_BRIDGE_HOST=0.0.0.0`) +2. 確認 `CURSOR_BRIDGE_PORT` 防火牆已開放 +3. 其他電腦將 `ANTHROPIC_BASE_URL` 指向這台的 IP + +```bash +export ANTHROPIC_BASE_URL=http://192.168.1.100:8766 +export ANTHROPIC_API_KEY=my-secret-key +claude +``` + +--- + +## 本機直接執行(pm2 模式) ### 1. 建置 @@ -32,18 +126,20 @@ go build -o cursor-api-proxy . ### 3. 啟動伺服器 ```bash +# 直接執行(前景) ./cursor-api-proxy + +# 用 pm2 背景執行 +make env PORT=8766 API_KEY=mysecret +make pm2 ``` -預設監聽 `127.0.0.1:8765`。 - -### 4. 設定 API Key(選用) - -如果需要外部存取,建議設定 API Key: +### 4. 設定 Claude Code ```bash -export CURSOR_BRIDGE_API_KEY=my-secret-key -./cursor-api-proxy +make claude-settings PORT=8766 +make claude-onboarding +claude ``` --- @@ -52,19 +148,14 @@ export CURSOR_BRIDGE_API_KEY=my-secret-key Claude Code 使用 Anthropic SDK,透過環境變數設定即可改用你的代理。 -### 步驟 - ```bash -# 1. 啟動代理(確認已在背景執行) -./cursor-api-proxy & +# 1. 啟動代理(Docker 或 pm2,確認已在背景執行) -# 2. 設定環境變數,讓 Claude Code 改用你的代理 -export ANTHROPIC_BASE_URL=http://127.0.0.1:8765 +# 2. 設定環境變數 +export ANTHROPIC_BASE_URL=http://127.0.0.1:8766 +export ANTHROPIC_API_KEY=my-secret-key # 若未設定 API_KEY,填任意值 -# 3. 如果代理有設定 CURSOR_BRIDGE_API_KEY,這裡填相同的值;沒有的話隨便填 -export ANTHROPIC_API_KEY=my-secret-key - -# 4. 啟動 Claude Code +# 3. 啟動 Claude Code claude ``` @@ -79,13 +170,7 @@ claude | `opus-4.6` | `claude-opus-4-6` | | `sonnet-4.6` | `claude-sonnet-4-6` | | `opus-4.5-thinking` | `claude-opus-4-5-thinking` | -| `sonnet-4.7` (未來新增) | `claude-sonnet-4-7` (自動生成) | - -### 也可直接指定模型 - -```bash -claude --model claude-sonnet-4-6 -``` +| `sonnet-4.7`(未來) | `claude-sonnet-4-7`(自動生成) | --- @@ -93,15 +178,8 @@ claude --model claude-sonnet-4-6 OpenCode 透過 `~/.config/opencode/opencode.json` 設定 provider,使用 OpenAI 相容格式。 -### 步驟 - 1. 啟動代理伺服器 - -```bash -./cursor-api-proxy & -``` - -2. 編輯 `~/.config/opencode/opencode.json`,在 `provider` 中新增一個 provider: +2. 編輯 `~/.config/opencode/opencode.json`: ```json { @@ -110,64 +188,42 @@ OpenCode 透過 `~/.config/opencode/opencode.json` 設定 provider,使用 Open "npm": "@ai-sdk/openai-compatible", "name": "Cursor Agent", "options": { - "baseURL": "http://127.0.0.1:8765/v1", + "baseURL": "http://127.0.0.1:8766/v1", "apiKey": "unused" }, "models": { "auto": { "name": "Cursor Auto" }, "sonnet-4.6": { "name": "Sonnet 4.6" }, - "opus-4.6": { "name": "Opus 4.6" }, - "opus-4.6-thinking": { "name": "Opus 4.6 Thinking" } + "opus-4.6": { "name": "Opus 4.6" } } } } } ``` -若代理有設定 `CURSOR_BRIDGE_API_KEY`,把 `apiKey` 改成相同的值。 - -3. 在 `opencode.json` 中設定預設模型: - -```json -{ - "model": "cursor/sonnet-4.6" -} -``` - -### 查看可用模型 +或使用指令一次設定: ```bash -curl http://127.0.0.1:8765/v1/models | jq '.data[].id' +make opencode PORT=8766 +make opencode-models PORT=8766 ``` -代理的 `/v1/models` 端點會回傳 Cursor CLI 目前支援的所有模型 ID,把結果加到 `opencode.json` 的 `models` 中即可。 - -### 在 OpenCode 中切換模型 - -OpenCode 的模型切換使用 `/model` 指令,從 `opencode.json` 的 `models` 清單中選擇。 - --- ## 模型對映原理 -兩端使用不同的模型 ID 格式: - ``` Claude Code OpenCode │ │ - │ claude-opus-4-6 │ opus-4.6 (Cursor 原生 ID) - │ (Anthropic alias) │ (OpenAI 相容格式) + │ claude-opus-4-6 │ opus-4.6 ▼ ▼ -┌──────────────────────┐ ┌──────────────────────┐ -│ ResolveToCursorModel │ │ 直接傳給代理 │ -│ claude-opus-4-6 │ │ opus-4.6 │ -│ ↓ │ │ ↓ │ -│ opus-4.6 │ │ opus-4.6 │ -└──────────────────────┘ └──────────────────────┘ - │ │ - └──────────┬───────────┘ - ▼ - Cursor CLI (agent --model opus-4.6) +┌──────────────────────────────────────┐ +│ ResolveToCursorModel │ +│ claude-opus-4-6 → opus-4.6 │ +└──────────────────────────────────────┘ + │ + ▼ + Cursor CLI (agent --model opus-4.6) ``` ### Anthropic 模型對映表(Claude Code 使用) @@ -181,66 +237,43 @@ Claude Code OpenCode | `opus-4.5` | `claude-opus-4-5` | Claude 4.5 Opus | | `opus-4.5-thinking` | `claude-opus-4-5-thinking` | Claude 4.5 Opus (Thinking) | | `sonnet-4.5` | `claude-sonnet-4-5` | Claude 4.5 Sonnet | -| `sonnet-4.5-thinking` | `claude-sonnet-4-5-thinking` | Claude 4.5 Sonnet (Thinking) | -### 動態對映(自動) - -Cursor CLI 新增模型時(如 `opus-4.7`、`sonnet-5.0`),代理自動生成對應的 `claude-*` ID,無需手動更新。 - -規則:`-.` → `claude---` +Cursor CLI 新增模型時自動生成對應 `claude-*` ID,無需手動更新。 --- ## 帳號管理 -### 登入帳號 - ```bash +# 登入 ./cursor-api-proxy login myaccount # 使用代理登入 ./cursor-api-proxy login myaccount --proxy=http://127.0.0.1:7890 -``` -### 列出帳號 - -```bash +# 列出所有帳號 ./cursor-api-proxy accounts -``` -### 登出帳號 - -```bash +# 登出 ./cursor-api-proxy logout myaccount -``` -### 重置 HWID(反BAN) - -```bash -# 基本重置 +# 重置 HWID(反BAN) ./cursor-api-proxy reset-hwid # 深度清理(清除 session 和 cookies) ./cursor-api-proxy reset-hwid --deep-clean ``` -### 啟動選項 - -| 選項 | 說明 | -|------|------| -| `--tailscale` | 綁定到 `0.0.0.0` 供區域網路存取 | -| `-h, --help` | 顯示說明 | - --- ## API 端點 | 端點 | 方法 | 說明 | |------|------|------| -| `http://127.0.0.1:8765/v1/chat/completions` | POST | OpenAI 格式聊天完成 | -| `http://127.0.0.1:8765/v1/models` | GET | 列出可用模型 | -| `http://127.0.0.1:8765/v1/chat/messages` | POST | Anthropic 格式聊天 | -| `http://127.0.0.1:8765/health` | GET | 健康檢查 | +| `/v1/chat/completions` | POST | OpenAI 格式聊天完成 | +| `/v1/messages` | POST | Anthropic 格式聊天 | +| `/v1/models` | GET | 列出可用模型 | +| `/health` | GET | 健康檢查 | --- @@ -250,10 +283,10 @@ Cursor CLI 新增模型時(如 `opus-4.7`、`sonnet-5.0`),代理自動生 | 變數 | 預設值 | 說明 | |------|--------|------| -| `CURSOR_BRIDGE_HOST` | `127.0.0.1` | 監聽位址(設為 `0.0.0.0` 可供區域網路存取) | -| `CURSOR_BRIDGE_PORT` | `8765` | 監聽連接埠 | -| `CURSOR_BRIDGE_API_KEY` | _(無)_ | API 鑑別金鑰,設定後所有請求需帶此金鑰 | -| `CURSOR_BRIDGE_TIMEOUT_MS` | `300000` | 請求逾時毫秒數(預設 5 分鐘) | +| `CURSOR_BRIDGE_HOST` | `127.0.0.1` | 監聽位址(Docker 模式固定 `0.0.0.0`) | +| `CURSOR_BRIDGE_PORT` | `8766` | 監聽連接埠 | +| `CURSOR_BRIDGE_API_KEY` | _(無)_ | API 鑑別金鑰 | +| `CURSOR_BRIDGE_TIMEOUT_MS` | `3600000` | 請求逾時毫秒數(預設 1 小時) | | `CURSOR_BRIDGE_MULTI_PORT` | `false` | 啟用多連接埠模式 | | `CURSOR_BRIDGE_VERBOSE` | `false` | 啟用詳細日誌輸出 | @@ -261,12 +294,10 @@ Cursor CLI 新增模型時(如 `opus-4.7`、`sonnet-5.0`),代理自動生 | 變數 | 預設值 | 說明 | |------|--------|------| -| `CURSOR_AGENT_BIN` / `CURSOR_CLI_BIN` / `CURSOR_CLI_PATH` | `agent` | Cursor CLI 二進位檔路徑 | -| `CURSOR_AGENT_NODE` | _(無)_ | Node.js 執行檔路徑(Windows 使用) | -| `CURSOR_AGENT_SCRIPT` | _(無)_ | Agent 腳本路徑(Windows 使用) | +| `CURSOR_AGENT_BIN` | `agent` | Cursor CLI 二進位檔路徑 | | `CURSOR_BRIDGE_DEFAULT_MODEL` | `auto` | 預設使用的模型 ID | | `CURSOR_BRIDGE_STRICT_MODEL` | `true` | 嚴格模式:禁止使用不在清單中的模型 | -| `CURSOR_BRIDGE_MAX_MODE` | `false` | 啟用 Max Mode(消耗更多額度) | +| `CURSOR_BRIDGE_MAX_MODE` | `false` | 啟用 Max Mode | | `CURSOR_BRIDGE_FORCE` | `false` | 強制執行,不詢問確認 | | `CURSOR_BRIDGE_APPROVE_MCPS` | `false` | 自動核准 MCP 工具呼叫 | @@ -276,22 +307,15 @@ Cursor CLI 新增模型時(如 `opus-4.7`、`sonnet-5.0`),代理自動生 |------|--------|------| | `CURSOR_BRIDGE_WORKSPACE` | _(目前目錄)_ | 工作目錄路徑 | | `CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE` | `true` | 限制 agent 只能存取工作目錄 | -| `CURSOR_CONFIG_DIRS` / `CURSOR_ACCOUNT_DIRS` | _(自動探索)_ | 帳號設定目錄,多個用逗號分隔 | +| `CURSOR_CONFIG_DIRS` | _(自動探索)_ | 帳號設定目錄,多個用逗號分隔 | -### TLS / HTTPS +### Docker 專用(.env 設定) -| 變數 | 預設值 | 說明 | -|------|--------|------| -| `CURSOR_BRIDGE_TLS_CERT` | _(無)_ | TLS 憑證檔路徑(啟用 HTTPS) | -| `CURSOR_BRIDGE_TLS_KEY` | _(無)_ | TLS 私鑰檔路徑(啟用 HTTPS) | - -### 記錄與 Windows 特定 - -| 變數 | 預設值 | 說明 | -|------|--------|------| -| `CURSOR_BRIDGE_SESSIONS_LOG` | `~/.cursor-api-proxy/sessions.log` | Session 記錄檔路徑 | -| `CURSOR_BRIDGE_WIN_CMDLINE_MAX` | `30000` | Windows 命令列最大長度(4096–32700) | -| `COMSPEC` | `cmd.exe` | Windows 命令直譯器路徑 | +| 變數 | 說明 | +|------|------| +| `CURSOR_AGENT_HOST_BIN` | 宿主機 Cursor agent 二進位檔路徑(掛載進容器) | +| `CURSOR_ACCOUNTS_DIR` | 宿主機帳號資料目錄(掛載進容器) | +| `WORKSPACE_DIR` | 宿主機工作區目錄(掛載進容器) | --- @@ -300,17 +324,17 @@ Cursor CLI 新增模型時(如 `opus-4.7`、`sonnet-5.0`),代理自動生 **Q: 為什麼需要登入帳號?** A: Cursor API 需要驗證才能使用,請先登入你的 Cursor 帳號。 +**Q: Docker 容器裡找不到 agent?** +A: 確認 `.env` 的 `CURSOR_AGENT_HOST_BIN` 填的是宿主機上正確的路徑,容器會把它掛載到 `/usr/local/bin/agent`。 + **Q: 如何處理被BAN的問題?** A: 使用 `reset-hwid` 命令重置機器識別碼,加上 `--deep-clean` 進行更徹底的清理。 **Q: 可以在其他設備上使用嗎?** -A: 可以,使用 `--tailscale` 選項啟動伺服器,然後透過區域網路 IP 存取。 +A: 可以。Docker 模式下 `CURSOR_BRIDGE_HOST` 預設為 `0.0.0.0`,開放區域網路存取。其他設備將 `ANTHROPIC_BASE_URL` 指向這台電腦的 IP 即可。 **Q: 模型列表多久更新一次?** -A: 每次呼叫 `GET /v1/models` 時,代理會即時呼叫 Cursor CLI 的 `--list-models` 取得最新模型,並自動生成對應的 `claude-*` ID。 - -**Q: 未來 Cursor 新增模型怎麼辦?** -A: 不用改任何東西。只要新模型符合 `-.` 命名規則,代理會自動生成對應的 `claude-*` ID。 +A: 每次呼叫 `GET /v1/models` 時即時更新,自動生成對應的 `claude-*` ID。 --- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7355fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + cursor-api-proxy: + build: + context: . + dockerfile: Dockerfile + image: cursor-api-proxy:latest + container_name: cursor-api-proxy + restart: unless-stopped + env_file: + - .env + ports: + - "${CURSOR_BRIDGE_PORT:-8766}:${CURSOR_BRIDGE_PORT:-8766}" + environment: + - CURSOR_BRIDGE_HOST=0.0.0.0 + volumes: + # Cursor CLI 二進位檔(從宿主機掛載,唯讀) + - ${CURSOR_AGENT_HOST_BIN:-/usr/local/bin/agent}:/usr/local/bin/agent:ro + # 帳號設定目錄(持久化帳號資料) + - ${CURSOR_ACCOUNTS_DIR:-~/.cursor-api-proxy}:/root/.cursor-api-proxy + # 工作區(選用,掛載你想讓 agent 存取的專案目錄) + - ${WORKSPACE_DIR:-/tmp/workspace}:/workspace + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:${CURSOR_BRIDGE_PORT:-8766}/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/internal/agent/cmdargs.go b/internal/agent/cmdargs.go index 9b4cd2e..cfb1030 100644 --- a/internal/agent/cmdargs.go +++ b/internal/agent/cmdargs.go @@ -3,7 +3,7 @@ package agent import "cursor-api-proxy/internal/config" func BuildAgentFixedArgs(cfg config.BridgeConfig, workspaceDir, model string, stream bool) []string { - args := []string{"--print", "--plan"} + args := []string{"--print"} if cfg.ApproveMcps { args = append(args, "--approve-mcps") } diff --git a/internal/env/env.go b/internal/env/env.go index 239e3d8..fdd843c 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -273,7 +273,7 @@ func LoadEnvConfig(e EnvSource, cwd string) LoadedEnv { TLSCertPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_CERT"}), cwd), TLSKeyPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_KEY"}), cwd), SessionsLogPath: sessionsLogPath, - ChatOnlyWorkspace: envBool(e, []string{"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"}, true), + ChatOnlyWorkspace: envBool(e, []string{"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"}, false), Verbose: envBool(e, []string{"CURSOR_BRIDGE_VERBOSE"}, false), MaxMode: envBool(e, []string{"CURSOR_BRIDGE_MAX_MODE"}, false), ConfigDirs: configDirs, diff --git a/internal/handlers/anthropic_handler.go b/internal/handlers/anthropic_handler.go index e6f26da..8b86377 100644 --- a/internal/handlers/anthropic_handler.go +++ b/internal/handlers/anthropic_handler.go @@ -16,9 +16,10 @@ import ( "cursor-api-proxy/internal/winlimit" "cursor-api-proxy/internal/workspace" "encoding/json" - "strings" "fmt" "net/http" + "regexp" + "strings" "time" "github.com/google/uuid" @@ -166,12 +167,44 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. }) if hasTools { - // tools 模式:先累積所有內容,完成後再一次性輸出(因為 tool_calls 需要完整解析) + // tools 模式:先即時串流文字,一旦偵測到 tool call 標記就切換為累積模式 + toolCallMarkerRe := regexp.MustCompile(`|`) + var toolCallMode bool + + textBlockOpen := false + textBlockIndex := 0 + p = parser.CreateStreamParserWithThinking( func(text string) { accumulated += text chunkNum++ logger.LogStreamChunk(model, text, chunkNum) + + if toolCallMode { + return + } + if toolCallMarkerRe.MatchString(text) { + if textBlockOpen { + writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex}) + textBlockOpen = false + } + toolCallMode = true + return + } + if !textBlockOpen { + textBlockIndex = 0 + writeEvent(map[string]interface{}{ + "type": "content_block_start", + "index": textBlockIndex, + "content_block": map[string]string{"type": "text", "text": ""}, + }) + textBlockOpen = true + } + writeEvent(map[string]interface{}{ + "type": "content_block_delta", + "index": textBlockIndex, + "delta": map[string]string{"type": "text_delta", "text": text}, + }) }, func(thinking string) { accumulatedThinking += thinking @@ -195,7 +228,11 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. } if parsed.HasToolCalls() { - if parsed.TextContent != "" { + if textBlockOpen { + writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex}) + blockIndex = textBlockIndex + 1 + } + if parsed.TextContent != "" && !textBlockOpen && !toolCallMode { writeEvent(map[string]interface{}{ "type": "content_block_start", "index": blockIndex, "content_block": map[string]string{"type": "text", "text": ""}, @@ -236,17 +273,27 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. }) writeEvent(map[string]interface{}{"type": "message_stop"}) } else { - writeEvent(map[string]interface{}{ - "type": "content_block_start", "index": blockIndex, - "content_block": map[string]string{"type": "text", "text": ""}, - }) - if accumulated != "" { + if textBlockOpen { + writeEvent(map[string]interface{}{"type": "content_block_stop", "index": textBlockIndex}) + } else if accumulated != "" { + writeEvent(map[string]interface{}{ + "type": "content_block_start", "index": blockIndex, + "content_block": map[string]string{"type": "text", "text": ""}, + }) writeEvent(map[string]interface{}{ "type": "content_block_delta", "index": blockIndex, "delta": map[string]string{"type": "text_delta", "text": accumulated}, }) + writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex}) + blockIndex++ + } else { + writeEvent(map[string]interface{}{ + "type": "content_block_start", "index": blockIndex, + "content_block": map[string]string{"type": "text", "text": ""}, + }) + writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex}) + blockIndex++ } - writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockIndex}) writeEvent(map[string]interface{}{ "type": "message_delta", "delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil}, @@ -258,9 +305,6 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. ) } else { // 非 tools 模式:即時串流 thinking 和 text - // blockCount 追蹤已開啟的 block 數量 - // thinkingOpen 代表 thinking block 是否已開啟且尚未關閉 - // textOpen 代表 text block 是否已開啟且尚未關閉 blockCount := 0 thinkingOpen := false textOpen := false @@ -270,12 +314,10 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. accumulated += text chunkNum++ logger.LogStreamChunk(model, text, chunkNum) - // 若 thinking block 尚未關閉,先關閉它 if thinkingOpen { writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1}) thinkingOpen = false } - // 若 text block 尚未開啟,先開啟它 if !textOpen { writeEvent(map[string]interface{}{ "type": "content_block_start", @@ -294,7 +336,6 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. func(thinking string) { accumulatedThinking += thinking chunkNum++ - // 若 thinking block 尚未開啟,先開啟它 if !thinkingOpen { writeEvent(map[string]interface{}{ "type": "content_block_start", @@ -312,12 +353,10 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. }, func() { logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true) - // 關閉尚未關閉的 thinking block if thinkingOpen { writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1}) thinkingOpen = false } - // 若 text block 尚未開啟(全部都是 thinking,沒有 text),開啟並立即關閉空的 text block if !textOpen { writeEvent(map[string]interface{}{ "type": "content_block_start", @@ -326,7 +365,6 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. }) blockCount++ } - // 關閉 text block writeEvent(map[string]interface{}{"type": "content_block_stop", "index": blockCount - 1}) writeEvent(map[string]interface{}{ "type": "message_delta", @@ -351,7 +389,6 @@ func HandleAnthropicMessages(w http.ResponseWriter, r *http.Request, cfg config. } result, err := agent.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, ctx) - // agent 結束後,若未收到 result/success 訊號,強制 flush 以確保 SSE stream 正確結尾 if ctx.Err() == nil { p.Flush() }