Compare commits

..

22 Commits

Author SHA1 Message Date
王性驊 f9a92b0bfa add refactor doc 2026-04-06 22:21:32 +08:00
王性驊 3dc49bfc7d fix: remove duplicate Verbose field and use raw JSON parsing for handlers
- Remove Verbose from Config struct (inherited from RestConf)
- Remove Verbose from yaml config to fix conflict
- Use raw JSON parsing in handlers for interface{} Content field
- Fix config tests
2026-04-04 12:49:32 +08:00
王性驊 9e2a10b614 feat: complete implementation of all endpoints and config
- Update etc/chat-api.yaml with all configuration options (no env vars)
- Add ToBridgeConfig method to Config for YAML-based config
- Implement complete AnthropicMessages streaming with SSE
- Add Tools field to AnthropicRequest
- Update ServiceContext with model tracking
- Add all CRUD handlers for health, models, chat, anthropic

Features restored:
- Health check endpoint
- Models list with caching
- ChatCompletions streaming (OpenAI format)
- AnthropicMessages streaming (Anthropic format)
- Tool calls support for both formats
- Thinking/reasoning content support
- Rate limit detection and handling
- Account pool integration
- Request/response logging
- Model resolution with strict mode
- Workspace and prompt truncation handling
2026-04-03 23:17:04 +08:00
王性驊 3387887fb9 feat(logic): implement complete ChatCompletions streaming with SSE
- Full SSE streaming support with proper event format
- Tool calls handling with marker detection
- Rate limit detection and reporting
- Account pool integration (round-robin, stats tracking)
- Request/response logging and traffic tracking
- Thinking/reasoning content support
- Model resolution with strict mode
- Workspace and prompt truncation handling
- Added X-Accel-Buffering header for proper SSE
- Pass method/path for logging context
2026-04-03 23:04:28 +08:00
王性驊 f90d72b279 feat(logic): implement logic layer for health, models, and chat completions
- HealthLogic: simple health check response
- ModelsLogic: list Cursor CLI models with caching and Anthropic aliases
- ChatCompletionsLogic: scaffold for OpenAI-format completions (streaming placeholder)
- AnthropicMessagesLogic: scaffold for Anthropic format (TODO)
- Update handler for SSE streaming support
- Add models.go with ListCursorCliModels and model mappings
2026-04-03 23:00:18 +08:00
王性驊 081f404f77 Task 9: Cleanup - remove old internal files, update import paths, add go-zero entry point
- Removed old files from internal/* (migrated to pkg/*)
- Removed old CLI files from cmd/ (now in cmd/cli/)
- Updated import paths (internal/* -> pkg/*)
- Rewrote main.go to support CLI commands + go-zero HTTP server
- Fixed AccountStat type references (use entity.AccountStat)
- Fixed cmd/cli/* to use usecase package instead of agent
- Fixed logger to use entity.AccountStat
- Fixed geminiweb fmt.Println redundant newlines
- Fixed scripts/detect-gemini-dom.go pointer format issues
2026-04-03 22:54:18 +08:00
王性驊 7e0b7a970c refactor(task-7): integrate all layers and fix type mismatches
- Merge all branches (domain, infrastructure, repository, provider, adapter, usecase, cli)
- Update ServiceContext to inject dependencies
- Fix Message.Content type mismatch (string vs interface{})
- Update AccountStat to use entity.AccountStat
- Add helper functions for content conversion
2026-04-03 17:49:11 +08:00
王性驊 8f1b7159ed merge: refactor/cli 2026-04-03 17:46:37 +08:00
王性驊 e379c79bd1 merge: refactor/usecase 2026-04-03 17:46:37 +08:00
王性驊 ef4b6519f5 merge: refactor/adapter 2026-04-03 17:46:36 +08:00
王性驊 7b82986dca merge: refactor/provider 2026-04-03 17:46:33 +08:00
王性驊 e5f19c243b merge: refactor/repository 2026-04-03 17:46:32 +08:00
王性驊 83418d5e76 merge: refactor/infrastructure 2026-04-03 17:46:32 +08:00
王性驊 5866a5b9c9 refactor(task-5): add usecase layer
- Migrate agent runner from internal/agent
- Migrate sanitizer from internal/sanitize
- Migrate toolcall from internal/toolcall
- Update import paths to use pkg/usecase
2026-04-03 17:38:16 +08:00
王性驊 270accfd75 refactor(task-4): migrate providers to pkg/provider
- Migrate cursor provider
- Migrate geminiweb provider (playwright-based)
- Update import paths to use pkg/domain/entity
2026-04-03 17:35:23 +08:00
王性驊 f9f3c5fb42 refactor(task-6): migrate adapters to pkg/adapter
- Migrate OpenAI adapter
- Migrate Anthropic adapter
- Update import paths
2026-04-03 17:33:53 +08:00
王性驊 d4fcb8d3b8 refactor(task-3): add repository layer implementation
- Migrate AccountPool from internal/pool
- Update import paths to use pkg/repository
2026-04-03 17:33:51 +08:00
王性驊 3a005ea02e refactor(task-8): migrate CLI tools to cmd/cli
- Migrate args.go, login.go, accounts.go
- Migrate resethwid.go, usage.go, sqlite.go
2026-04-03 17:22:52 +08:00
王性驊 80d7a4bb29 refactor(task-2): migrate infrastructure layer
- Migrate process (runner, kill_unix, kill_windows)
- Migrate parser (stream)
- Migrate httputil (httputil)
- Migrate logger (logger)
- Migrate env (env)
- Migrate workspace (workspace)
- Migrate winlimit (winlimit)
2026-04-03 17:22:51 +08:00
王性驊 294bd74a43 refactor(task-1): add domain layer with entities and interfaces
- Add entity types: Message, Tool, ToolCall, Chunk, Account
- Add repository interfaces: AccountPool, Provider
- Add usecase interfaces: ChatUsecase, AgentRunner
- Add model constants and error definitions
2026-04-03 17:22:48 +08:00
王性驊 8b6abbbba7 refactor(task-0): initialize go-zero project structure
- Add go-zero dependency to go.mod
- Create api/chat.api with OpenAI-compatible types
- Create etc/chat.yaml configuration
- Update Makefile with goctl commands
- Generate go-zero scaffold (types, handlers, logic, svc)
- Move chat.go to cmd/chat/chat.go
- Add Config struct for go-zero (keep BridgeConfig for backward compatibility)
2026-04-03 17:15:35 +08:00
王性驊 b18e3d82f0 add refactor doc 2026-04-03 10:17:17 +08:00
105 changed files with 11865 additions and 5625 deletions

5
.gitignore vendored
View File

@ -1,5 +1,4 @@
.idea/
bin/
cursor-api-proxy*
.env
cursor-adapter
cursor-mcp-server
.opencode

View File

@ -0,0 +1,462 @@
# REFACTOR TASKS
重構任務拆分,支援 git worktree 並行開發。
---
## Task Overview
### 並行策略
```
時間軸 ──────────────────────────────────────────────────────────────►
Task 0: Init (必須先完成)
├── Task 1: Domain Layer ─────────────────────────┐
│ │
│ ┌── Task 2: Infrastructure Layer ────────────┤── 並行
│ │ │
│ └── Task 3: Repository Layer ────────────────┘
│ (依賴 Task 1)
├── Task 4: Provider Layer ──────────────────────┐
│ (依賴 Task 1) │
│ │── 可並行
├── Task 5: Usecase Layer ───────────────────────┤
│ (依賴 Task 3) │
│ │
├── Task 6: Adapter Layer ───────────────────────┘
│ (依賴 Task 1)
├── Task 7: Internal Layer ──────────────────────┐
│ (整合所有,必須最後) │
│ │── 序列
├── Task 8: CLI Tools │
│ │
└── Task 9: Cleanup & Tests ────────────────────┘
```
### Worktree 分支規劃
| 分支名稱 | 基於 | 任務 | 可並行 |
|---------|------|------|--------|
| `refactor/init` | `master` | Task 0 | ❌ |
| `refactor/domain` | `refactor/init` | Task 1 | ✅ |
| `refactor/infrastructure` | `refactor/init` | Task 2 | ✅ |
| `refactor/repository` | `refactor/domain` | Task 3 | ✅ |
| `refactor/provider` | `refactor/domain` | Task 4 | ✅ |
| `refactor/usecase` | `refactor/repository` | Task 5 | ✅ |
| `refactor/adapter` | `refactor/domain` | Task 6 | ✅ |
| `refactor/internal` | 合併所有 | Task 7 | ❌ |
| `refactor/cli` | `refactor/init` | Task 8 | ✅ |
| `refactor/cleanup` | 合併所有 | Task 9 | ❌ |
---
## Task 0: 初始化
### 分支
`refactor/init`
### 依賴
無(必須先完成)
### 小任務
- [ ] **0.1** 更新 go.mod (5min)
- `go get github.com/zeromicro/go-zero@latest`
- `go mod tidy`
- [ ] **0.2** 建立目錄 (1min)
- `mkdir -p api etc`
- [ ] **0.3** 建立 `api/chat.api` (15min)
- 定義 API types
- 定義 routes
- [ ] **0.4** 建立 `etc/chat.yaml` (5min)
- 配置參數
- [ ] **0.5** 更新 Makefile (10min)
- 新增 goctl 命令
- [ ] **0.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 1: Domain Layer
### 分支
`refactor/domain`
### 依賴
Task 0 完成
### 小任務
- [ ] **1.1** 建立目錄結構 (1min)
- `pkg/domain/entity`
- `pkg/domain/repository`
- `pkg/domain/usecase`
- `pkg/domain/const`
- [ ] **1.2** `entity/message.go` (10min)
- Message, Tool, ToolFunction, ToolCall
- [ ] **1.3** `entity/chunk.go` (5min)
- StreamChunk, ChunkType
- [ ] **1.4** `entity/account.go` (5min)
- Account, AccountStat
- [ ] **1.5** `repository/account.go` (10min)
- AccountPool interface
- [ ] **1.6** `repository/provider.go` (5min)
- Provider interface
- [ ] **1.7** `usecase/chat.go` (15min)
- ChatUsecase interface
- [ ] **1.8** `usecase/agent.go` (5min)
- AgentRunner interface
- [ ] **1.9** `const/models.go` (10min)
- Model 常數
- [ ] **1.10** `const/errors.go` (5min)
- 錯誤定義
- [ ] **1.11** 提交 (2min)
**預估時間**: ~2h
---
## Task 2: Infrastructure Layer
### 分支
`refactor/infrastructure`
### 依賴
Task 0 完成(可與 Task 1 並行)
### 小任務
- [ ] **2.1** 建立目錄 (2min)
- `pkg/infrastructure/{process,parser,httputil,logger,env,workspace,winlimit}`
- [ ] **2.2** 遷移 process (10min)
- runner.go, kill_unix.go, kill_windows.go, process_test.go
- [ ] **2.3** 遷移 parser (5min)
- stream.go, stream_test.go
- [ ] **2.4** 遷移 httputil (5min)
- httputil.go, httputil_test.go
- [ ] **2.5** 遷移 logger (5min)
- logger.go
- [ ] **2.6** 遷移 env (5min)
- env.go, env_test.go
- [ ] **2.7** 遷移 workspace (5min)
- workspace.go
- [ ] **2.8** 遷移 winlimit (5min)
- winlimit.go, winlimit_test.go
- [ ] **2.9** 驗證編譯 (5min)
- [ ] **2.10** 提交 (2min)
**預估時間**: ~1h
---
## Task 3: Repository Layer
### 分支
`refactor/repository`
### 依賴
Task 1 完成
### 小任務
- [ ] **3.1** 建立目錄 (1min)
- [ ] **3.2** 遷移 account.go (20min)
- AccountPool 實作
- 移除全局變數
- [ ] **3.3** 遷移 provider.go (10min)
- Provider 工廠
- [ ] **3.4** 遷移測試 (5min)
- [ ] **3.5** 驗證編譯 (5min)
- [ ] **3.6** 提交 (2min)
**預估時間**: ~1h
---
## Task 4: Provider Layer
### 分支
`refactor/provider`
### 依賴
Task 1 完成
### 小任務
- [ ] **4.1** 建立目錄 (1min)
- `pkg/provider/cursor`
- `pkg/provider/geminiweb`
- [ ] **4.2** 遷移 cursor provider (5min)
- [ ] **4.3** 遷移 geminiweb provider (10min)
- [ ] **4.4** 更新 import (5min)
- [ ] **4.5** 驗證編譯 (5min)
- [ ] **4.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 5: Usecase Layer
### 分支
`refactor/usecase`
### 依賴
Task 3 完成
### 小任務
- [ ] **5.1** 建立目錄 (1min)
- [ ] **5.2** 建立 chat.go (30min)
- 核心聊天邏輯
- [ ] **5.3** 遷移 agent.go (20min)
- runner, token, cmdargs, maxmode
- [ ] **5.4** 遷移 sanitizer (10min)
- [ ] **5.5** 遷移 toolcall (10min)
- [ ] **5.6** 驗證編譯 (5min)
- [ ] **5.7** 提交 (2min)
**預估時間**: ~2h
---
## Task 6: Adapter Layer
### 分支
`refactor/adapter`
### 依賴
Task 1 完成
### 小任務
- [ ] **6.1** 建立目錄 (1min)
- [ ] **6.2** 遷移 openai adapter (10min)
- [ ] **6.3** 遷移 anthropic adapter (10min)
- [ ] **6.4** 更新 import (5min)
- [ ] **6.5** 驗證編譯 (5min)
- [ ] **6.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 7: Internal Layer
### 分支
`refactor/internal`
### 依賴
Task 1-6 全部完成
### 小任務
- [ ] **7.1** 合併所有分支 (5min)
- [ ] **7.2** 更新 config/config.go (15min)
- 使用 rest.RestConf
- [ ] **7.3** 建立 svc/servicecontext.go (30min)
- DI 容器
- [ ] **7.4** 建立 logic/ (1h)
- chatcompletionlogic.go
- geminichatlogic.go
- anthropiclogic.go
- healthlogic.go
- modelslogic.go
- [ ] **7.5** 建立 handler/ (1h)
- 自訂 SSE handler
- [ ] **7.6** 建立 middleware/ (20min)
- auth.go
- recovery.go
- [ ] **7.7** 建立 types/ (5min)
- goctl 生成
- [ ] **7.8** 更新 import (30min)
- 批量更新
- [ ] **7.9** 驗證編譯 (10min)
- [ ] **7.10** 提交 (2min)
**預估時間**: ~4h
---
## Task 8: CLI Tools
### 分支
`refactor/cli`
### 依賴
Task 0 完成
### 小任務
- [ ] **8.1** 建立目錄 (1min)
- [ ] **8.2** 遷移 CLI 工具 (10min)
- [ ] **8.3** 遷移 gemini-login (5min)
- [ ] **8.4** 更新 import (5min)
- [ ] **8.5** 提交 (2min)
**預估時間**: ~30min
---
## Task 9: Cleanup & Tests
### 分支
`refactor/cleanup`
### 依賴
Task 7 完成
### 小任務
- [ ] **9.1** 移除舊目錄 (5min)
- [ ] **9.2** 更新 import (30min)
- 批量 sed
- [ ] **9.3** 建立 cmd/chat/chat.go (10min)
- [ ] **9.4** SSE 整合測試 (2h)
- [ ] **9.5** 回歸測試 (1h)
- [ ] **9.6** 更新 README (15min)
- [ ] **9.7** 提交 (2min)
**預估時間**: ~4h
---
## 並行執行計劃
### Wave 1 (可完全並行)
```
Terminal 1: Task 0 (init) → 30min
Terminal 2: (等待 Task 0)
```
### Wave 2 (可完全並行)
```
Terminal 1: Task 1 (domain) → 2h
Terminal 2: Task 2 (infrastructure) → 1h
Terminal 3: Task 8 (cli) → 30min
```
### Wave 3 (可部分並行)
```
Terminal 1: Task 3 (repository) → 1h (依賴 Task 1)
Terminal 2: Task 4 (provider) → 30min (依賴 Task 1)
Terminal 3: Task 6 (adapter) → 30min (依賴 Task 1)
Terminal 4: (等待 Task 3)
```
### Wave 4 (可部分並行)
```
Terminal 1: Task 5 (usecase) → 2h (依賴 Task 3)
Terminal 2: (等待 Task 5)
```
### Wave 5 (序列)
```
Task 7 (internal) → 4h
Task 9 (cleanup) → 4h
```
**總時間估計**:
- 完全序列: ~15h
- 並行執行: ~9h
- 節省: ~40%
---
## Git Worktree 指令
```bash
# 創建 worktrees
git worktree add ../worktrees/init -b refactor/init
git worktree add ../worktrees/domain -b refactor/domain
git worktree add ../worktrees/infrastructure -b refactor/infrastructure
git worktree add ../worktrees/repository -b refactor/repository
git worktree add ../worktrees/provider -b refactor/provider
git worktree add ../worktrees/usecase -b refactor/usecase
git worktree add ../worktrees/adapter -b refactor/adapter
git worktree add ../worktrees/cli -b refactor/cli
# 並行工作
cd ../worktrees/domain && # Terminal 1
cd ../worktrees/infrastructure && # Terminal 2
cd ../worktrees/cli && # Terminal 3
# 清理 worktrees
git worktree remove ../worktrees/init
git worktree remove ../worktrees/domain
# ... 等等
```
---
**文件版本**: v1.0
**建立日期**: 2026-04-03

339
Makefile Normal file
View File

@ -0,0 +1,339 @@
# ──────────────────────────────────────────────
# cursor-api-proxy — 設定與建置
# 編輯下方變數,然後執行 make env 產生 .env 檔
# ──────────────────────────────────────────────
# ── go-zero 代碼生成 ───────────────────────────
.PHONY: api api-doc gen fmt lint
api:
goctl api go -api api/chat.api -dir . --style go_zero
api-doc:
goctl api doc -api api/chat.api -dir docs/api/
gen: api
go mod tidy
go fmt ./...
fmt:
go fmt ./...
lint:
go vet ./...
go fmt ./...
# ── 伺服器設定 ─────────────────────────────────
HOST ?= 127.0.0.1
PORT ?= 8766
API_KEY ?=
TIMEOUT_MS ?= 3600000
MULTI_PORT ?= false
VERBOSE ?= false
# ── Agent / 模型設定 ──────────────────────────
AGENT_BIN ?= agent
AGENT_NODE ?=
AGENT_SCRIPT ?=
DEFAULT_MODEL ?= auto
STRICT_MODEL ?= true
MAX_MODE ?= false
FORCE ?= false
APPROVE_MCPS ?= false
# ── 工作區與帳號 ──────────────────────────────
WORKSPACE ?=
CHAT_ONLY_WORKSPACE ?= true
CONFIG_DIRS ?=
# ── OpenCode 模型設定 ────────────────────
OPENCODE_MODEL ?= cursor/claude-4.6-sonnet-medium
OPENCODE_SMALL_MODEL ?= cursor/gpt-5.4-nano-medium
# ── Cursor / Claude Code~/.claude────────────────
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
ANTHROPIC_BASE_HOST ?= $(HOST)
# ── TLS / HTTPS ───────────────────────────────
TLS_CERT ?=
TLS_KEY ?=
# ── Gemini Web Provider ───────────────────────
PROVIDER ?= cursor
GEMINI_ACCOUNT_DIR ?=
GEMINI_BROWSER_VISIBLE ?= false
GEMINI_MAX_SESSIONS ?= 3
# ── 記錄 ──────────────────────────────────────
SESSIONS_LOG ?=
# ──────────────────────────────────────────────
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 \
docker-build docker-up docker-down docker-logs docker-restart docker-shell docker-env docker-setup
## 產生 .env 檔(預設輸出至 .env可用 ENV_FILE=xxx 覆寫)
env:
@printf '# 由 make env 自動產生,請勿手動編輯\n' > $(ENV_FILE)
@printf 'CURSOR_BRIDGE_HOST=%s\n' "$(HOST)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_PORT=%s\n' "$(PORT)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_API_KEY=%s\n' "$(API_KEY)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_TIMEOUT_MS=%s\n' "$(TIMEOUT_MS)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_MULTI_PORT=%s\n' "$(MULTI_PORT)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_VERBOSE=%s\n' "$(VERBOSE)" >> $(ENV_FILE)
@printf 'CURSOR_AGENT_BIN=%s\n' "$(AGENT_BIN)" >> $(ENV_FILE)
@printf 'CURSOR_AGENT_NODE=%s\n' "$(AGENT_NODE)" >> $(ENV_FILE)
@printf 'CURSOR_AGENT_SCRIPT=%s\n' "$(AGENT_SCRIPT)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_DEFAULT_MODEL=%s\n' "$(DEFAULT_MODEL)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_STRICT_MODEL=%s\n' "$(STRICT_MODEL)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_MAX_MODE=%s\n' "$(MAX_MODE)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_FORCE=%s\n' "$(FORCE)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_APPROVE_MCPS=%s\n' "$(APPROVE_MCPS)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_WORKSPACE=%s\n' "$(WORKSPACE)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE=%s\n' "$(CHAT_ONLY_WORKSPACE)" >> $(ENV_FILE)
@printf 'CURSOR_CONFIG_DIRS=%s\n' "$(CONFIG_DIRS)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_TLS_CERT=%s\n' "$(TLS_CERT)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_TLS_KEY=%s\n' "$(TLS_KEY)" >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_SESSIONS_LOG=%s\n' "$(SESSIONS_LOG)" >> $(ENV_FILE)
@printf '# ── Provider 設定 ───────────────────────────\n' >> $(ENV_FILE)
@printf 'CURSOR_BRIDGE_PROVIDER=%s\n' "$(PROVIDER)" >> $(ENV_FILE)
@printf '# Gemini Web Provider 設定(當 PROVIDER=gemini-web 時使用)\n' >> $(ENV_FILE)
@printf 'GEMINI_ACCOUNT_DIR=%s\n' "$(GEMINI_ACCOUNT_DIR)" >> $(ENV_FILE)
@printf 'GEMINI_BROWSER_VISIBLE=%s\n' "$(GEMINI_BROWSER_VISIBLE)" >> $(ENV_FILE)
@printf 'GEMINI_MAX_SESSIONS=%s\n' "$(GEMINI_MAX_SESSIONS)" >> $(ENV_FILE)
@echo "已產生 $(ENV_FILE)"
## 編譯二進位檔
build:
go build -o cursor-api-proxy .
## 載入 .env 後直接執行(需先執行 make env 或已有 .env
run: build
@if [ -f $(ENV_FILE) ]; then \
set -a && . ./$(ENV_FILE) && set +a && ./cursor-api-proxy; \
else \
echo "找不到 $(ENV_FILE),請先執行 make env"; exit 1; \
fi
## 清除產出物
clean:
rm -f cursor-api-proxy $(ENV_FILE)
## 設定 OpenCode 使用此代理(更新 opencode.json 的 cursor 與 gemini-web provider
opencode: build
@if [ ! -f "$(OPENCODE_CONFIG)" ]; then \
echo "找不到 $(OPENCODE_CONFIG),建立新設定檔"; \
mkdir -p $$(dirname "$(OPENCODE_CONFIG)"); \
printf '{\n "model": "$(OPENCODE_MODEL)",\n "small_model": "$(OPENCODE_SMALL_MODEL)",\n "provider": {\n "cursor": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Cursor Agent",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": { "auto": { "name": "Cursor Auto" } }\n },\n "gemini-web": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Gemini Web",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": {\n "gemini-2.0-flash": { "name": "Gemini 2.0 Flash" },\n "gemini-2.5-pro": { "name": "Gemini 2.5 Pro" },\n "gemini-2.5-pro-thinking": { "name": "Gemini 2.5 Pro Thinking" }\n }\n }\n }\n}\n' > "$(OPENCODE_CONFIG)"; \
echo "已建立 $(OPENCODE_CONFIG)(包含 cursor 與 gemini-web provider"; \
elif [ -n "$(API_KEY)" ]; then \
jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" --arg key "$(API_KEY)" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider.cursor.options.apiKey = $$key | .provider["gemini-web"].options.baseURL = $$base | .provider["gemini-web"].options.apiKey = $$key' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
echo "已更新 $(OPENCODE_CONFIG)model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1apiKey 已設定)"; \
else \
jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider["gemini-web"].options.baseURL = $$base' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
echo "已更新 $(OPENCODE_CONFIG)model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1"; \
fi
## 啟動代理並用 curl 同步模型列表到 opencode.json
opencode-models: opencode
@echo "啟動代理以取得模型列表..."
@set -a && . ./$(ENV_FILE) 2>/dev/null; set +a; \
./cursor-api-proxy & PID=$$!; \
sleep 2; \
MODELS=$$(curl -s http://$(HOST):$(PORT)/v1/models | jq '[.data[].id]'); \
kill $$PID 2>/dev/null; wait $$PID 2>/dev/null; \
if [ -n "$$MODELS" ] && [ "$$MODELS" != "null" ]; then \
jq --argjson ids "$$MODELS" 'reduce $ids[] as $id (.; .provider.cursor.models[$id] = { name: $id } | .provider["gemini-web"].models[$id] = { name: $id })' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \
echo "已同步模型列表到 $(OPENCODE_CONFIG)cursor 與 gemini-web"; \
else \
echo "無法取得模型列表,請確認代理已啟動"; \
fi
## 編譯並用 pm2 啟動
pm2: build
@if [ -f "$(ENV_FILE)" ]; then \
env $$(cat $(ENV_FILE) | grep -v '^#' | xargs) CURSOR_BRIDGE_HOST=$(HOST) CURSOR_BRIDGE_PORT=$(PORT) pm2 start ./cursor-api-proxy --name cursor-api-proxy --update-env; \
else \
CURSOR_BRIDGE_HOST=$(HOST) CURSOR_BRIDGE_PORT=$(PORT) pm2 start ./cursor-api-proxy --name cursor-api-proxy; \
fi
@pm2 save
@echo "pm2 已啟動 cursor-api-proxyhttp://$(HOST):$(PORT)"
## 用 pm2 啟動 OpenCode 代理(設定 + 啟動一步完成)
pm2-opencode: opencode pm2
@echo "OpenCode 設定已更新並用 pm2 啟動代理"
## 寫入 ~/.claude/settings.jsonANTHROPIC_BASE_URL、三個 DEFAULT_* 模型;需 jq
claude-settings:
@command -v jq >/dev/null 2>&1 || { echo "需要 jq"; exit 1; }
@mkdir -p $$(dirname "$(CLAUDE_SETTINGS)")
@jq -n \
--arg base "http://$(ANTHROPIC_BASE_HOST):$(PORT)" \
--arg token "$(ANTHROPIC_AUTH_TOKEN)" \
--arg sonnet "$(ANTHROPIC_DEFAULT_SONNET_MODEL)" \
--arg opus "$(ANTHROPIC_DEFAULT_OPUS_MODEL)" \
--arg haiku "$(ANTHROPIC_DEFAULT_HAIKU_MODEL)" \
'{ env: { ANTHROPIC_BASE_URL: $$base, ANTHROPIC_AUTH_TOKEN: $$token, ANTHROPIC_DEFAULT_SONNET_MODEL: $$sonnet, ANTHROPIC_DEFAULT_OPUS_MODEL: $$opus, ANTHROPIC_DEFAULT_HAIKU_MODEL: $$haiku } }' \
> "$(CLAUDE_SETTINGS).tmp" && mv "$(CLAUDE_SETTINGS).tmp" "$(CLAUDE_SETTINGS)"
@echo "已寫入 $(CLAUDE_SETTINGS)BASE_URL=http://$(ANTHROPIC_BASE_HOST):$(PORT)"
## 將 ~/.claude.json 的 hasCompletedOnboarding 設為 true繞過初次引導需 jq
claude-onboarding:
@command -v jq >/dev/null 2>&1 || { echo "需要 jq"; exit 1; }
@test -f "$(CLAUDE_JSON)" || { echo "找不到 $(CLAUDE_JSON)"; exit 1; }
@jq '.hasCompletedOnboarding = true' "$(CLAUDE_JSON)" > "$(CLAUDE_JSON).tmp" && mv "$(CLAUDE_JSON).tmp" "$(CLAUDE_JSON)"
@echo "已設定 $(CLAUDE_JSON) hasCompletedOnboarding=true"
## 一次執行 claude-settings + claude-onboarding
claude-cursor-setup: claude-settings claude-onboarding
@echo "Cursor/Claude Code 本機設定已套用"
## 編譯並用 pm2 啟動 + 設定 Claude Code 環境變數
pm2-claude-code: pm2
@echo ""
@echo "Claude Code 設定:將以下指令加入你的 shell 啟動檔(~/.bashrc 或 ~/.zshrc"
@echo ""
@echo " export ANTHROPIC_BASE_URL=http://$(HOST):$(PORT)"
@echo " export ANTHROPIC_API_KEY=$(if $(API_KEY),$(API_KEY),dummy-key)"
@echo ""
@echo "或在當前 shell 執行:"
@echo ""
@echo " export ANTHROPIC_BASE_URL=http://$(HOST):$(PORT)"
@echo " export ANTHROPIC_API_KEY=$(if $(API_KEY),$(API_KEY),dummy-key)"
@echo " claude"
@echo ""
## 停止 pm2 中的代理
pm2-stop:
pm2 stop cursor-api-proxy 2>/dev/null || echo "cursor-api-proxy 未在執行"
## 查看 pm2 日誌
pm2-logs:
pm2 logs cursor-api-proxy
## ──────────────────────────────────────────────────
## Docker Compose 指令
## ──────────────────────────────────────────────────
## 複製 .env.example 為 .env 並自動偵測本機 agent 路徑(首次設定)
docker-env:
@if [ -f .env ]; then \
echo ".env 已存在,若要重置請手動刪除後再執行"; \
else \
cp .env.example .env; \
DETECTED_AGENT=$$(which agent 2>/dev/null || echo ""); \
if [ -n "$$DETECTED_AGENT" ]; then \
REAL_AGENT=$$(readlink -f "$$DETECTED_AGENT"); \
sed -i "s|^CURSOR_AGENT_HOST_BIN=.*|CURSOR_AGENT_HOST_BIN=$$REAL_AGENT|" .env; \
echo " 已偵測 agent: $$REAL_AGENT"; \
else \
echo " 警告:找不到 agent請手動設定 CURSOR_AGENT_HOST_BIN"; \
fi; \
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 "可用目標:"
@echo " make env 產生 .env先在 Makefile 頂端填好變數)"
@echo " make build 編譯 cursor-api-proxy 二進位檔"
@echo " make run 編譯並載入 .env 執行"
@echo " make pm2 編譯並用 pm2 啟動代理"
@echo " make pm2-stop 停止 pm2 中的代理"
@echo " make pm2-logs 查看 pm2 日誌"
@echo " make pm2-claude-code 啟動代理 + 輸出 Claude Code 設定指令"
@echo " make opencode 編譯並設定 OpenCode更新 opencode.json"
@echo " make pm2-opencode 設定 OpenCode + 啟動代理"
@echo " make opencode-models 編譯、設定 OpenCode 並同步模型列表"
@echo " make claude-settings 寫入 ~/.claude/settings.json模型與 BASE_URL"
@echo " make claude-onboarding 設定 ~/.claude.json hasCompletedOnboarding=true"
@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 "Provider 設定範例:"
@echo " make env PROVIDER=cursor # 使用 Cursor預設"
@echo " make env PROVIDER=gemini-web # 使用 Gemini Web"
@echo " make env PROVIDER=gemini-web GEMINI_ACCOUNT_DIR=/path/to/sessions"
@echo " make env PROVIDER=gemini-web GEMINI_BROWSER_VISIBLE=true"
@echo " make opencode # 設定 OpenCode含 cursor 與 gemini-web provider"
@echo ""
@echo "覆寫範例:"
@echo " make env PORT=9000 API_KEY=mysecret TIMEOUT_MS=60000"
@echo " make pm2-claude-code PORT=8765 API_KEY=mykey"
@echo " make pm2-opencode PORT=8765"
@echo " make claude-settings PORT=8766 ANTHROPIC_BASE_HOST=localhost ANTHROPIC_DEFAULT_OPUS_MODEL=claude-4.6-opus-high"
@echo ""
@echo "使用 Gemini Web Provider"
@echo " 1. make env PROVIDER=gemini-web"
@echo " 2. gemini-login my-session # 登入並儲存 session"
@echo " 3. make run # 啟動代理"
@echo " 4. 在 OpenCode 設定 model: gemini/gemini-2.5-pro"

200
README.md
View File

@ -1,100 +1,152 @@
# cursor-adapter
# Cursor API Proxy
**OpenAI相容 Chat Completions API** 對外提供服務,後端透過 **Cursor CLI**(預設 `agent`)或選用的 **ACP** 傳輸與 Cursor 互動的本地代理程式。另提供 **Anthropic Messages** 相容端點
一個代理伺服器,讓你用標準 OpenAI / Anthropic API 存取 Cursor CLI 模型
## 需求
可接入:
- Claude Code
- OpenCode
- 任何支援 OpenAI / Anthropic API 的工具
- Go **1.26.1**(見 `go.mod`
- 已安裝並可在 `PATH` 中執行的 **Cursor CLI**(名稱或路徑見設定檔 `cursor_cli_path`
---
## 建置與執行
## 功能
- API 相容OpenAI / Anthropic
- 多帳號管理
- 模型自動對映(轉成 claude-*
- 支援區網存取0.0.0.0
- 連線池優化
---
## 快速開始(本機)
### 看幫助
```bash
make help
```
### 安裝依賴
```bash
go build -o cursor-adapter .
./cursor-adapter
curl https://cursor.com/install -fsS | bash
curl -fsSL https://claude.ai/install.sh | bash
```
未指定 `-c``--config` 時,會讀取 `~/.cursor-adapter/config.yaml`;若檔案不存在則使用內建預設值。
首次可複製範例後再編輯:
### 下載與建置
```bash
mkdir -p ~/.cursor-adapter
cp config.example.yaml ~/.cursor-adapter/config.yaml
git clone https://code.30cm.net/daniel.w/opencode-cursor-agent.git
cd cursor-api-proxy-go
go build -o cursor-api-proxy .
```
## 命令列參數
### 登入
| 參數 | 說明 |
```bash
./cursor-api-proxy login myaccount
```
### 啟動
```bash
make env PORT=8766 API_KEY=mysecret
make pm2
```
---
## Claude Code 設定
```bash
make claude-settings PORT=8766
make claude-onboarding
claude
```
---
## OpenCode 設定
編輯:
~/.config/opencode/opencode.json
```json
{
"provider": {
"cursor": {
"options": {
"baseURL": "http://127.0.0.1:8766/v1"
}
}
}
}
```
---
## Docker簡化版
```bash
make docker-setup
vim .env
make docker-setup
```
檢查:
```bash
curl http://localhost:8766/health
```
---
## 常用指令
```bash
make docker-up
make docker-down
make docker-logs
make docker-restart
```
---
## API
| 路徑 | 方法 |
|------|------|
| `-c`, `--config` | 設定檔路徑 |
| `-p`, `--port` | 監聽埠(覆寫設定檔) |
| `--debug` | 除錯層級日誌 |
| `--use-acp` | 改用 Cursor ACP 傳輸(預設為 CLI stream-json |
| `--chat-only-workspace` | 預設 `true`:在暫存工作區並覆寫 `HOME``CURSOR_CONFIG_DIR` 等,避免子行程讀取啟動目錄或 `~/.cursor` 規則;設為 `false` 時代理的工作目錄會對 Cursor agent 可見 |
| /v1/chat/completions | POST |
| /v1/messages | POST |
| /v1/models | GET |
| /health | GET |
啟動前會檢查 Cursor CLI 是否可用;失敗時程式會退出並顯示錯誤。
---
## 設定檔YAML
## 環境變數(核心
欄位與 `config.example.yaml` 對齊,例如:
| 變數 | 預設 |
|------|------|
| CURSOR_BRIDGE_HOST | 127.0.0.1 |
| CURSOR_BRIDGE_PORT | 8766 |
| CURSOR_BRIDGE_TIMEOUT_MS | 3600000 |
- `port`HTTP 服務埠(預設 `8976`
- `cursor_cli_path`CLI 可執行檔名或路徑
- `default_model`、`available_models`、`timeout`(秒)、`max_concurrent`
- `use_acp`、`chat_only_workspace`、`log_level`
- `cursor_mode``plan`(預設,純大腦+ `<tool_call>` 翻譯成 caller 端 tool_use`agent`(讓 Cursor CLI 自己拿 host 的檔案shell 工具直接執行)
- `workspace_root`:絕對路徑;設了之後子行程就跑在這個資料夾,不再用 chat-only temp dir。`agent` 模式下幾乎都要設。Per-request 用 `X-Cursor-Workspace` header 動態覆蓋。
---
### 兩種典型擺法
1. **大腦模式(預設)**
```yaml
cursor_mode: plan
chat_only_workspace: true
```
Cursor CLI 不執行任何東西。proxy 把 system_prompt 注入腦袋,要它輸出 `<tool_call>{...}</tool_call>`proxy 再翻成 Anthropic `tool_use` 給 callerClaude Desktop / Claude Code / opencode跑。caller 必須有自己的 host MCP例如 desktop-commander
2. **執行者模式**
```yaml
cursor_mode: agent
chat_only_workspace: false
workspace_root: /Users/<you>/Desktop
system_prompt: "" # 移掉「你只是大腦」的口令,讓它正常使用工具
```
Cursor CLI 自己用內建 filesystem/shell 工具直接動 `workspace_root`。caller 不需要任何 MCP整段在 host 上完成;回到 caller 那邊只有最後的文字結論。
## HTTP 端點
服務綁定 **127.0.0.1**(僅本機)。路由啟用 CORS`Access-Control-Allow-Origin: *`),並允許標頭如 `Authorization`、`X-Cursor-Session-ID`、`X-Cursor-Workspace`。
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/health` | 健康檢查 |
| GET | `/v1/models` | 模型列表 |
| POST | `/v1/chat/completions` | OpenAI 相容聊天完成(含串流 SSE |
| POST | `/v1/messages` | Anthropic Messages 相容 |
## 開發與測試
## 帳號操作
```bash
go test ./...
./cursor-api-proxy login myaccount
./cursor-api-proxy accounts
./cursor-api-proxy logout myaccount
./cursor-api-proxy reset-hwid
```
輔助腳本:`scripts/test_cursor_cli.sh`(驗證本機 Cursor CLI 是否可呼叫)。
---
## 專案結構(概要)
## 備註
- `main.go`CLI 入口、設定與橋接組裝、服務啟動
- `internal/config`:設定載入與驗證
- `internal/server`HTTP 路由、CORS、OpenAIAnthropic 處理、SSE、工作階段
- `internal/bridge`:與 Cursor CLIACP 的橋接與併發控制
- `internal/converter`:請求/回應與模型對應
- `internal/types`:共用型別與 API 結構
- `internal/workspace`:暫存工作區與隔離行為
- `internal/sanitize`:輸入清理
- `docs/`計畫、架構、PRD 與 Cursor CLI 格式等文件
- Docker 預設開放區網0.0.0.0
- 模型自動同步
- 支援多模型切換
## 安全與隱私
建議維持 `chat_only_workspace: true`,除非你有意讓 Cursor agent 存取代理行程的工作目錄與本機 Cursor 設定。詳見 `config.example.yaml` 註解與 `internal/config` 實作。
---

141
api/chat.api Normal file
View File

@ -0,0 +1,141 @@
syntax = "v1"
info (
title: "Cursor API Proxy"
desc: "OpenAI-compatible API proxy for Cursor/Gemini"
author: "cursor-api-proxy"
version: "1.0"
)
// ============ Types ============
type (
// Health
HealthRequest {}
HealthResponse {
Status string `json:"status"`
Version string `json:"version"`
}
// Models
ModelsRequest {}
ModelsResponse {
Object string `json:"object"`
Data []ModelData `json:"data"`
}
ModelData {
Id string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
}
// Chat Completions
ChatCompletionRequest {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream,optional"`
Tools []Tool `json:"tools,optional"`
Functions []Function `json:"functions,optional"`
MaxTokens int `json:"max_tokens,optional"`
Temperature float64 `json:"temperature,optional"`
}
Message {
Role string `json:"role"`
Content interface{} `json:"content"`
}
Tool {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
ToolFunction {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
}
Function {
Name string `json:"name"`
Description string `json:"description,optional"`
Parameters interface{} `json:"parameters,optional"`
}
ChatCompletionResponse {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
Choice {
Index int `json:"index"`
Message RespMessage `json:"message,optional"`
Delta Delta `json:"delta,optional"`
FinishReason string `json:"finish_reason"`
}
RespMessage {
Role string `json:"role"`
Content string `json:"content,optional"`
ToolCalls []ToolCall `json:"tool_calls,optional"`
}
Delta {
Role string `json:"role,optional"`
Content string `json:"content,optional"`
ReasoningContent string `json:"reasoning_content,optional"`
ToolCalls []ToolCall `json:"tool_calls,optional"`
}
ToolCall {
Index int `json:"index"`
Id string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
FunctionCall {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
Usage {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Anthropic Messages
AnthropicRequest {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,optional"`
System string `json:"system,optional"`
}
AnthropicResponse {
Id string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
Usage AnthropicUsage `json:"usage"`
}
ContentBlock {
Type string `json:"type"`
Text string `json:"text,optional"`
}
AnthropicUsage {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
)
// ============ Routes ============
@server (
prefix: /v1
group: chat
)
service chat-api {
@handler Health
get /health returns (HealthResponse)
@handler Models
get /v1/models returns (ModelsResponse)
@handler ChatCompletions
post /v1/chat/completions (ChatCompletionRequest)
@handler AnthropicMessages
post /v1/messages (AnthropicRequest)
}

34
cmd/chat/chat.go Normal file
View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package main
import (
"flag"
"fmt"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/internal/handler"
"cursor-api-proxy/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/rest"
)
var configFile = flag.String("f", "etc/chat-api.yaml", "the config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}

197
cmd/cli/accounts.go Normal file
View File

@ -0,0 +1,197 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"cursor-api-proxy/pkg/usecase"
)
type AccountInfo struct {
Name string
ConfigDir string
Authenticated bool
Email string
DisplayName string
AuthID string
Plan string
SubscriptionStatus string
ExpiresAt string
}
func ReadAccountInfo(name, configDir string) AccountInfo {
info := AccountInfo{Name: name, ConfigDir: configDir}
configFile := filepath.Join(configDir, "cli-config.json")
data, err := os.ReadFile(configFile)
if err != nil {
return info
}
var raw struct {
AuthInfo *struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
AuthID string `json:"authId"`
} `json:"authInfo"`
}
if err := json.Unmarshal(data, &raw); err == nil && raw.AuthInfo != nil {
info.Authenticated = true
info.Email = raw.AuthInfo.Email
info.DisplayName = raw.AuthInfo.DisplayName
info.AuthID = raw.AuthInfo.AuthID
}
statsigFile := filepath.Join(configDir, "statsig-cache.json")
statsigData, err := os.ReadFile(statsigFile)
if err != nil {
return info
}
var statsigWrapper struct {
Data string `json:"data"`
}
if err := json.Unmarshal(statsigData, &statsigWrapper); err != nil || statsigWrapper.Data == "" {
return info
}
var statsig struct {
User *struct {
Custom *struct {
IsEnterpriseUser bool `json:"isEnterpriseUser"`
StripeSubscriptionStatus string `json:"stripeSubscriptionStatus"`
StripeMembershipExpiration string `json:"stripeMembershipExpiration"`
} `json:"custom"`
} `json:"user"`
}
if err := json.Unmarshal([]byte(statsigWrapper.Data), &statsig); err != nil {
return info
}
if statsig.User != nil && statsig.User.Custom != nil {
c := statsig.User.Custom
if c.IsEnterpriseUser {
info.Plan = "Enterprise"
} else if c.StripeSubscriptionStatus == "active" {
info.Plan = "Pro"
} else {
info.Plan = "Free"
}
info.SubscriptionStatus = c.StripeSubscriptionStatus
info.ExpiresAt = c.StripeMembershipExpiration
}
return info
}
func HandleAccountsList() error {
accountsDir := usecase.AccountsDir()
entries, err := os.ReadDir(accountsDir)
if err != nil {
fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.")
return nil
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) == 0 {
fmt.Println("No accounts found. Use 'cursor-api-proxy login' to add one.")
return nil
}
fmt.Print("Cursor Accounts:\n\n")
keychainToken := usecase.ReadKeychainToken()
for i, name := range names {
configDir := filepath.Join(accountsDir, name)
info := ReadAccountInfo(name, configDir)
fmt.Printf(" %d. %s\n", i+1, name)
if info.Authenticated {
cachedToken := usecase.ReadCachedToken(configDir)
keychainMatchesAccount := keychainToken != "" && info.AuthID != "" && TokenSub(keychainToken) == info.AuthID
token := cachedToken
if token == "" && keychainMatchesAccount {
token = keychainToken
}
var liveProfile *StripeProfile
var liveUsage *UsageData
if token != "" {
liveUsage, _ = FetchAccountUsage(token)
liveProfile, _ = FetchStripeProfile(token)
}
if info.Email != "" {
display := ""
if info.DisplayName != "" {
display = " (" + info.DisplayName + ")"
}
fmt.Printf(" %s%s\n", info.Email, display)
}
if info.Plan != "" && liveProfile == nil {
canceled := ""
if info.SubscriptionStatus == "canceled" {
canceled = " · canceled"
}
expiry := ""
if info.ExpiresAt != "" {
expiry = " · expires " + info.ExpiresAt
}
fmt.Printf(" %s%s%s\n", info.Plan, canceled, expiry)
}
fmt.Println(" Authenticated")
if liveProfile != nil {
fmt.Printf(" %s\n", DescribePlan(liveProfile))
}
if liveUsage != nil {
for _, line := range FormatUsageSummary(liveUsage) {
fmt.Println(line)
}
}
} else {
fmt.Println(" Not authenticated")
}
fmt.Println("")
}
fmt.Println("Tip: run 'cursor-api-proxy logout <name>' to remove an account.")
return nil
}
func HandleLogout(accountName string) error {
if accountName == "" {
fmt.Fprintln(os.Stderr, "Error: Please specify the account name to remove.")
fmt.Fprintln(os.Stderr, "Usage: cursor-api-proxy logout <account-name>")
os.Exit(1)
}
accountsDir := usecase.AccountsDir()
configDir := filepath.Join(accountsDir, accountName)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Account '%s' not found.\n", accountName)
os.Exit(1)
}
if err := os.RemoveAll(configDir); err != nil {
fmt.Fprintf(os.Stderr, "Error removing account: %v\n", err)
os.Exit(1)
}
fmt.Printf("Account '%s' removed.\n", accountName)
return nil
}

118
cmd/cli/args.go Normal file
View File

@ -0,0 +1,118 @@
package cmd
import "fmt"
type ParsedArgs struct {
Tailscale bool
Help bool
Login bool
AccountsList bool
Logout bool
AccountName string
Proxies []string
ResetHwid bool
DeepClean bool
DryRun bool
}
func ParseArgs(argv []string) (ParsedArgs, error) {
var args ParsedArgs
for i := 0; i < len(argv); i++ {
arg := argv[i]
switch arg {
case "login", "add-account":
args.Login = true
if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' {
i++
args.AccountName = argv[i]
}
case "logout", "remove-account":
args.Logout = true
if i+1 < len(argv) && len(argv[i+1]) > 0 && argv[i+1][0] != '-' {
i++
args.AccountName = argv[i]
}
case "accounts", "list-accounts":
args.AccountsList = true
case "reset-hwid", "reset":
args.ResetHwid = true
case "--deep-clean":
args.DeepClean = true
case "--dry-run":
args.DryRun = true
case "--tailscale":
args.Tailscale = true
case "--help", "-h":
args.Help = true
default:
if len(arg) > len("--proxy=") && arg[:len("--proxy=")] == "--proxy=" {
raw := arg[len("--proxy="):]
parts := splitComma(raw)
for _, p := range parts {
if p != "" {
args.Proxies = append(args.Proxies, p)
}
}
} else {
return args, fmt.Errorf("Unknown argument: %s", arg)
}
}
}
return args, nil
}
func splitComma(s string) []string {
var result []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == ',' {
part := trim(s[start:i])
if part != "" {
result = append(result, part)
}
start = i + 1
}
}
return result
}
func trim(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
func PrintHelp(version string) {
fmt.Printf("cursor-api-proxy v%s\n\n", version)
fmt.Println("Usage:")
fmt.Println(" cursor-api-proxy [options]")
fmt.Println("")
fmt.Println("Commands:")
fmt.Println(" login [name] Log into a Cursor account (saved to ~/.cursor-api-proxy/accounts/)")
fmt.Println(" login [name] --proxy=... Same, but with a proxy from a comma-separated list")
fmt.Println(" logout <name> Remove a saved Cursor account")
fmt.Println(" accounts List saved accounts with plan info")
fmt.Println(" reset-hwid Reset Cursor machine/telemetry IDs (anti-ban)")
fmt.Println(" reset-hwid --deep-clean Also wipe session storage and cookies")
fmt.Println("")
fmt.Println("Options:")
fmt.Println(" --tailscale Bind to 0.0.0.0 for tailnet/LAN access")
fmt.Println(" -h, --help Show this help message")
}

126
cmd/cli/login.go Normal file
View File

@ -0,0 +1,126 @@
package cmd
import (
"bufio"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"syscall"
"time"
"cursor-api-proxy/pkg/infrastructure/env"
"cursor-api-proxy/pkg/usecase"
)
var loginURLRe = regexp.MustCompile(`https://cursor\.com/loginDeepControl.*?redirectTarget=cli`)
func HandleLogin(accountName string, proxies []string) error {
e := env.OsEnvToMap()
loaded := env.LoadEnvConfig(e, "")
agentBin := loaded.AgentBin
if accountName == "" {
accountName = fmt.Sprintf("account-%d", time.Now().UnixMilli()%10000)
}
accountsDir := usecase.AccountsDir()
configDir := filepath.Join(accountsDir, accountName)
dirWasNew := !fileExists(configDir)
if err := os.MkdirAll(accountsDir, 0755); err != nil {
return fmt.Errorf("failed to create accounts dir: %w", err)
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
fmt.Printf("Logging into Cursor account: %s\n", accountName)
fmt.Printf("Config: %s\n\n", configDir)
fmt.Println("Run the login command — complete the login in your browser.")
fmt.Println("")
cleanupDir := func() {
if dirWasNew {
_ = os.RemoveAll(configDir)
}
}
cmdEnv := make([]string, 0, len(e)+2)
for k, v := range e {
cmdEnv = append(cmdEnv, k+"="+v)
}
cmdEnv = append(cmdEnv, "CURSOR_CONFIG_DIR="+configDir)
cmdEnv = append(cmdEnv, "NO_OPEN_BROWSER=1")
child := exec.Command(agentBin, "login")
child.Env = cmdEnv
child.Stdin = os.Stdin
child.Stderr = os.Stderr
stdoutPipe, err := child.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := child.Start(); err != nil {
cleanupDir()
if os.IsNotExist(err) {
return fmt.Errorf("could not find '%s'. Make sure the Cursor CLI is installed", agentBin)
}
return fmt.Errorf("error launching agent login: %w", err)
}
// Handle cancellation signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
go func() {
sig := <-sigCh
_ = child.Process.Kill()
cleanupDir()
if sig == syscall.SIGINT {
fmt.Println("\n\nLogin cancelled.")
}
os.Exit(0)
}()
defer signal.Stop(sigCh)
var stdoutBuf string
scanner := bufio.NewScanner(stdoutPipe)
for scanner.Scan() {
line := scanner.Text()
fmt.Println(line)
stdoutBuf += line + "\n"
if loginURLRe.MatchString(stdoutBuf) {
match := loginURLRe.FindString(stdoutBuf)
if match != "" {
fmt.Printf("\nOpen this URL in your browser (incognito recommended):\n%s\n\n", match)
}
}
}
if err := child.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
cleanupDir()
return fmt.Errorf("login failed with code %d", exitErr.ExitCode())
}
return err
}
// Cache keychain token for this account
token := usecase.ReadKeychainToken()
if token != "" {
usecase.WriteCachedToken(configDir, token)
}
fmt.Printf("\nAccount '%s' saved — it will be auto-discovered when you start the proxy.\n", accountName)
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

261
cmd/cli/resethwid.go Normal file
View File

@ -0,0 +1,261 @@
package cmd
import (
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/google/uuid"
)
func sha256hex() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func sha512hex() string {
b := make([]byte, 64)
_, _ = rand.Read(b)
h := sha512.Sum512(b)
return hex.EncodeToString(h[:])
}
func newUUID() string {
return uuid.New().String()
}
func log(icon, msg string) {
fmt.Printf(" %s %s\n", icon, msg)
}
func getCursorGlobalStorage() string {
switch runtime.GOOS {
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage")
case "windows":
appdata := os.Getenv("APPDATA")
return filepath.Join(appdata, "Cursor", "User", "globalStorage")
default:
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg == "" {
home, _ := os.UserHomeDir()
xdg = filepath.Join(home, ".config")
}
return filepath.Join(xdg, "Cursor", "User", "globalStorage")
}
}
func getCursorRoot() string {
gs := getCursorGlobalStorage()
return filepath.Dir(filepath.Dir(gs))
}
func generateNewIDs() map[string]string {
return map[string]string{
"telemetry.machineId": sha256hex(),
"telemetry.macMachineId": sha512hex(),
"telemetry.devDeviceId": newUUID(),
"telemetry.sqmId": "{" + fmt.Sprintf("%s", newUUID()+"") + "}",
"storage.serviceMachineId": newUUID(),
}
}
func killCursor() {
log("", "Stopping Cursor processes...")
switch runtime.GOOS {
case "windows":
exec.Command("taskkill", "/F", "/IM", "Cursor.exe").Run()
default:
exec.Command("pkill", "-x", "Cursor").Run()
exec.Command("pkill", "-f", "Cursor.app").Run()
}
log("", "Cursor stopped (or was not running)")
}
func updateStorageJSON(storagePath string, ids map[string]string) {
if _, err := os.Stat(storagePath); os.IsNotExist(err) {
log("", fmt.Sprintf("storage.json not found: %s", storagePath))
return
}
if runtime.GOOS == "darwin" {
exec.Command("chflags", "nouchg", storagePath).Run()
exec.Command("chmod", "644", storagePath).Run()
}
data, err := os.ReadFile(storagePath)
if err != nil {
log("", fmt.Sprintf("storage.json read error: %v", err))
return
}
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
log("", fmt.Sprintf("storage.json parse error: %v", err))
return
}
for k, v := range ids {
obj[k] = v
}
out, err := json.MarshalIndent(obj, "", " ")
if err != nil {
log("", fmt.Sprintf("storage.json marshal error: %v", err))
return
}
if err := os.WriteFile(storagePath, out, 0644); err != nil {
log("", fmt.Sprintf("storage.json write error: %v", err))
return
}
log("", "storage.json updated")
}
func updateStateVscdb(dbPath string, ids map[string]string) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
log("", fmt.Sprintf("state.vscdb not found: %s", dbPath))
return
}
if runtime.GOOS == "darwin" {
exec.Command("chflags", "nouchg", dbPath).Run()
exec.Command("chmod", "644", dbPath).Run()
}
if err := updateVscdbPureGo(dbPath, ids); err != nil {
log("", fmt.Sprintf("state.vscdb error: %v", err))
} else {
log("", "state.vscdb updated")
}
}
func updateMachineIDFile(machineID, cursorRoot string) {
var candidates []string
if runtime.GOOS == "linux" {
candidates = []string{
filepath.Join(cursorRoot, "machineid"),
filepath.Join(cursorRoot, "machineId"),
}
} else {
candidates = []string{filepath.Join(cursorRoot, "machineId")}
}
filePath := candidates[0]
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
filePath = c
break
}
}
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
log("", fmt.Sprintf("machineId dir error: %v", err))
return
}
if runtime.GOOS == "darwin" {
if _, err := os.Stat(filePath); err == nil {
exec.Command("chflags", "nouchg", filePath).Run()
exec.Command("chmod", "644", filePath).Run()
}
}
if err := os.WriteFile(filePath, []byte(machineID+"\n"), 0644); err != nil {
log("", fmt.Sprintf("machineId write error: %v", err))
return
}
log("", fmt.Sprintf("machineId file updated (%s)", filepath.Base(filePath)))
}
var dirsToWipe = []string{
"Session Storage", "Local Storage", "IndexedDB", "Cache", "Code Cache",
"GPUCache", "Service Worker", "Network", "Cookies", "Cookies-journal",
}
func deepClean(cursorRoot string) {
log("", "Deep-cleaning session data...")
wiped := 0
for _, name := range dirsToWipe {
target := filepath.Join(cursorRoot, name)
if _, err := os.Stat(target); os.IsNotExist(err) {
continue
}
info, err := os.Stat(target)
if err != nil {
continue
}
if info.IsDir() {
if err := os.RemoveAll(target); err == nil {
wiped++
}
} else {
if err := os.Remove(target); err == nil {
wiped++
}
}
}
log("", fmt.Sprintf("Wiped %d cache/session items", wiped))
}
func HandleResetHwid(doDeepClean, dryRun bool) error {
fmt.Print("\nCursor HWID Reset\n\n")
fmt.Println(" Resets all machine / telemetry IDs so Cursor sees a fresh install.")
fmt.Print(" Cursor must be closed — it will be killed automatically.\n\n")
globalStorage := getCursorGlobalStorage()
cursorRoot := getCursorRoot()
if _, err := os.Stat(globalStorage); os.IsNotExist(err) {
fmt.Printf("Cursor config not found at:\n %s\n", globalStorage)
fmt.Println(" Make sure Cursor is installed and has been run at least once.")
os.Exit(1)
}
if dryRun {
fmt.Println(" [DRY RUN] Would reset IDs in:")
fmt.Printf(" %s\n", filepath.Join(globalStorage, "storage.json"))
fmt.Printf(" %s\n", filepath.Join(globalStorage, "state.vscdb"))
fmt.Printf(" %s\n", filepath.Join(cursorRoot, "machineId"))
return nil
}
killCursor()
time.Sleep(800 * time.Millisecond)
newIDs := generateNewIDs()
log("", "Generated new IDs:")
for k, v := range newIDs {
fmt.Printf(" %s: %s\n", k, v)
}
fmt.Println()
log("", "Updating storage.json...")
updateStorageJSON(filepath.Join(globalStorage, "storage.json"), newIDs)
log("", "Updating state.vscdb...")
updateStateVscdb(filepath.Join(globalStorage, "state.vscdb"), newIDs)
log("", "Updating machineId file...")
updateMachineIDFile(newIDs["telemetry.machineId"], cursorRoot)
if doDeepClean {
fmt.Println()
deepClean(cursorRoot)
}
fmt.Print("\nHWID reset complete. You can now restart Cursor.\n\n")
return nil
}

29
cmd/cli/sqlite.go Normal file
View File

@ -0,0 +1,29 @@
package cmd
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
func updateVscdbPureGo(dbPath string, ids map[string]string) error {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
if err != nil {
return fmt.Errorf("create table: %w", err)
}
for k, v := range ids {
_, err = db.Exec(`INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)`, k, v)
if err != nil {
return fmt.Errorf("insert %s: %w", k, err)
}
}
return nil
}

255
cmd/cli/usage.go Normal file
View File

@ -0,0 +1,255 @@
package cmd
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type ModelUsage struct {
NumRequests int `json:"numRequests"`
NumRequestsTotal int `json:"numRequestsTotal"`
NumTokens int `json:"numTokens"`
MaxTokenUsage *int `json:"maxTokenUsage"`
MaxRequestUsage *int `json:"maxRequestUsage"`
}
type UsageData struct {
StartOfMonth string `json:"startOfMonth"`
Models map[string]ModelUsage `json:"-"`
}
type StripeProfile struct {
MembershipType string `json:"membershipType"`
SubscriptionStatus string `json:"subscriptionStatus"`
DaysRemainingOnTrial *int `json:"daysRemainingOnTrial"`
IsTeamMember bool `json:"isTeamMember"`
IsYearlyPlan bool `json:"isYearlyPlan"`
}
func DecodeJWTPayload(token string) map[string]interface{} {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil
}
padded := strings.ReplaceAll(parts[1], "-", "+")
padded = strings.ReplaceAll(padded, "_", "/")
data, err := base64.StdEncoding.DecodeString(padded + strings.Repeat("=", (4-len(padded)%4)%4))
if err != nil {
return nil
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
}
func TokenSub(token string) string {
payload := DecodeJWTPayload(token)
if payload == nil {
return ""
}
if sub, ok := payload["sub"].(string); ok {
return sub
}
return ""
}
func apiGet(path, token string) (map[string]interface{}, error) {
client := &http.Client{Timeout: 8 * time.Second}
req, err := http.NewRequest("GET", "https://api2.cursor.sh"+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil
}
return result, nil
}
func FetchAccountUsage(token string) (*UsageData, error) {
raw, err := apiGet("/auth/usage", token)
if err != nil || raw == nil {
return nil, err
}
startOfMonth, _ := raw["startOfMonth"].(string)
usage := &UsageData{
StartOfMonth: startOfMonth,
Models: make(map[string]ModelUsage),
}
for k, v := range raw {
if k == "startOfMonth" {
continue
}
data, err := json.Marshal(v)
if err != nil {
continue
}
var mu ModelUsage
if err := json.Unmarshal(data, &mu); err == nil {
usage.Models[k] = mu
}
}
return usage, nil
}
func FetchStripeProfile(token string) (*StripeProfile, error) {
raw, err := apiGet("/auth/full_stripe_profile", token)
if err != nil || raw == nil {
return nil, err
}
profile := &StripeProfile{
MembershipType: fmt.Sprintf("%v", raw["membershipType"]),
SubscriptionStatus: fmt.Sprintf("%v", raw["subscriptionStatus"]),
IsTeamMember: raw["isTeamMember"] == true,
IsYearlyPlan: raw["isYearlyPlan"] == true,
}
if d, ok := raw["daysRemainingOnTrial"].(float64); ok {
di := int(d)
profile.DaysRemainingOnTrial = &di
}
return profile, nil
}
func DescribePlan(profile *StripeProfile) string {
if profile == nil {
return ""
}
switch profile.MembershipType {
case "free_trial":
days := 0
if profile.DaysRemainingOnTrial != nil {
days = *profile.DaysRemainingOnTrial
}
return fmt.Sprintf("Pro Trial (%dd left) — unlimited fast requests", days)
case "pro":
return "Pro — extended limits"
case "pro_plus":
return "Pro+ — extended limits"
case "ultra":
return "Ultra — extended limits"
case "free", "hobby":
return "Hobby (free) — limited agent requests"
default:
return fmt.Sprintf("%s · %s", profile.MembershipType, profile.SubscriptionStatus)
}
}
var modelLabels = map[string]string{
"gpt-4": "Fast Premium Requests",
"claude-sonnet-4-6": "Claude Sonnet 4.6",
"claude-sonnet-4-5-20250929-v1": "Claude Sonnet 4.5",
"claude-sonnet-4-20250514-v1": "Claude Sonnet 4",
"claude-opus-4-6-v1": "Claude Opus 4.6",
"claude-opus-4-5-20251101-v1": "Claude Opus 4.5",
"claude-opus-4-1-20250805-v1": "Claude Opus 4.1",
"claude-opus-4-20250514-v1": "Claude Opus 4",
"claude-haiku-4-5-20251001-v1": "Claude Haiku 4.5",
"claude-3-5-haiku-20241022-v1": "Claude 3.5 Haiku",
"gpt-5": "GPT-5",
"gpt-4o": "GPT-4o",
"o1": "o1",
"o3-mini": "o3-mini",
"cursor-small": "Cursor Small (free)",
}
func modelLabel(key string) string {
if label, ok := modelLabels[key]; ok {
return label
}
return key
}
func FormatUsageSummary(usage *UsageData) []string {
if usage == nil {
return nil
}
var lines []string
start := "?"
if usage.StartOfMonth != "" {
if t, err := time.Parse(time.RFC3339, usage.StartOfMonth); err == nil {
start = t.Format("2006-01-02")
} else {
start = usage.StartOfMonth
}
}
lines = append(lines, fmt.Sprintf(" Billing period from %s", start))
if len(usage.Models) == 0 {
lines = append(lines, " No requests this billing period")
return lines
}
type entry struct {
key string
usage ModelUsage
}
var entries []entry
for k, v := range usage.Models {
entries = append(entries, entry{k, v})
}
// Sort: entries with limits first, then by usage descending
for i := 1; i < len(entries); i++ {
for j := i; j > 0; j-- {
a, b := entries[j-1], entries[j]
aHasLimit := a.usage.MaxRequestUsage != nil
bHasLimit := b.usage.MaxRequestUsage != nil
if !aHasLimit && bHasLimit {
entries[j-1], entries[j] = entries[j], entries[j-1]
} else if aHasLimit == bHasLimit && a.usage.NumRequests < b.usage.NumRequests {
entries[j-1], entries[j] = entries[j], entries[j-1]
} else {
break
}
}
}
for _, e := range entries {
used := e.usage.NumRequests
max := e.usage.MaxRequestUsage
label := modelLabel(e.key)
if max != nil && *max > 0 {
pct := int(float64(used) / float64(*max) * 100)
bar := makeBar(used, *max, 12)
lines = append(lines, fmt.Sprintf(" %s: %d/%d (%d%%) [%s]", label, used, *max, pct, bar))
} else if used > 0 {
lines = append(lines, fmt.Sprintf(" %s: %d requests", label, used))
} else {
lines = append(lines, fmt.Sprintf(" %s: 0 requests (unlimited)", label))
}
}
return lines
}
func makeBar(used, max, width int) string {
fill := int(float64(used) / float64(max) * float64(width))
if fill > width {
fill = width
}
return strings.Repeat("█", fill) + strings.Repeat("░", width-fill)
}

61
cmd/gemini-login/main.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"cursor-api-proxy/internal/config"
"cursor-api-proxy/pkg/infrastructure/env"
"cursor-api-proxy/pkg/provider/geminiweb"
"fmt"
"os"
"strings"
)
func main() {
accountName := ""
visible := false
// 解析命令列參數
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if arg == "--visible" || arg == "-v" {
visible = true
} else if arg == "--help" || arg == "-h" {
printHelp()
os.Exit(0)
} else if !strings.HasPrefix(arg, "-") {
accountName = arg
}
}
e := env.OsEnvToMap()
loaded := env.LoadEnvConfig(e, "")
cfg := config.LoadBridgeConfig(e, "")
cfg.GeminiAccountDir = loaded.GeminiAccountDir
// 命令列參數優先於環境變數
cfg.GeminiBrowserVisible = visible || loaded.GeminiBrowserVisible
fmt.Printf("Session 儲存位置: %s\n", cfg.GeminiAccountDir)
fmt.Printf("瀏覽器可見: %v\n", cfg.GeminiBrowserVisible)
if err := geminiweb.RunLogin(cfg, accountName); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func printHelp() {
fmt.Println("使用方法: gemini-login [options] [session-name]")
fmt.Println("")
fmt.Println("選項:")
fmt.Println(" --visible, -v 顯示瀏覽器視窗(預設隱藏)")
fmt.Println(" --help, -h 顯示此說明")
fmt.Println("")
fmt.Println("環境變數:")
fmt.Println(" GEMINI_ACCOUNT_DIR Session 儲存目錄(預設: ~/.cursor-api-proxy/gemini-accounts")
fmt.Println(" GEMINI_BROWSER_VISIBLE 是否顯示瀏覽器true/false預設: false")
fmt.Println("")
fmt.Println("範例:")
fmt.Println(" gemini-login my-session")
fmt.Println(" gemini-login --visible my-session")
fmt.Println(" GEMINI_BROWSER_VISIBLE=true gemini-login")
}

View File

@ -1,241 +0,0 @@
// Command cursor-mcp-server is a Model Context Protocol (MCP) server that
// exposes the cursor-adapter HTTP API as MCP tools for Claude Desktop.
//
// It communicates with Claude Desktop over stdio (JSON-RPC) and forwards
// requests to a running cursor-adapter instance via HTTP.
//
// Usage (standalone):
//
// go run ./cmd/mcp-server
// go run ./cmd/mcp-server --adapter-url http://127.0.0.1:8765
//
// Usage (Claude Desktop config):
//
// {
// "mcpServers": {
// "cursor-bridge": {
// "command": "/path/to/cursor-mcp-server",
// "args": ["--adapter-url", "http://127.0.0.1:8765"]
// }
// }
// }
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var adapterURL string
func init() {
flag.StringVar(&adapterURL, "adapter-url", "http://127.0.0.1:8765", "cursor-adapter HTTP base URL")
}
// --- Tool input/output types ---
type AskCursorInput struct {
Prompt string `json:"prompt" mcp:"required"`
Model string `json:"model"`
}
type EmptyInput struct{}
type TextOutput struct {
Text string `json:"text"`
}
// --- Tool handlers ---
func askCursor(ctx context.Context, _ *mcp.CallToolRequest, input AskCursorInput) (*mcp.CallToolResult, TextOutput, error) {
model := input.Model
if model == "" {
model = "claude-opus-4-7-high"
}
payload := map[string]interface{}{
"model": model,
"max_tokens": 16384,
"messages": []map[string]string{{"role": "user", "content": input.Prompt}},
"stream": false,
}
body, _ := json.Marshal(payload)
httpReq, err := http.NewRequestWithContext(ctx, "POST", adapterURL+"/v1/messages", bytes.NewReader(body))
if err != nil {
return nil, TextOutput{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", "mcp-bridge")
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(httpReq)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter at " + adapterURL + ". Make sure it is running."}},
IsError: true,
}, TextOutput{}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("❌ cursor-adapter HTTP %d: %s", resp.StatusCode, string(respBody))}},
IsError: true,
}, TextOutput{}, nil
}
var data struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &data); err != nil {
return nil, TextOutput{Text: string(respBody)}, nil
}
if data.Error != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cursor error: " + data.Error.Message}},
IsError: true,
}, TextOutput{}, nil
}
var texts []string
for _, block := range data.Content {
if block.Type == "text" {
texts = append(texts, block.Text)
}
}
result := strings.Join(texts, "\n")
if result == "" {
result = string(respBody)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("[Model: %s]\n\n%s", model, result)}},
}, TextOutput{Text: result}, nil
}
func listModels(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/v1/models", nil)
if err != nil {
return nil, TextOutput{}, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "❌ Cannot connect to cursor-adapter"}},
IsError: true,
}, TextOutput{}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var data struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &data); err != nil {
return nil, TextOutput{Text: string(respBody)}, nil
}
var lines []string
lines = append(lines, fmt.Sprintf("Available models (%d total):\n", len(data.Data)))
for _, m := range data.Data {
lines = append(lines, " "+m.ID)
}
text := strings.Join(lines, "\n")
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, TextOutput{Text: text}, nil
}
func checkHealth(ctx context.Context, _ *mcp.CallToolRequest, _ EmptyInput) (*mcp.CallToolResult, TextOutput, error) {
httpReq, err := http.NewRequestWithContext(ctx, "GET", adapterURL+"/health", nil)
if err != nil {
return nil, TextOutput{}, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "❌ cursor-adapter is not running"}},
IsError: true,
}, TextOutput{}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var pretty bytes.Buffer
text := string(respBody)
if err := json.Indent(&pretty, respBody, "", " "); err == nil {
text = pretty.String()
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, TextOutput{Text: text}, nil
}
func main() {
flag.Parse()
if envURL := os.Getenv("CURSOR_ADAPTER_URL"); envURL != "" {
adapterURL = envURL
}
server := mcp.NewServer(
&mcp.Implementation{
Name: "cursor-bridge",
Version: "1.0.0",
},
&mcp.ServerOptions{
Instructions: "This server provides access to the Cursor AI coding agent via cursor-adapter. " +
"Use ask_cursor to delegate coding tasks, code generation, debugging, or technical questions to Cursor.",
},
)
mcp.AddTool(server, &mcp.Tool{
Name: "ask_cursor",
Description: "Ask the Cursor AI agent a question or delegate a coding task. " +
"Use this when you need code generation, review, debugging, or a second opinion. " +
"The Cursor agent acts as a pure reasoning engine. " +
"Available models: claude-opus-4-7-high (default), claude-opus-4-7-thinking-high, " +
"claude-4.6-opus-high, claude-4.6-sonnet-medium, gpt-5.4-medium, gemini-3.1-pro. " +
"Pass model name in the 'model' field.",
}, askCursor)
mcp.AddTool(server, &mcp.Tool{
Name: "list_cursor_models",
Description: "List all available models from the Cursor adapter.",
}, listModels)
mcp.AddTool(server, &mcp.Tool{
Name: "cursor_health",
Description: "Check the health status of the cursor-adapter service.",
}, checkHealth)
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}

View File

@ -1,38 +0,0 @@
port: 8976
cursor_cli_path: agent
default_model: claude-sonnet-4-20250514
timeout: 300
max_concurrent: 5
use_acp: false
# Isolate Cursor CLI / ACP child in an empty temp workspace with
# HOME / CURSOR_CONFIG_DIR / XDG_CONFIG_HOME overridden so the agent can
# neither read the adapter's cwd nor load global rules from ~/.cursor.
# Recommended: true. Set to false only if you intentionally want the
# Cursor agent to see the adapter's working directory.
chat_only_workspace: true
# How to launch the Cursor CLI subprocess.
# plan (default): pass --mode plan; the CLI never executes tools, it
# just proposes plans. The proxy translates brain-side
# <tool_call>...</tool_call> sentinels into real Anthropic
# tool_use blocks for the calling client to execute.
# agent: omit --mode and add --trust; the CLI runs in its native agent
# mode with full filesystem/shell tools and acts inside
# workspace_root. Use this when you want the CLI itself to be
# the executor (e.g. let it reorganise ~/Desktop directly).
cursor_mode: plan
# Absolute directory the Cursor CLI subprocess runs in. Setting this
# disables the chat-only temp workspace isolation. Required when
# cursor_mode: agent if you want the CLI to act on a real folder.
# Per-request override: clients can send `X-Cursor-Workspace: /abs/path`.
# Example: workspace_root: /Users/daniel/Desktop
workspace_root: ""
log_level: INFO
available_models:
- claude-sonnet-4-20250514
- claude-opus-4-20250514
- gpt-5.2
- gemini-3.1-pro

View File

@ -1,110 +0,0 @@
port: 8765
cursor_cli_path: agent
default_model: claude-opus-4-7-high
timeout: 300
max_concurrent: 5
use_acp: false
chat_only_workspace: true
log_level: ERROR
system_prompt: |
You are the reasoning brain of a two-process AI system. The caller
(Claude Desktop / Claude Code / opencode) has the user's real machine,
filesystem, terminal, and any MCP-provided tools. You do not. You drive
it remotely by emitting tool invocations the proxy translates into real
Anthropic tool_use blocks for the caller to execute.
Each turn you receive (a) the user's request, (b) the list of executors
the caller currently has under "Available executors", and (c) a
transcript of past tool_use / tool_result blocks. Decide what to do
next.
CONTRACT — output exactly ONE of the following per turn:
1. A normal answer in plain markdown (when no tool is needed, or when
you are presenting the final result to the user).
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else
before or after:
<tool_call>
{"name":"<tool_name>","input": { ... }}
</tool_call>
- "name" must match one of the executors listed under "Available
executors". If no executors are listed, fall back to a plain-text
answer.
- "input" must conform to that tool's input_schema.
- Do not add commentary around the block. The proxy will translate
it into a real tool_use call; the caller will run it and send you
a tool_result on the next turn.
HARD RULES:
- NEVER claim you "cannot access the filesystem / terminal / browser",
"are in Ask/Plan mode", "are sandboxed", or "need the user to do it
manually". Those statements are false here — the caller is your
hands.
- NEVER fabricate tool names. If the tool you need is not listed,
explain in plain text what command/operation should be run and stop.
- Prefer a single tool_call per turn; iterate via the tool_result
feedback. Multi-step plans should be expressed as a sequence of
turns, not crammed into one block.
- Default target shell when emitting raw shell commands inside text:
zsh on macOS, unless the user says otherwise.
VOCABULARY — when the user says any of:
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
"這個資料夾" / "the folder" / "the project"
they ALWAYS mean the folder the caller (Claude Desktop / Claude
Code / opencode) attached or opened for this session — i.e. the
host-mounted folder visible to the caller's executor (typically
under `/sessions/<id>/mnt/<X>` or an absolute host path). They
NEVER mean the directory your own subprocess happens to be running
in, and they NEVER mean a sandbox path like `/sessions/.../mnt/`
with no folder under it. If you are tempted to call `pwd` and
answer with that, stop — the answer the user wants is the mount
root, found by listing `/sessions/*/mnt/*/` (see ORIENTATION
below) or by reading the "Known host-mount paths" section.
ORIENTATION (first turn of a fresh session):
The caller's executor often runs inside a sandbox (e.g. Claude
Desktop's Cowork) that bind-mounts ONE folder the user attached for
this session. The folder's name is unknown to you in advance — it
could be Desktop, a project root, Documents, anything. From the
sandbox it shows up under `/sessions/<id>/mnt/<whatever>`, and that
path IS the user's working folder for this conversation regardless of
its name.
If the user refers to "my folder" / "the mounted folder" / "this
project" / "the desktop" / etc. and you have a shell-like executor
available but no path has been established yet (no `Working
directory:` line, no "Known host-mount paths" section, no prior
tool_result revealing one), your FIRST tool_call must be a single
discovery probe that enumerates every mount under `/sessions/*/mnt/`,
e.g.:
<tool_call>
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
</tool_call>
Treat whatever directory comes back under `/sessions/*/mnt/<X>` as
THE working folder for this session, no matter what `<X>` is. Then
use that path (or subpaths under it) for every subsequent tool_call.
Do NOT ask the user to name or re-state the folder — they already
attached it. The proxy also re-surfaces previously discovered mount
roots under "Known host-mount paths" on later turns; prefer those
over re-probing.
available_models:
- claude-opus-4-7-high
- claude-opus-4-7-thinking-high
- claude-4.6-opus-high
- claude-4.6-opus-high-thinking
- claude-4.6-sonnet-medium
- claude-4.6-sonnet-medium-thinking
- claude-4.5-opus-high
- claude-4.5-sonnet
- claude-4-sonnet
- gpt-5.4-medium
- gpt-5.2
- gemini-3.1-pro

1069
docs/MIGRATION_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

462
docs/REFACTOR_TASKS.md Normal file
View File

@ -0,0 +1,462 @@
# REFACTOR TASKS
重構任務拆分,支援 git worktree 並行開發。
---
## Task Overview
### 並行策略
```
時間軸 ──────────────────────────────────────────────────────────────►
Task 0: Init (必須先完成)
├── Task 1: Domain Layer ─────────────────────────┐
│ │
│ ┌── Task 2: Infrastructure Layer ────────────┤── 並行
│ │ │
│ └── Task 3: Repository Layer ────────────────┘
│ (依賴 Task 1)
├── Task 4: Provider Layer ──────────────────────┐
│ (依賴 Task 1) │
│ │── 可並行
├── Task 5: Usecase Layer ───────────────────────┤
│ (依賴 Task 3) │
│ │
├── Task 6: Adapter Layer ───────────────────────┘
│ (依賴 Task 1)
├── Task 7: Internal Layer ──────────────────────┐
│ (整合所有,必須最後) │
│ │── 序列
├── Task 8: CLI Tools │
│ │
└── Task 9: Cleanup & Tests ────────────────────┘
```
### Worktree 分支規劃
| 分支名稱 | 基於 | 任務 | 可並行 |
|---------|------|------|--------|
| `refactor/init` | `master` | Task 0 | ❌ |
| `refactor/domain` | `refactor/init` | Task 1 | ✅ |
| `refactor/infrastructure` | `refactor/init` | Task 2 | ✅ |
| `refactor/repository` | `refactor/domain` | Task 3 | ✅ |
| `refactor/provider` | `refactor/domain` | Task 4 | ✅ |
| `refactor/usecase` | `refactor/repository` | Task 5 | ✅ |
| `refactor/adapter` | `refactor/domain` | Task 6 | ✅ |
| `refactor/internal` | 合併所有 | Task 7 | ❌ |
| `refactor/cli` | `refactor/init` | Task 8 | ✅ |
| `refactor/cleanup` | 合併所有 | Task 9 | ❌ |
---
## Task 0: 初始化
### 分支
`refactor/init`
### 依賴
無(必須先完成)
### 小任務
- [ ] **0.1** 更新 go.mod (5min)
- `go get github.com/zeromicro/go-zero@latest`
- `go mod tidy`
- [ ] **0.2** 建立目錄 (1min)
- `mkdir -p api etc`
- [ ] **0.3** 建立 `api/chat.api` (15min)
- 定義 API types
- 定義 routes
- [ ] **0.4** 建立 `etc/chat.yaml` (5min)
- 配置參數
- [ ] **0.5** 更新 Makefile (10min)
- 新增 goctl 命令
- [ ] **0.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 1: Domain Layer
### 分支
`refactor/domain`
### 依賴
Task 0 完成
### 小任務
- [ ] **1.1** 建立目錄結構 (1min)
- `pkg/domain/entity`
- `pkg/domain/repository`
- `pkg/domain/usecase`
- `pkg/domain/const`
- [ ] **1.2** `entity/message.go` (10min)
- Message, Tool, ToolFunction, ToolCall
- [ ] **1.3** `entity/chunk.go` (5min)
- StreamChunk, ChunkType
- [ ] **1.4** `entity/account.go` (5min)
- Account, AccountStat
- [ ] **1.5** `repository/account.go` (10min)
- AccountPool interface
- [ ] **1.6** `repository/provider.go` (5min)
- Provider interface
- [ ] **1.7** `usecase/chat.go` (15min)
- ChatUsecase interface
- [ ] **1.8** `usecase/agent.go` (5min)
- AgentRunner interface
- [ ] **1.9** `const/models.go` (10min)
- Model 常數
- [ ] **1.10** `const/errors.go` (5min)
- 錯誤定義
- [ ] **1.11** 提交 (2min)
**預估時間**: ~2h
---
## Task 2: Infrastructure Layer
### 分支
`refactor/infrastructure`
### 依賴
Task 0 完成(可與 Task 1 並行)
### 小任務
- [ ] **2.1** 建立目錄 (2min)
- `pkg/infrastructure/{process,parser,httputil,logger,env,workspace,winlimit}`
- [ ] **2.2** 遷移 process (10min)
- runner.go, kill_unix.go, kill_windows.go, process_test.go
- [ ] **2.3** 遷移 parser (5min)
- stream.go, stream_test.go
- [ ] **2.4** 遷移 httputil (5min)
- httputil.go, httputil_test.go
- [ ] **2.5** 遷移 logger (5min)
- logger.go
- [ ] **2.6** 遷移 env (5min)
- env.go, env_test.go
- [ ] **2.7** 遷移 workspace (5min)
- workspace.go
- [ ] **2.8** 遷移 winlimit (5min)
- winlimit.go, winlimit_test.go
- [ ] **2.9** 驗證編譯 (5min)
- [ ] **2.10** 提交 (2min)
**預估時間**: ~1h
---
## Task 3: Repository Layer
### 分支
`refactor/repository`
### 依賴
Task 1 完成
### 小任務
- [ ] **3.1** 建立目錄 (1min)
- [ ] **3.2** 遷移 account.go (20min)
- AccountPool 實作
- 移除全局變數
- [ ] **3.3** 遷移 provider.go (10min)
- Provider 工廠
- [ ] **3.4** 遷移測試 (5min)
- [ ] **3.5** 驗證編譯 (5min)
- [ ] **3.6** 提交 (2min)
**預估時間**: ~1h
---
## Task 4: Provider Layer
### 分支
`refactor/provider`
### 依賴
Task 1 完成
### 小任務
- [ ] **4.1** 建立目錄 (1min)
- `pkg/provider/cursor`
- `pkg/provider/geminiweb`
- [ ] **4.2** 遷移 cursor provider (5min)
- [ ] **4.3** 遷移 geminiweb provider (10min)
- [ ] **4.4** 更新 import (5min)
- [ ] **4.5** 驗證編譯 (5min)
- [ ] **4.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 5: Usecase Layer
### 分支
`refactor/usecase`
### 依賴
Task 3 完成
### 小任務
- [ ] **5.1** 建立目錄 (1min)
- [ ] **5.2** 建立 chat.go (30min)
- 核心聊天邏輯
- [ ] **5.3** 遷移 agent.go (20min)
- runner, token, cmdargs, maxmode
- [ ] **5.4** 遷移 sanitizer (10min)
- [ ] **5.5** 遷移 toolcall (10min)
- [ ] **5.6** 驗證編譯 (5min)
- [ ] **5.7** 提交 (2min)
**預估時間**: ~2h
---
## Task 6: Adapter Layer
### 分支
`refactor/adapter`
### 依賴
Task 1 完成
### 小任務
- [ ] **6.1** 建立目錄 (1min)
- [ ] **6.2** 遷移 openai adapter (10min)
- [ ] **6.3** 遷移 anthropic adapter (10min)
- [ ] **6.4** 更新 import (5min)
- [ ] **6.5** 驗證編譯 (5min)
- [ ] **6.6** 提交 (2min)
**預估時間**: ~30min
---
## Task 7: Internal Layer
### 分支
`refactor/internal`
### 依賴
Task 1-6 全部完成
### 小任務
- [ ] **7.1** 合併所有分支 (5min)
- [ ] **7.2** 更新 config/config.go (15min)
- 使用 rest.RestConf
- [ ] **7.3** 建立 svc/servicecontext.go (30min)
- DI 容器
- [ ] **7.4** 建立 logic/ (1h)
- chatcompletionlogic.go
- geminichatlogic.go
- anthropiclogic.go
- healthlogic.go
- modelslogic.go
- [ ] **7.5** 建立 handler/ (1h)
- 自訂 SSE handler
- [ ] **7.6** 建立 middleware/ (20min)
- auth.go
- recovery.go
- [ ] **7.7** 建立 types/ (5min)
- goctl 生成
- [ ] **7.8** 更新 import (30min)
- 批量更新
- [ ] **7.9** 驗證編譯 (10min)
- [ ] **7.10** 提交 (2min)
**預估時間**: ~4h
---
## Task 8: CLI Tools
### 分支
`refactor/cli`
### 依賴
Task 0 完成
### 小任務
- [ ] **8.1** 建立目錄 (1min)
- [ ] **8.2** 遷移 CLI 工具 (10min)
- [ ] **8.3** 遷移 gemini-login (5min)
- [ ] **8.4** 更新 import (5min)
- [ ] **8.5** 提交 (2min)
**預估時間**: ~30min
---
## Task 9: Cleanup & Tests
### 分支
`refactor/cleanup`
### 依賴
Task 7 完成
### 小任務
- [ ] **9.1** 移除舊目錄 (5min)
- [ ] **9.2** 更新 import (30min)
- 批量 sed
- [ ] **9.3** 建立 cmd/chat/chat.go (10min)
- [ ] **9.4** SSE 整合測試 (2h)
- [ ] **9.5** 回歸測試 (1h)
- [ ] **9.6** 更新 README (15min)
- [ ] **9.7** 提交 (2min)
**預估時間**: ~4h
---
## 並行執行計劃
### Wave 1 (可完全並行)
```
Terminal 1: Task 0 (init) → 30min
Terminal 2: (等待 Task 0)
```
### Wave 2 (可完全並行)
```
Terminal 1: Task 1 (domain) → 2h
Terminal 2: Task 2 (infrastructure) → 1h
Terminal 3: Task 8 (cli) → 30min
```
### Wave 3 (可部分並行)
```
Terminal 1: Task 3 (repository) → 1h (依賴 Task 1)
Terminal 2: Task 4 (provider) → 30min (依賴 Task 1)
Terminal 3: Task 6 (adapter) → 30min (依賴 Task 1)
Terminal 4: (等待 Task 3)
```
### Wave 4 (可部分並行)
```
Terminal 1: Task 5 (usecase) → 2h (依賴 Task 3)
Terminal 2: (等待 Task 5)
```
### Wave 5 (序列)
```
Task 7 (internal) → 4h
Task 9 (cleanup) → 4h
```
**總時間估計**:
- 完全序列: ~15h
- 並行執行: ~9h
- 節省: ~40%
---
## Git Worktree 指令
```bash
# 創建 worktrees
git worktree add ../worktrees/init -b refactor/init
git worktree add ../worktrees/domain -b refactor/domain
git worktree add ../worktrees/infrastructure -b refactor/infrastructure
git worktree add ../worktrees/repository -b refactor/repository
git worktree add ../worktrees/provider -b refactor/provider
git worktree add ../worktrees/usecase -b refactor/usecase
git worktree add ../worktrees/adapter -b refactor/adapter
git worktree add ../worktrees/cli -b refactor/cli
# 並行工作
cd ../worktrees/domain && # Terminal 1
cd ../worktrees/infrastructure && # Terminal 2
cd ../worktrees/cli && # Terminal 3
# 清理 worktrees
git worktree remove ../worktrees/init
git worktree remove ../worktrees/domain
# ... 等等
```
---
**文件版本**: v1.0
**建立日期**: 2026-04-03

312
docs/TODOS.md Normal file
View File

@ -0,0 +1,312 @@
# TODOS
重構 cursor-api-proxy → go-zero + DDD Architecture 的待辦事項。
---
## Phase 1: API 定義與骨架生成
### DONE
- [x] 建立 `api/chat.api` 定義檔
- [x] 建立 `etc/chat.yaml` 配置檔
- [x] 生成代碼骨架
- [x] 移動 `chat.go``cmd/chat/`
### TODO
#### TODO-1: 全局變數遷移清單
- **What**: 建立全局變數到 ServiceContext 的遷移清單
- **Why**: 現有代碼有多個全局變數,遷移時容易遺漏
- **Files**:
- `internal/pool/pool.go:36-38``globalPool`, `globalMu` → ServiceContext
- `internal/process/process.go:117``MaxModeFn` → ServiceContext
- `internal/handlers/chat.go:28-29``rateLimitRe`, `retryAfterRe` → ServiceContext 或常數
- `internal/models/cursormap.go:8,47,51` → 正則表達式常數化
- **Decision**: ServiceContext 注入
- **Effort**: human ~2h / CC ~30min
- **Depends on**: Phase 2 (Domain 層建立)
- **Status**: pending
#### TODO-2: go.mod 更新
- **What**: 添加 go-zero 依賴到 go.mod
- **Why**: 現有 go.mod 沒有 go-zero 依賴
- **Command**: `go get github.com/zeromicro/go-zero@latest`
- **Decision**: 使用最新穩定版
- **Effort**: human ~5min / CC ~1min
- **Depends on**: Phase 1 開始前
- **Status**: pending
#### TODO-3: Makefile 更新
- **What**: 更新 Makefile 以支援 go-zero 的建置流程
- **Why**: 需要新增 goctl 命令和整合現有 env/run 命令
- **Commands to add**:
```makefile
.PHONY: api
api:
goctl api go -api api/chat.api -dir . --style go_zero
.PHONY: api-doc
api-doc:
goctl api doc -api api/chat.api -dir docs/
.PHONY: gen
gen: api
go mod tidy
```
- **Decision**: 需要追蹤
- **Effort**: human ~30min / CC ~10min
- **Depends on**: Phase 1 (API 定義與骨架生成)
- **Status**: pending
---
## Phase 2: Domain 層建立
### DONE
- [ ] 建立 `pkg/domain/entity/`
- [ ] 建立 `pkg/domain/repository/`
- [ ] 建立 `pkg/domain/usecase/`
- [ ] 建立 `pkg/domain/const/`
### TODO
#### TODO-4: import 循環依賴檢測
- **What**: 在每個 Phase 完成後執行 `go build ./...` 檢測循環依賴
- **Why**: DDD 架構分層容易產生循環依賴
- **Potential cycles**:
- `pkg/usecase``pkg/domain/usecase`
- `pkg/repository``pkg/domain/repository`
- **Command**: `go build ./... && go test ./... -run=none`
- **Depends on**: 每個 Phase 完成後
- **Status**: pending
---
## Phase 8: Internal 層重組
### DONE
- [ ] 更新 `internal/config/config.go`
- [ ] 建立 `internal/svc/servicecontext.go`
- [ ] 建立 `internal/logic/`
- [ ] 建立 `internal/handler/`
- [ ] 建立 `internal/middleware/`
### TODO
#### TODO-5: SSE 整合測試
- **What**: 增加 SSE streaming 的端對端測試
- **Why**: SSE 是核心功能,自訂 handler 容易出錯,沒有測試覆蓋
- **Test cases**:
1. SSE streaming 請求正常返回
2. SSE client disconnect 正確處理
3. SSE timeout 正確處理
4. 非串流請求轉 SSE 格式
- **Implementation**:
```go
// tests/integration/sse_test.go
func TestSSEStreaming(t *testing.T) {
// 使用 httptest 模擬 SSE 客戶端
// 驗證 data: [DONE] 正確返回
}
```
- **Decision**: 使用 `rest.WithCustom` 路由
- **Effort**: human ~2h / CC ~30min
- **Depends on**: Phase 8 完成Internal 層重組)
- **Status**: pending
#### TODO-6: SSE Handler 實作
- **What**: 使用 `rest.WithCustom` 實作 SSE streaming handler
- **Why**: go-zero 標準 handler 不支援 SSE需要自訂
- **Implementation**:
```go
// internal/handler/chat_handler.go
func NewChatHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// SSE 設定
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 委託給 usecase
svcCtx.ChatUsecase.Stream(r.Context(), input, callback)
}
}
```
- **Decision**: 使用 `rest.WithCustom` 路由
- **Effort**: human ~2h / CC ~30min
- **Depends on**: Phase 6 (Usecase 層建立)
- **Status**: pending
---
## Phase 10: 清理與測試
### DONE
- [ ] 移除舊目錄
- [ ] 更新 import 路徑
- [ ] 執行測試
### TODO
#### TODO-7: 測試文件遷移
- **What**: 測試文件跟隨源碼遷移到 pkg/
- **Why**: 測試應該與源碼在同一目錄
- **Files to migrate**:
- `internal/httputil/httputil_test.go``pkg/infrastructure/httputil/`
- `internal/config/config_test.go``internal/config/` (保留)
- `internal/sanitize/sanitize_test.go``pkg/usecase/`
- `internal/models/cursormap_test.go``pkg/domain/const/`
- `internal/models/cursorcli_test.go``pkg/domain/const/`
- `internal/parser/stream_test.go``pkg/infrastructure/parser/`
- `internal/env/env_test.go``pkg/infrastructure/env/`
- `internal/winlimit/winlimit_test.go``pkg/infrastructure/winlimit/`
- `internal/anthropic/anthropic_test.go``pkg/adapter/anthropic/`
- `internal/pool/pool_test.go``pkg/repository/`
- `internal/openai/openai_test.go``pkg/adapter/openai/`
- `internal/process/process_test.go``pkg/infrastructure/process/`
- **Decision**: 測試遷移到 pkg/
- **Effort**: human ~1h / CC ~10min
- **Depends on**: Phase 3-7 完成
- **Status**: pending
#### TODO-8: ServiceContext 單例 Pool
- **What**: AccountPool 使用單例模式,透過 sync.Once 確保只初始化一次
- **Why**: 避免每次請求創建新 Pool 的開銷
- **Implementation**:
```go
// pkg/repository/account.go
var (
globalPool *AccountPool
globalPoolOnce sync.Once
)
func GetAccountPool(configDirs []string) *AccountPool {
globalPoolOnce.Do(func() {
globalPool = NewAccountPool(configDirs)
})
return globalPool
}
```
- **Decision**: 使用單例 Pool
- **Effort**: human ~30min / CC ~10min
- **Depends on**: Phase 4 (Repository 層實作)
- **Status**: pending
---
## Phase 獨立 TODO
### TODO-9: 回歸測試自動化
- **What**: 建立自動化回歸測試腳本
- **Why**: 確保每次遷移後功能正常
- **Script**:
```bash
# scripts/regression-test.sh
#!/bin/bash
set -e
echo "=== Health check ==="
curl -s http://localhost:8080/health | jq .
echo "=== Models list ==="
curl -s http://localhost:8080/v1/models | jq .
echo "=== Chat completion (non-streaming) ==="
curl -s -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":false}' | jq .
echo "=== Chat completion (streaming) ==="
curl -s -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}'
```
- **Depends on**: Phase 10 完成
- **Status**: pending
---
## Summary
| TODO | Phase | Effort | Status |
|------|-------|--------|--------|
| TODO-1: 全局變數遷移清單 | Phase 2 | 2h | pending |
| TODO-2: go.mod 更新 | Phase 1 | 5min | pending |
| TODO-3: Makefile 更新 | Phase 1 | 30min | pending |
| TODO-4: import 循環依賴檢測 | Each Phase | 5min | pending |
| TODO-5: SSE 整合測試 | Phase 8 | 2h | pending |
| TODO-6: SSE Handler 實作 | Phase 8 | 2h | pending |
| TODO-7: 測試文件遷移 | Phase 10 | 1h | pending |
| TODO-8: ServiceContext 單例 Pool | Phase 4 | 30min | pending |
| TODO-9: 回歸測試自動化 | Phase 10 | 30min | pending |
---
## Dependencies Graph
```
Phase 1 (API 定義)
├── TODO-2: go.mod 更新 (必须在開始前完成)
├── TODO-3: Makefile 更新
Phase 2 (Domain 層)
├── TODO-1: 全局變數遷移清單
├── TODO-4: import 循環依賴檢測
Phase 3 (Infrastructure 層)
├── TODO-4: import 循環依賴檢測
Phase 4 (Repository 層)
├── TODO-8: ServiceContext 單例 Pool
├── TODO-4: import 循環依賴檢測
Phase 5 (Provider 層)
├── TODO-4: import 循環依賴檢測
Phase 6 (Usecase 層)
├── TODO-4: import 循環依賴檢測
Phase 7 (Adapter 層)
├── TODO-4: import 循環依賴檢測
Phase 8 (Internal 層)
├── TODO-5: SSE 整合測試
├── TODO-6: SSE Handler 實作
├── TODO-4: import 循環依賴檢測
Phase 9 (CLI 工具)
├── TODO-4: import 循環依賴檢測
Phase 10 (清理與測試)
├── TODO-7: 測試文件遷移
├── TODO-9: 回歸測試自動化
├── TODO-4: import 循環依賴檢測
完成
```
---
**文件版本**: v1.0
**建立日期**: 2026-04-03
**最後更新**: 2026-04-03

29
docs/refactor.md Normal file
View File

@ -0,0 +1,29 @@
```mermaid
.
├── build/ # [Infrastructure] 存放 Dockerfile 與建置相關腳本
├── docker-compose.yml # [Infrastructure] 本地開發環境編排 (Mongo, Redis, etc.)
├── etc/ # [Configuration] 存放各環境的 yaml 設定檔範本
├── generate/ # [Contract] Interface First 定義區
│ └── api/ # 存放 .api 原始定義,作為服務間的通訊契約
│ └── database/ # 如果有必要請幫我放建立db 的檔案
├── internal/ # [Framework Layer] 強依賴 go-zero 框架的實作區
│ ├── config/ # 框架層的 Config mapping
│ ├── logic/ # Adapter: 負責將框架 Request 轉接至 pkg/usecase
│ ├── server/ # Transport: gRPC/HTTP Server 實作 (僅處理協議)
│ └── svc/ # DI Center: 依賴注入中心,管理全域 Resource (DB, Client)
├── pkg/ # [Core Domain] 核心業務邏輯 (不依賴 go-zero 框架)
│ ├── domain/ # <Domain Layer>
│ │ ├── entity/ # 純粹的業務物件 (POJO/POCO),不含資料庫標籤
│ │ ├── repository/ # Repository Interface: 定義資料存取規範 (DIP)
│ │ ├── const/ # 常數
│ │ └── usecase/ # Usecase Interface: 定義業務功能的 API 契約
│ ├── repository/ # <Infrastructure Layer>
│ │ ├── *_test.go # 使用 Testcontainers (Real DB) 進行整合測試
│ │ └── *.go # 實作 domain/repository 接口 (MongoDB/Redis)
│ └── usecase/ # <Application Layer>
│ ├── *_test.go # 核心業務邏輯的 Unit Test (使用 Mock)
│ └── *.go # 實作業務流程,協調 Repository 與 Utils
├── Makefile # [Automation] 封裝 protoc, test, build 等常用指令
├── go.mod # [Dependency]
└── main.go # [Entry] 服務啟動進入點
```

42
etc/chat-api.yaml Normal file
View File

@ -0,0 +1,42 @@
Name: api-proxy
Host: 0.0.0.0
Port: 8766
Timeout: 3600000
# Cursor Agent 配置
AgentBin: /Users/daniel/.local/bin/agent
DefaultModel: claude-4.5-sonnet
Provider: cursor
TimeoutMs: 3600000
# 多帳號池配置
ConfigDirs:
- ~/.cursor-api-proxy/accounts/default
MultiPort: false
# TLS 憑證(可選)
TLSCertPath: ""
TLSKeyPath: ""
# 日誌
SessionsLogPath: ""
# Verbose 使用 RestConf 預設值
# Gemini Web Provider 配置
GeminiAccountDir: ~/.cursor-api-proxy/gemini-accounts
GeminiBrowserVisible: false
GeminiMaxSessions: 10
# 工作區配置
Workspace: ""
ChatOnlyWorkspace: true
WinCmdlineMax: 32768
# Agent 行為
Force: false
ApproveMcps: false
MaxMode: false
StrictModel: true
# API Key可選留空則不驗證
RequiredKey: ""

45
etc/chat.yaml Normal file
View File

@ -0,0 +1,45 @@
Name: chat-api
Host: ${CURSOR_BRIDGE_HOST:0.0.0.0}
Port: ${CURSOR_BRIDGE_PORT:8080}
# API Key 驗證(可選)
# Auth:
# AccessSecret: ${CURSOR_API_KEY:}
# AccessExpire: 86400
# Cursor 配置
AgentBin: ${CURSOR_AGENT_BIN:cursor-agent}
DefaultModel: ${CURSOR_DEFAULT_MODEL:claude-3.5-sonnet}
Provider: ${CURSOR_PROVIDER:cursor}
# 超時設定
TimeoutMs: ${CURSOR_TIMEOUT_MS:300000}
# 多帳號池
ConfigDirs:
- ${HOME}/.cursor-api-proxy/accounts/default
MultiPort: false
# TLS
TLSCertPath: ${CURSOR_TLS_CERT_PATH:}
TLSKeyPath: ${CURSOR_TLS_KEY_PATH:}
# 日誌
SessionsLogPath: ${CURSOR_SESSIONS_LOG_PATH:}
Verbose: ${CURSOR_VERBOSE:false}
# Gemini 設定
GeminiAccountDir: ${GEMINI_ACCOUNT_DIR:}
GeminiBrowserVisible: ${GEMINI_BROWSER_VISIBLE:false}
GeminiMaxSessions: ${GEMINI_MAX_SESSIONS:10}
# 工作區設定
Workspace: ${CURSOR_WORKSPACE:}
ChatOnlyWorkspace: ${CURSOR_CHAT_ONLY_WORKSPACE:true}
WinCmdlineMax: ${CURSOR_WIN_CMDLINE_MAX:32768}
# Agent 設定
Force: ${CURSOR_FORCE:false}
ApproveMcps: ${CURSOR_APPROVE_MCPS:false}
MaxMode: ${CURSOR_MAX_MODE:false}
StrictModel: ${CURSOR_STRICT_MODEL:true}

78
go.mod
View File

@ -1,21 +1,71 @@
module github.com/daniel/cursor-adapter
module cursor-api-proxy
go 1.26.1
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
github.com/go-rod/rod v0.116.2
github.com/google/uuid v1.6.0
github.com/playwright-community/playwright-go v0.5700.1
github.com/zeromicro/go-zero v1.10.1
modernc.org/sqlite v1.48.0
)
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/grafana/pyroscope-go v1.2.8 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/titanous/json5 v1.0.0 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

269
go.sum
View File

@ -1,35 +1,248 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M=
github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE=
go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

BIN
internal/.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,332 +0,0 @@
package bridge
import (
"context"
"encoding/json"
"io"
"log/slog"
"os/exec"
"strings"
"testing"
"time"
)
func cliOpts(path string, chatOnly bool, max int, timeout time.Duration) Options {
return Options{CursorPath: path, ChatOnly: chatOnly, MaxConcurrent: max, Timeout: timeout}
}
func TestNewBridge(t *testing.T) {
b := NewCLIBridge(cliOpts("/usr/bin/agent", false, 4, 30*time.Second))
if b == nil {
t.Fatal("NewCLIBridge returned nil")
}
if b.cursorPath != "/usr/bin/agent" {
t.Errorf("cursorPath = %q, want %q", b.cursorPath, "/usr/bin/agent")
}
if cap(b.semaphore) != 4 {
t.Errorf("semaphore capacity = %d, want 4", cap(b.semaphore))
}
if b.timeout != 30*time.Second {
t.Errorf("timeout = %v, want 30s", b.timeout)
}
}
func TestNewBridge_DefaultConcurrency(t *testing.T) {
b := NewCLIBridge(cliOpts("agent", false, 0, 10*time.Second))
if cap(b.semaphore) != 1 {
t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore))
}
}
func TestNewBridge_NegativeConcurrency(t *testing.T) {
b := NewCLIBridge(cliOpts("agent", false, -5, 10*time.Second))
if cap(b.semaphore) != 1 {
t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore))
}
}
func TestNewBridge_UsesACPWhenRequested(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
b := NewBridge(Options{CursorPath: "agent", Logger: logger, UseACP: true, MaxConcurrent: 2, Timeout: 10 * time.Second})
if _, ok := b.(*ACPBridge); !ok {
t.Fatalf("expected ACPBridge, got %T", b)
}
}
func TestBuildACPCommandArgs_NoModel(t *testing.T) {
got := buildACPCommandArgs("/tmp/workspace", "auto")
want := []string{"--workspace", "/tmp/workspace", "acp"}
if len(got) != len(want) {
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
}
}
}
func TestBuildACPCommandArgs_WithModel(t *testing.T) {
got := buildACPCommandArgs("/tmp/workspace", "sonnet-4.6")
want := []string{"--workspace", "/tmp/workspace", "--model", "sonnet-4.6", "acp"}
if len(got) != len(want) {
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
}
}
}
func TestBuildCLICommandArgs_PlanMode(t *testing.T) {
got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", "plan", true, false)
wantPrefix := []string{
"--print",
"--mode", "plan",
"--workspace", "/tmp/workspace",
"--model", "auto",
"--stream-partial-output", "--output-format", "stream-json",
}
if len(got) != len(wantPrefix)+1 {
t.Fatalf("unexpected arg length: %v", got)
}
for i := range wantPrefix {
if got[i] != wantPrefix[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], wantPrefix[i], got)
}
}
if got[len(got)-1] != "hello" {
t.Fatalf("last arg = %q, want prompt", got[len(got)-1])
}
}
func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
got := buildCLICommandArgs("hi", "", "/tmp/ws", "plan", false, true)
found := false
for _, a := range got {
if a == "--trust" {
found = true
break
}
}
if !found {
t.Fatalf("expected --trust when chatOnly=true, got args: %v", got)
}
}
func TestBuildCLICommandArgs_AgentModeOmitsModeFlagAndAddsTrust(t *testing.T) {
got := buildCLICommandArgs("hi", "", "/Users/me/Desktop", "agent", false, false)
for _, a := range got {
if a == "--mode" {
t.Fatalf("agent mode should not emit --mode flag, args: %v", got)
}
}
hasTrust := false
for _, a := range got {
if a == "--trust" {
hasTrust = true
break
}
}
if !hasTrust {
t.Fatalf("agent mode should imply --trust, args: %v", got)
}
}
// mockCmdBridge builds a bridge that executes a fake command for channel logic testing.
//
//nolint:unused
func mockCmdBridge(t *testing.T) *CLIBridge {
t.Helper()
return NewCLIBridge(cliOpts("echo", false, 2, 5*time.Second))
}
func TestExecute_ContextCancelled(t *testing.T) {
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
outputChan, errChan := b.Execute(ctx, "test prompt", "gpt-4", "")
// Should receive error due to cancelled context
select {
case err := <-errChan:
if err == nil {
t.Error("expected error from cancelled context, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for error from cancelled context")
}
// outputChan should be closed
select {
case _, ok := <-outputChan:
if ok {
t.Error("expected outputChan to be closed")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for outputChan to close")
}
}
func TestExecute_SemaphoreBlocking(t *testing.T) {
b := NewCLIBridge(cliOpts("/bin/sleep", false, 1, 30*time.Second))
// Fill the semaphore
b.semaphore <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, errChan := b.Execute(ctx, "test", "model", "")
// Should get error because semaphore is full and context times out
select {
case err := <-errChan:
if err == nil {
t.Error("expected error, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for semaphore blocking error")
}
// Release the semaphore
<-b.semaphore
}
func TestExecute_InvalidCommand(t *testing.T) {
b := NewCLIBridge(cliOpts("/nonexistent/command", false, 1, 5*time.Second))
ctx := context.Background()
outputChan, errChan := b.Execute(ctx, "test", "model", "")
var outputs []string
for line := range outputChan {
outputs = append(outputs, line)
}
// Should have error from starting invalid command
select {
case err := <-errChan:
if err == nil {
t.Error("expected error for invalid command, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for error")
}
}
func TestExecute_ValidJSONOutput(t *testing.T) {
// Use "printf" to simulate JSON line output
b := NewCLIBridge(cliOpts("printf", false, 2, 5*time.Second))
ctx := context.Background()
// printf with JSON lines
outputChan, errChan := b.Execute(ctx, `{"type":"assistant","message":{"content":[{"text":"hello"}]}}\n{"type":"result"}`, "model", "")
var outputs []string
for line := range outputChan {
outputs = append(outputs, line)
}
// Check errChan for any errors
select {
case err := <-errChan:
if err != nil {
t.Logf("error (may be expected): %v", err)
}
default:
}
if len(outputs) == 0 {
t.Log("no outputs received (printf may not handle newlines as expected)")
} else {
t.Logf("received %d output lines", len(outputs))
}
}
func TestHandleACPNotification_ForwardsAgentMessageChunk(t *testing.T) {
w := &acpWorker{}
var got strings.Builder
w.setActiveSinkLocked(func(text string) {
got.WriteString(text)
})
params, err := json.Marshal(map[string]interface{}{
"sessionId": "s1",
"update": map[string]interface{}{
"sessionUpdate": "agent_message_chunk",
"content": map[string]interface{}{
"type": "text",
"text": "嗨",
},
},
})
if err != nil {
t.Fatalf("marshal params: %v", err)
}
handled := w.handleACPNotification(acpMessage{
Method: "session/update",
Params: params,
})
if !handled {
t.Fatal("expected session/update to be handled")
}
if got.String() != "嗨" {
t.Fatalf("sink text = %q, want %q", got.String(), "嗨")
}
}
func TestHandleACPNotification_IgnoresNonAssistantContentUpdate(t *testing.T) {
w := &acpWorker{}
var got strings.Builder
w.setActiveSinkLocked(func(text string) {
got.WriteString(text)
})
params, err := json.Marshal(map[string]interface{}{
"sessionId": "s1",
"update": map[string]interface{}{
"sessionUpdate": "planner_thought_chunk",
"content": map[string]interface{}{
"type": "text",
"text": "Handling user greetings",
},
},
})
if err != nil {
t.Fatalf("marshal params: %v", err)
}
handled := w.handleACPNotification(acpMessage{
Method: "session/update",
Params: params,
})
if !handled {
t.Fatal("expected session/update to be handled")
}
if got.String() != "" {
t.Fatalf("sink text = %q, want empty", got.String())
}
}
func TestReadLoop_DoesNotPanicWhenReaderDoneIsNil(t *testing.T) {
w := &acpWorker{
pending: make(map[int]chan acpResponse),
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("readLoop should not panic when readerDone is nil, got %v", r)
}
}()
w.readLoop(io.NopCloser(strings.NewReader("")))
}
// Ensure exec is used (imported but may appear unused without integration tests)
var _ = exec.Command

View File

@ -1,202 +1,162 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"cursor-api-proxy/pkg/infrastructure/env"
"github.com/zeromicro/go-zero/rest"
)
// Config for go-zero (generated by goctl)
type Config struct {
Port int `yaml:"port"`
CursorCLIPath string `yaml:"cursor_cli_path"`
DefaultModel string `yaml:"default_model"`
Timeout int `yaml:"timeout"`
MaxConcurrent int `yaml:"max_concurrent"`
UseACP bool `yaml:"use_acp"`
ChatOnlyWorkspace bool `yaml:"chat_only_workspace"`
LogLevel string `yaml:"log_level"`
AvailableModels []string `yaml:"available_models,omitempty"`
SystemPrompt string `yaml:"system_prompt"`
rest.RestConf
// CursorMode controls how the Cursor CLI subprocess is launched.
// "plan" (default): pass `--mode plan`. The CLI never executes
// tools; it only proposes plans. Combined with brain
// SystemPrompt + <tool_call> sentinel translation, the
// caller (Claude Desktop) is the executor.
// "agent": omit `--mode`, letting Cursor CLI use its native agent
// mode with full filesystem/shell tools. The CLI itself
// becomes the executor and acts inside WorkspaceRoot.
CursorMode string `yaml:"cursor_mode"`
// Cursor 配置
AgentBin string
DefaultModel string
Provider string
TimeoutMs int
// WorkspaceRoot, when non-empty, is the absolute directory the Cursor
// CLI subprocess runs in (and treats as its project root). Setting
// this disables the chat-only temp workspace isolation. Useful when
// you want the CLI to actually edit files on the host (e.g. set to
// /Users/<you>/Desktop and use cursor_mode: agent to let it
// reorganise that folder directly).
//
// Per-request override: clients may send `X-Cursor-Workspace: /abs/path`
// to switch the working directory just for that call.
WorkspaceRoot string `yaml:"workspace_root"`
// 多帳號池
ConfigDirs []string
MultiPort bool
// TLS
TLSCertPath string
TLSKeyPath string
// 日誌
SessionsLogPath string
// Verbose is inherited from rest.RestConf
// Gemini
GeminiAccountDir string
GeminiBrowserVisible bool
GeminiMaxSessions int
// 工作區
Workspace string
ChatOnlyWorkspace bool
WinCmdlineMax int
// Agent
Force bool
ApproveMcps bool
MaxMode bool
StrictModel bool
// API Key
RequiredKey string
}
// Defaults returns a Config populated with default values.
//
// ChatOnlyWorkspace defaults to true. This is the cursor-api-proxy posture:
// every Cursor CLI / ACP child is spawned in an empty temp directory with
// HOME / CURSOR_CONFIG_DIR overridden so it cannot see the host user's real
// project files or global ~/.cursor rules. Set to false only if you really
// want the Cursor agent to have access to the cwd where cursor-adapter
// started.
func Defaults() Config {
return Config{
Port: 8976,
CursorCLIPath: "agent",
DefaultModel: "auto",
Timeout: 300,
MaxConcurrent: 5,
UseACP: false,
ChatOnlyWorkspace: true,
LogLevel: "INFO",
SystemPrompt: DefaultSystemPrompt,
CursorMode: "plan",
WorkspaceRoot: "",
}
// BridgeConfig for backward compatibility with existing code
type BridgeConfig struct {
AgentBin string
Host string
Port int
RequiredKey string
DefaultModel string
Mode string
Provider string
Force bool
ApproveMcps bool
StrictModel bool
Workspace string
TimeoutMs int
TLSCertPath string
TLSKeyPath string
SessionsLogPath string
ChatOnlyWorkspace bool
Verbose bool
MaxMode bool
ConfigDirs []string
MultiPort bool
WinCmdlineMax int
GeminiAccountDir string
GeminiBrowserVisible bool
GeminiMaxSessions int
}
// DefaultSystemPrompt is prepended to every prompt sent to the Cursor CLI.
// It puts the model in "remote brain" mode: it never executes anything
// directly; instead it either answers in plain text or emits a single
// <tool_call>{...}</tool_call> sentinel that the proxy translates into a
// real Anthropic tool_use block for the caller (Claude Desktop / Claude
// Code / opencode) to execute. The caller's tool_result comes back as
// transcript on the next turn.
const DefaultSystemPrompt = `You are the reasoning brain of a two-process AI system. ` +
`The caller (Claude Desktop / Claude Code / opencode) has the user's real machine, ` +
`filesystem, terminal, and any MCP-provided tools. You do not. You drive it remotely ` +
`by emitting tool invocations the proxy translates into real Anthropic tool_use blocks ` +
`for the caller to execute.
Each turn you receive (a) the user's request, (b) the list of executors the caller ` +
`currently has under "Available executors", and (c) a transcript of past tool_use / ` +
`tool_result blocks.
CONTRACT output exactly ONE of:
1. A normal answer in plain markdown (when no tool is needed, or when you are ` +
`presenting the final result to the user).
2. EXACTLY one tool invocation, wrapped in sentinels with NOTHING else around it:
<tool_call>
{"name":"<tool_name>","input": { ... }}
</tool_call>
"name" must match an executor listed under "Available executors"; "input" must ` +
`conform to that tool's input_schema.
HARD RULES:
- NEVER claim you "cannot access the filesystem / terminal / browser", "are in Ask ` +
`or Plan mode", "are sandboxed", or "need the user to do it manually". Those ` +
`statements are false the caller is your hands.
- NEVER fabricate tool names. If the tool you need is not in the list, explain in ` +
`plain text what should be run and stop.
- Prefer a single tool_call per turn; iterate via tool_result feedback.
- Default shell when emitting raw commands as text: zsh on macOS.
VOCABULARY when the user says any of:
"工作目錄" / "working directory" / "cwd" / "pwd" / "目前資料夾" /
"這個資料夾" / "the folder" / "the project"
they ALWAYS mean the folder the caller (Claude Desktop / Claude Code / opencode) ` +
`attached or opened for this session — i.e. the host-mounted folder visible ` +
`to the caller's executor (typically under ` + "`/sessions/<id>/mnt/<X>`" + ` or ` +
`an absolute host path). They NEVER mean the directory your own subprocess ` +
`happens to be running in, and they NEVER mean a sandbox path like ` +
"`/sessions/.../mnt/`" + ` with no folder under it. If you are tempted to call ` +
"`pwd`" + ` and answer with that, stop — the answer the user wants is the ` +
`mount root, which is found by listing ` + "`/sessions/*/mnt/*/`" + ` (see ` +
`ORIENTATION below) or by reading the "Known host-mount paths" section.
ORIENTATION (first turn of a fresh session):
The caller's executor often runs inside a sandbox (e.g. Claude Desktop's ` +
`Cowork) that bind-mounts ONE folder the user attached for this session. ` +
`The folder's name is unknown to you in advance — it could be Desktop, a ` +
`project root, Documents, anything. From the sandbox it shows up under ` +
"`/sessions/<id>/mnt/<whatever>`" + `, and that path IS the user's working ` +
`folder for this conversation regardless of its name.
If the user refers to "my folder" / "the mounted folder" / "this project" / ` +
`"the desktop" / etc. and you have a shell-like executor available but no ` +
`path has been established yet (no ` + "`Working directory:`" + ` line, no ` +
`"Known host-mount paths" section, no prior tool_result revealing one), ` +
`your FIRST tool_call must be a single discovery probe that enumerates ` +
`every mount under ` + "`/sessions/*/mnt/`" + `, e.g.:
<tool_call>
{"name":"<shell_tool>","input":{"command":"pwd; ls -d /sessions/*/mnt/*/ 2>/dev/null; ls -la /workspace 2>/dev/null | head"}}
</tool_call>
Treat whatever directory comes back under ` + "`/sessions/*/mnt/<X>`" + ` as ` +
`THE working folder for this session, no matter what ` + "`<X>`" + ` is. ` +
`Then use that path (or subpaths under it) for every subsequent tool_call. ` +
`Do NOT ask the user to name or re-state the folder — they already attached ` +
`it. The proxy also re-surfaces previously discovered mount roots under ` +
`"Known host-mount paths" on later turns; prefer those over re-probing.`
// Load reads a YAML config file from path. If path is empty it defaults to
// ~/.cursor-adapter/config.yaml. When the file does not exist, a config with
// default values is returned without an error.
func Load(path string) (*Config, error) {
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolving home directory: %w", err)
}
path = filepath.Join(home, ".cursor-adapter", "config.yaml")
// ToBridgeConfig converts Config to BridgeConfig
func (c Config) ToBridgeConfig() BridgeConfig {
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
c := Defaults()
return &c, nil
}
return nil, fmt.Errorf("reading config file %s: %w", path, err)
}
cfg := Defaults()
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config file %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("validating config: %w", err)
}
return &cfg, nil
}
func (c *Config) validate() error {
if c.Port <= 0 {
return fmt.Errorf("port must be > 0, got %d", c.Port)
}
if c.CursorCLIPath == "" {
return fmt.Errorf("cursor_cli_path must not be empty")
}
if c.Timeout <= 0 {
return fmt.Errorf("timeout must be > 0, got %d", c.Timeout)
}
switch c.CursorMode {
case "", "plan", "agent":
default:
return fmt.Errorf("cursor_mode must be \"plan\" or \"agent\", got %q", c.CursorMode)
}
if c.WorkspaceRoot != "" {
if !filepath.IsAbs(c.WorkspaceRoot) {
return fmt.Errorf("workspace_root must be an absolute path, got %q", c.WorkspaceRoot)
configDirs := c.ConfigDirs
if len(configDirs) == 0 {
configDirs = []string{filepath.Join(home, ".cursor-api-proxy", "accounts", "default")}
} else {
for i, dir := range configDirs {
if len(dir) > 0 && dir[0] == '~' {
configDirs[i] = filepath.Join(home, dir[1:])
}
}
}
return nil
geminiDir := c.GeminiAccountDir
if geminiDir != "" && geminiDir[0] == '~' {
geminiDir = filepath.Join(home, geminiDir[1:])
}
return BridgeConfig{
AgentBin: c.AgentBin,
Host: c.Host,
Port: c.Port,
RequiredKey: c.RequiredKey,
DefaultModel: c.DefaultModel,
Mode: "ask",
Provider: c.Provider,
Force: c.Force,
ApproveMcps: c.ApproveMcps,
StrictModel: c.StrictModel,
Workspace: c.Workspace,
TimeoutMs: c.TimeoutMs,
TLSCertPath: c.TLSCertPath,
TLSKeyPath: c.TLSKeyPath,
SessionsLogPath: c.SessionsLogPath,
ChatOnlyWorkspace: c.ChatOnlyWorkspace,
Verbose: c.Verbose,
MaxMode: c.MaxMode,
ConfigDirs: configDirs,
MultiPort: c.MultiPort,
WinCmdlineMax: c.WinCmdlineMax,
GeminiAccountDir: geminiDir,
GeminiBrowserVisible: c.GeminiBrowserVisible,
GeminiMaxSessions: c.GeminiMaxSessions,
}
}
// LoadBridgeConfig loads config from environment (for backward compatibility)
func LoadBridgeConfig(e env.EnvSource, cwd string) BridgeConfig {
loaded := env.LoadEnvConfig(e, cwd)
return BridgeConfig{
AgentBin: loaded.AgentBin,
Host: loaded.Host,
Port: loaded.Port,
RequiredKey: loaded.RequiredKey,
DefaultModel: loaded.DefaultModel,
Mode: "ask",
Provider: loaded.Provider,
Force: loaded.Force,
ApproveMcps: loaded.ApproveMcps,
StrictModel: loaded.StrictModel,
Workspace: loaded.Workspace,
TimeoutMs: loaded.TimeoutMs,
TLSCertPath: loaded.TLSCertPath,
TLSKeyPath: loaded.TLSKeyPath,
SessionsLogPath: loaded.SessionsLogPath,
ChatOnlyWorkspace: loaded.ChatOnlyWorkspace,
Verbose: loaded.Verbose,
MaxMode: loaded.MaxMode,
ConfigDirs: loaded.ConfigDirs,
MultiPort: loaded.MultiPort,
WinCmdlineMax: loaded.WinCmdlineMax,
GeminiAccountDir: loaded.GeminiAccountDir,
GeminiBrowserVisible: loaded.GeminiBrowserVisible,
GeminiMaxSessions: loaded.GeminiMaxSessions,
}
}

View File

@ -1,160 +1,60 @@
package config
package config_test
import (
"os"
"path/filepath"
"reflect"
"testing"
"cursor-api-proxy/internal/config"
)
func TestDefaults(t *testing.T) {
d := Defaults()
func TestConfigToBridgeConfig(t *testing.T) {
cfg := config.Config{}
if d.Port != 8976 {
t.Errorf("expected port 8976, got %d", d.Port)
bc := cfg.ToBridgeConfig()
if bc.Host != "" {
t.Errorf("Host = %q, want empty", bc.Host)
}
if d.CursorCLIPath != "agent" {
t.Errorf("expected cursor_cli_path 'agent', got %q", d.CursorCLIPath)
}
if d.DefaultModel != "auto" {
t.Errorf("expected default_model 'auto', got %q", d.DefaultModel)
}
if d.Timeout != 300 {
t.Errorf("expected timeout 300, got %d", d.Timeout)
}
if d.MaxConcurrent != 5 {
t.Errorf("expected max_concurrent 5, got %d", d.MaxConcurrent)
}
if d.LogLevel != "INFO" {
t.Errorf("expected log_level 'INFO', got %q", d.LogLevel)
}
if d.UseACP {
t.Errorf("expected use_acp false by default, got true")
if bc.Mode != "ask" {
t.Errorf("Mode = %q, want ask", bc.Mode)
}
}
func TestLoadValidYAML(t *testing.T) {
content := `port: 9000
cursor_cli_path: mycli
default_model: gpt-5.2
timeout: 60
max_concurrent: 10
use_acp: true
log_level: DEBUG
available_models:
- gpt-5.2
- claude-sonnet-4-20250514
`
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := Config{
Port: 9000,
CursorCLIPath: "mycli",
DefaultModel: "gpt-5.2",
Timeout: 60,
MaxConcurrent: 10,
UseACP: true,
func TestConfigToBridgeConfigWithValues(t *testing.T) {
cfg := config.Config{
AgentBin: "cursor",
DefaultModel: "claude-3.5-sonnet",
Provider: "cursor",
TimeoutMs: 300000,
Force: true,
ApproveMcps: true,
StrictModel: true,
Workspace: "/tmp/test",
ChatOnlyWorkspace: true,
LogLevel: "DEBUG",
AvailableModels: []string{"gpt-5.2", "claude-sonnet-4-20250514"},
SystemPrompt: DefaultSystemPrompt,
CursorMode: "plan",
WorkspaceRoot: "",
GeminiAccountDir: "/tmp/gemini",
GeminiMaxSessions: 5,
}
if !reflect.DeepEqual(*cfg, want) {
t.Errorf("mismatch\n got: %+v\nwant: %+v", *cfg, want)
bc := cfg.ToBridgeConfig()
if bc.AgentBin != "cursor" {
t.Errorf("AgentBin = %q, want cursor", bc.AgentBin)
}
if bc.DefaultModel != "claude-3.5-sonnet" {
t.Errorf("DefaultModel = %q, want claude-3.5-sonnet", bc.DefaultModel)
}
if bc.TimeoutMs != 300000 {
t.Errorf("TimeoutMs = %d, want 300000", bc.TimeoutMs)
}
if !bc.Force {
t.Error("Force should be true")
}
if !bc.ApproveMcps {
t.Error("ApproveMcps should be true")
}
if !bc.StrictModel {
t.Error("StrictModel should be true")
}
if bc.Mode != "ask" {
t.Errorf("Mode = %q, want ask", bc.Mode)
}
}
func TestLoadMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "does-not-exist.yaml")
cfg, err := Load(path)
if err != nil {
t.Fatalf("expected no error for missing file, got: %v", err)
}
d := Defaults()
if !reflect.DeepEqual(*cfg, d) {
t.Errorf("expected defaults\n got: %+v\nwant: %+v", *cfg, d)
}
}
func TestLoadInvalidYAML(t *testing.T) {
content := `{{not valid yaml`
dir := t.TempDir()
path := filepath.Join(dir, "bad.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error for invalid YAML, got nil")
}
}
func TestLoadValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "zero port",
yaml: "port: 0\ncursor_cli_path: agent\ntimeout: 10\n",
wantErr: "port must be > 0",
},
{
name: "empty cursor_cli_path",
yaml: "port: 80\ncursor_cli_path: \"\"\ntimeout: 10\n",
wantErr: "cursor_cli_path must not be empty",
},
{
name: "zero timeout",
yaml: "port: 80\ncursor_cli_path: agent\ntimeout: 0\n",
wantErr: "timeout must be > 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(tt.yaml), 0644); err != nil {
t.Fatal(err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error, got nil")
}
if got := err.Error(); !contains(got, tt.wantErr) {
t.Errorf("error %q should contain %q", got, tt.wantErr)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -1,183 +0,0 @@
package converter
import (
"encoding/json"
"fmt"
"strings"
"github.com/daniel/cursor-adapter/internal/types"
)
// CursorLine 代表 Cursor CLI stream-json 的一行。
type CursorLine struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
Message *CursorMessage `json:"message,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
Usage *CursorUsage `json:"usage,omitempty"`
}
// FlexibleContent 可以是 string 或 []CursorContent。
type FlexibleContent []CursorContent
func (fc *FlexibleContent) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*fc = []CursorContent{{Type: "text", Text: s}}
return nil
}
var items []CursorContent
if err := json.Unmarshal(data, &items); err != nil {
return err
}
*fc = items
return nil
}
type CursorMessage struct {
Role string `json:"role"`
Content FlexibleContent `json:"content"`
}
type CursorContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
type CursorUsage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
// ConvertResult 是轉換一行的結果。
type ConvertResult struct {
Chunk *types.ChatCompletionChunk
Done bool
Skip bool
Error error
Usage *CursorUsage
}
// StreamParser tracks accumulated assistant output so the OpenAI stream only
// emits newly appended text, not the full Cursor message each time.
type StreamParser struct {
chatID string
accumulated string
lastRawText string
}
func NewStreamParser(chatID string) *StreamParser {
return &StreamParser{chatID: chatID}
}
func (p *StreamParser) Parse(line string) ConvertResult {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return ConvertResult{Skip: true}
}
var cl CursorLine
if err := json.Unmarshal([]byte(trimmed), &cl); err != nil {
return ConvertResult{Error: fmt.Errorf("unmarshal error: %w", err)}
}
switch cl.Type {
case "system", "user":
return ConvertResult{Skip: true}
case "assistant":
content := ExtractContent(cl.Message)
return p.emitAssistantDelta(content)
case "result":
if cl.IsError {
errMsg := cl.Result
if errMsg == "" {
errMsg = "unknown cursor error"
}
return ConvertResult{Error: fmt.Errorf("cursor error: %s", errMsg)}
}
return ConvertResult{
Done: true,
Usage: cl.Usage,
}
default:
return ConvertResult{Skip: true}
}
}
func (p *StreamParser) ParseRawText(content string) ConvertResult {
content = strings.TrimSpace(content)
if content == "" {
return ConvertResult{Skip: true}
}
if content == p.lastRawText {
return ConvertResult{Skip: true}
}
p.lastRawText = content
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
Content: &content,
})
return ConvertResult{Chunk: &chunk}
}
// emitAssistantDelta handles both output modes the Cursor CLI can use:
//
// - CUMULATIVE: each assistant message contains the full text so far
// (text.startsWith(accumulated)). We emit only the new suffix and
// replace accumulated with the full text.
// - INCREMENTAL: each assistant message contains just the new fragment.
// We emit the fragment verbatim and append it to accumulated.
//
// Either way, the duplicate final "assistant" message that Cursor CLI emits
// at the end of a session is caught by the content == accumulated check and
// skipped.
func (p *StreamParser) emitAssistantDelta(content string) ConvertResult {
if content == "" {
return ConvertResult{Skip: true}
}
if content == p.accumulated {
return ConvertResult{Skip: true}
}
var delta string
if p.accumulated != "" && strings.HasPrefix(content, p.accumulated) {
delta = content[len(p.accumulated):]
p.accumulated = content
} else {
delta = content
p.accumulated += content
}
if delta == "" {
return ConvertResult{Skip: true}
}
chunk := types.NewChatCompletionChunk(p.chatID, 0, "", types.Delta{
Content: &delta,
})
return ConvertResult{Chunk: &chunk}
}
// ConvertLine 將一行 Cursor stream-json 轉換為 OpenAI SSE chunk。
func ConvertLine(line string, chatID string) ConvertResult {
return NewStreamParser(chatID).Parse(line)
}
// ExtractContent 從 CursorMessage 提取所有文字內容。
func ExtractContent(msg *CursorMessage) string {
if msg == nil {
return ""
}
var parts []string
for _, c := range msg.Content {
if c.Text != "" {
parts = append(parts, c.Text)
}
}
return strings.Join(parts, "")
}

View File

@ -1,305 +0,0 @@
package converter
import (
"fmt"
"strings"
"testing"
)
func TestConvertLineAssistant(t *testing.T) {
line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}}`
result := ConvertLine(line, "chat-123")
if result.Skip {
t.Error("expected not Skip")
}
if result.Done {
t.Error("expected not Done")
}
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.Chunk == nil {
t.Fatal("expected Chunk, got nil")
}
if result.Chunk.ID != "chat-123" {
t.Errorf("Chunk.ID = %q, want %q", result.Chunk.ID, "chat-123")
}
if result.Chunk.Object != "chat.completion.chunk" {
t.Errorf("Chunk.Object = %q, want %q", result.Chunk.Object, "chat.completion.chunk")
}
if len(result.Chunk.Choices) != 1 {
t.Fatalf("len(Choices) = %d, want 1", len(result.Chunk.Choices))
}
if *result.Chunk.Choices[0].Delta.Content != "Hello, world!" {
t.Errorf("Delta.Content = %q, want %q", *result.Chunk.Choices[0].Delta.Content, "Hello, world!")
}
}
func TestConvertLineSystem(t *testing.T) {
line := `{"type":"system","message":{"role":"system","content":"init"}}`
result := ConvertLine(line, "chat-123")
if !result.Skip {
t.Error("expected Skip for system line")
}
if result.Chunk != nil {
t.Error("expected nil Chunk for system line")
}
if result.Error != nil {
t.Errorf("unexpected error: %v", result.Error)
}
}
func TestConvertLineUser(t *testing.T) {
line := `{"type":"user","message":{"role":"user","content":"hello"}}`
result := ConvertLine(line, "chat-123")
if !result.Skip {
t.Error("expected Skip for user line")
}
if result.Chunk != nil {
t.Error("expected nil Chunk for user line")
}
if result.Error != nil {
t.Errorf("unexpected error: %v", result.Error)
}
}
func TestConvertLineResultSuccess(t *testing.T) {
line := `{"type":"result","subtype":"success","result":"done","usage":{"inputTokens":100,"outputTokens":50}}`
result := ConvertLine(line, "chat-123")
if !result.Done {
t.Error("expected Done")
}
if result.Skip {
t.Error("expected not Skip")
}
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.Usage == nil {
t.Fatal("expected Usage, got nil")
}
if result.Usage.InputTokens != 100 {
t.Errorf("Usage.InputTokens = %d, want 100", result.Usage.InputTokens)
}
if result.Usage.OutputTokens != 50 {
t.Errorf("Usage.OutputTokens = %d, want 50", result.Usage.OutputTokens)
}
}
func TestConvertLineResultError(t *testing.T) {
line := `{"type":"result","is_error":true,"result":"something went wrong"}`
result := ConvertLine(line, "chat-123")
if result.Error == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(result.Error.Error(), "something went wrong") {
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "something went wrong")
}
if result.Done {
t.Error("expected not Done for error")
}
}
func TestConvertLineEmpty(t *testing.T) {
tests := []string{"", " ", "\n", " \n "}
for _, line := range tests {
result := ConvertLine(line, "chat-123")
if !result.Skip {
t.Errorf("expected Skip for empty/whitespace line %q", line)
}
if result.Error != nil {
t.Errorf("unexpected error for empty line: %v", result.Error)
}
}
}
func TestConvertLineInvalidJSON(t *testing.T) {
line := `{"type":"assistant", invalid json}`
result := ConvertLine(line, "chat-123")
if result.Error == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
if !strings.Contains(result.Error.Error(), "unmarshal error") {
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "unmarshal error")
}
}
func TestExtractContent(t *testing.T) {
t.Run("nil message", func(t *testing.T) {
result := ExtractContent(nil)
if result != "" {
t.Errorf("ExtractContent(nil) = %q, want empty", result)
}
})
t.Run("single content", func(t *testing.T) {
msg := &CursorMessage{
Role: "assistant",
Content: []CursorContent{
{Type: "text", Text: "Hello"},
},
}
result := ExtractContent(msg)
if result != "Hello" {
t.Errorf("ExtractContent() = %q, want %q", result, "Hello")
}
})
t.Run("multiple content", func(t *testing.T) {
msg := &CursorMessage{
Role: "assistant",
Content: []CursorContent{
{Type: "text", Text: "Hello"},
{Type: "text", Text: ", "},
{Type: "text", Text: "world!"},
},
}
result := ExtractContent(msg)
if result != "Hello, world!" {
t.Errorf("ExtractContent() = %q, want %q", result, "Hello, world!")
}
})
t.Run("empty content", func(t *testing.T) {
msg := &CursorMessage{
Role: "assistant",
Content: []CursorContent{},
}
result := ExtractContent(msg)
if result != "" {
t.Errorf("ExtractContent() = %q, want empty", result)
}
})
}
func TestStreamParser_OnlyEmitsNewDeltaFromAccumulatedAssistantMessages(t *testing.T) {
parser := NewStreamParser("chat-123")
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hel"}]}}`)
if first.Error != nil {
t.Fatalf("unexpected error on first chunk: %v", first.Error)
}
if first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
t.Fatal("expected first chunk content")
}
if got := *first.Chunk.Choices[0].Delta.Content; got != "Hel" {
t.Fatalf("first delta = %q, want %q", got, "Hel")
}
second := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
if second.Error != nil {
t.Fatalf("unexpected error on second chunk: %v", second.Error)
}
if second.Chunk == nil || second.Chunk.Choices[0].Delta.Content == nil {
t.Fatal("expected second chunk content")
}
if got := *second.Chunk.Choices[0].Delta.Content; got != "lo" {
t.Fatalf("second delta = %q, want %q", got, "lo")
}
}
func TestStreamParser_SkipsFinalDuplicateAssistantMessage(t *testing.T) {
parser := NewStreamParser("chat-123")
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
if first.Skip || first.Error != nil || first.Chunk == nil {
t.Fatalf("expected first assistant chunk, got %+v", first)
}
duplicate := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
if !duplicate.Skip {
t.Fatalf("expected duplicate assistant message to be skipped, got %+v", duplicate)
}
}
func TestStreamParser_ResultIncludesUsage(t *testing.T) {
parser := NewStreamParser("chat-123")
result := parser.Parse(`{"type":"result","subtype":"success","usage":{"inputTokens":10,"outputTokens":4}}`)
if !result.Done {
t.Fatal("expected result.Done")
}
if result.Usage == nil {
t.Fatal("expected usage")
}
if result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 4 {
t.Fatalf("unexpected usage: %+v", result.Usage)
}
}
func TestStreamParser_CanReconstructFinalContentFromIncrementalAssistantMessages(t *testing.T) {
parser := NewStreamParser("chat-123")
lines := []string{
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界"}]}}`,
}
var content strings.Builder
for i, line := range lines {
result := parser.Parse(line)
if result.Error != nil {
t.Fatalf("line %d unexpected error: %v", i, result.Error)
}
if result.Skip {
continue
}
if result.Chunk == nil || result.Chunk.Choices[0].Delta.Content == nil {
t.Fatalf("line %d expected chunk content, got %+v", i, result)
}
content.WriteString(*result.Chunk.Choices[0].Delta.Content)
}
if got := content.String(); got != "你好,世界" {
t.Fatalf("reconstructed content = %q, want %q", got, "你好,世界")
}
}
func TestStreamParser_RawTextFallbackSkipsExactDuplicates(t *testing.T) {
parser := NewStreamParser("chat-123")
first := parser.ParseRawText("plain chunk")
if first.Skip || first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
t.Fatalf("expected raw text chunk, got %+v", first)
}
duplicate := parser.ParseRawText("plain chunk")
if !duplicate.Skip {
t.Fatalf("expected duplicate raw text to be skipped, got %+v", duplicate)
}
}
func TestStreamParser_IncrementalFragmentsAccumulateAndSkipFinalDuplicate(t *testing.T) {
parser := NewStreamParser("chat-123")
fragments := []string{"你", "好,", "世", "界!"}
var got strings.Builder
for i, fr := range fragments {
line := fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":%q}]}}`, fr)
res := parser.Parse(line)
if res.Skip || res.Error != nil || res.Chunk == nil {
t.Fatalf("fragment %d: expected delta chunk, got %+v", i, res)
}
got.WriteString(*res.Chunk.Choices[0].Delta.Content)
}
if got.String() != "你好,世界!" {
t.Fatalf("reconstructed = %q, want 你好,世界!", got.String())
}
final := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界!"}]}}`)
if !final.Skip {
t.Fatalf("expected final duplicate cumulative message to be skipped, got %+v", final)
}
}
func TestNewStreamParser_DoesNotExistYet(t *testing.T) {
_ = fmt.Sprintf
}

View File

@ -1,142 +0,0 @@
package converter
import "strings"
// Short-name aliases → actual Cursor model IDs.
// Allows users to configure friendly names in OpenCode instead of memorising
// exact Cursor IDs like "claude-4.6-sonnet-medium".
var shortAlias = map[string]string{
// Claude 4.7
"opus-4.7": "claude-opus-4-7-high",
"opus-4.7-thinking": "claude-opus-4-7-thinking-high",
"opus-4.7-low": "claude-opus-4-7-low",
"opus-4.7-medium": "claude-opus-4-7-medium",
"opus-4.7-high": "claude-opus-4-7-high",
"opus-4.7-xhigh": "claude-opus-4-7-xhigh",
"opus-4.7-max": "claude-opus-4-7-max",
// Claude 4.6
"sonnet-4.6": "claude-4.6-sonnet-medium",
"sonnet-4.6-thinking": "claude-4.6-sonnet-medium-thinking",
"opus-4.6": "claude-4.6-opus-high",
"opus-4.6-thinking": "claude-4.6-opus-high-thinking",
"opus-4.6-max": "claude-4.6-opus-max",
// Claude 4.5
"sonnet-4.5": "claude-4.5-sonnet",
"sonnet-4.5-thinking": "claude-4.5-sonnet-thinking",
"opus-4.5": "claude-4.5-opus-high",
"opus-4.5-thinking": "claude-4.5-opus-high-thinking",
// Claude 4
"sonnet-4": "claude-4-sonnet",
"sonnet-4-thinking": "claude-4-sonnet-thinking",
// Anthropic API-style names → Cursor IDs
// Claude 4.7
"claude-opus-4-7": "claude-opus-4-7-high",
"claude-opus-4.7": "claude-opus-4-7-high",
"claude-opus-4-7-thinking": "claude-opus-4-7-thinking-high",
"claude-opus-4.7-thinking": "claude-opus-4-7-thinking-high",
// Claude 4.6
"claude-opus-4-6": "claude-4.6-opus-high",
"claude-opus-4.6": "claude-4.6-opus-high",
"claude-sonnet-4-6": "claude-4.6-sonnet-medium",
"claude-sonnet-4.6": "claude-4.6-sonnet-medium",
"claude-opus-4-6-thinking": "claude-4.6-opus-high-thinking",
"claude-sonnet-4-6-thinking": "claude-4.6-sonnet-medium-thinking",
// Claude 4.5
"claude-opus-4-5": "claude-4.5-opus-high",
"claude-opus-4.5": "claude-4.5-opus-high",
"claude-sonnet-4-5": "claude-4.5-sonnet",
"claude-sonnet-4.5": "claude-4.5-sonnet",
"claude-opus-4-5-thinking": "claude-4.5-opus-high-thinking",
"claude-sonnet-4-5-thinking": "claude-4.5-sonnet-thinking",
// Claude 4
"claude-sonnet-4": "claude-4-sonnet",
"claude-sonnet-4-thinking": "claude-4-sonnet-thinking",
// Old Anthropic date-based names
"claude-sonnet-4-20250514": "claude-4-sonnet",
"claude-opus-4-20250514": "claude-4.5-opus-high",
// GPT shortcuts
"gpt-5.4": "gpt-5.4-medium",
"gpt-5.4-fast": "gpt-5.4-medium-fast",
// Gemini
"gemini-3.1": "gemini-3.1-pro",
}
// ResolveToCursorModel maps a user-supplied model name to its Cursor model ID.
// If the name is already a valid Cursor ID, it passes through unchanged.
func ResolveToCursorModel(requested string) string {
if requested == "" {
return ""
}
key := strings.ToLower(strings.TrimSpace(requested))
if mapped, ok := shortAlias[key]; ok {
return mapped
}
return requested
}
type aliasEntry struct {
CursorID string
AliasID string
Name string
}
var reverseAliases = []aliasEntry{
// Claude 4.7 — Cursor uses "claude-opus-4-7-*" natively, add friendly aliases
{"claude-opus-4-7-low", "claude-opus-4.7-low", "Claude Opus 4.7 (Low)"},
{"claude-opus-4-7-medium", "claude-opus-4.7-medium", "Claude Opus 4.7 (Medium)"},
{"claude-opus-4-7-high", "claude-opus-4.7-high", "Claude Opus 4.7"},
{"claude-opus-4-7-xhigh", "claude-opus-4.7-xhigh", "Claude Opus 4.7 (XHigh)"},
{"claude-opus-4-7-max", "claude-opus-4.7-max", "Claude Opus 4.7 (Max)"},
{"claude-opus-4-7-thinking-low", "claude-opus-4.7-thinking-low", "Claude Opus 4.7 Thinking (Low)"},
{"claude-opus-4-7-thinking-medium", "claude-opus-4.7-thinking-medium", "Claude Opus 4.7 Thinking (Medium)"},
{"claude-opus-4-7-thinking-high", "claude-opus-4.7-thinking-high", "Claude Opus 4.7 Thinking"},
{"claude-opus-4-7-thinking-xhigh", "claude-opus-4.7-thinking-xhigh", "Claude Opus 4.7 Thinking (XHigh)"},
{"claude-opus-4-7-thinking-max", "claude-opus-4.7-thinking-max", "Claude Opus 4.7 Thinking (Max)"},
// Claude 4.6
{"claude-4.6-opus-high", "claude-opus-4-6", "Claude Opus 4.6"},
{"claude-4.6-opus-high-thinking", "claude-opus-4-6-thinking", "Claude Opus 4.6 (Thinking)"},
{"claude-4.6-opus-max", "claude-opus-4-6-max", "Claude Opus 4.6 (Max)"},
{"claude-4.6-opus-max-thinking", "claude-opus-4-6-max-thinking", "Claude Opus 4.6 Max (Thinking)"},
{"claude-4.6-sonnet-medium", "claude-sonnet-4-6", "Claude Sonnet 4.6"},
{"claude-4.6-sonnet-medium-thinking", "claude-sonnet-4-6-thinking", "Claude Sonnet 4.6 (Thinking)"},
// Claude 4.5
{"claude-4.5-opus-high", "claude-opus-4-5", "Claude Opus 4.5"},
{"claude-4.5-opus-high-thinking", "claude-opus-4-5-thinking", "Claude Opus 4.5 (Thinking)"},
{"claude-4.5-sonnet", "claude-sonnet-4-5", "Claude Sonnet 4.5"},
{"claude-4.5-sonnet-thinking", "claude-sonnet-4-5-thinking", "Claude Sonnet 4.5 (Thinking)"},
// Claude 4
{"claude-4-sonnet", "claude-sonnet-4", "Claude Sonnet 4"},
{"claude-4-sonnet-thinking", "claude-sonnet-4-thinking", "Claude Sonnet 4 (Thinking)"},
}
// GetAnthropicModelAliases returns alias entries for models available in Cursor,
// so that /v1/models shows both Cursor IDs and friendly Anthropic-style names.
func GetAnthropicModelAliases(availableCursorIDs []string) []struct {
ID string
Name string
} {
set := make(map[string]bool, len(availableCursorIDs))
for _, id := range availableCursorIDs {
set[id] = true
}
var result []struct {
ID string
Name string
}
for _, a := range reverseAliases {
if set[a.CursorID] {
result = append(result, struct {
ID string
Name string
}{ID: a.AliasID, Name: a.Name})
}
}
return result
}

View File

@ -0,0 +1,53 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"encoding/json"
"io"
"net/http"
"cursor-api-proxy/internal/logic/chat"
"cursor-api-proxy/internal/svc"
"cursor-api-proxy/internal/types"
"cursor-api-proxy/pkg/infrastructure/httputil"
)
func AnthropicMessagesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Read raw body first
rawBody, err := io.ReadAll(r.Body)
if err != nil {
httputil.WriteJSON(w, 400, map[string]interface{}{
"error": map[string]string{"type": "invalid_request_error", "message": "failed to read body"},
}, nil)
return
}
var req types.AnthropicRequest
if err := json.Unmarshal(rawBody, &req); err != nil {
httputil.WriteJSON(w, 400, map[string]interface{}{
"error": map[string]string{"type": "invalid_request_error", "message": "invalid JSON body"},
}, nil)
return
}
l := chat.NewAnthropicMessagesLogic(r.Context(), svcCtx)
if req.Stream {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
_ = l.AnthropicMessagesStream(&req, w, r.Method, r.URL.Path)
} else {
err := l.AnthropicMessages(&req, w, r.Method, r.URL.Path)
if err != nil {
httputil.WriteJSON(w, 500, map[string]interface{}{
"error": map[string]string{"type": "api_error", "message": err.Error()},
}, nil)
}
}
}
}

View File

@ -0,0 +1,55 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"encoding/json"
"io"
"net/http"
"cursor-api-proxy/internal/logic/chat"
"cursor-api-proxy/internal/svc"
"cursor-api-proxy/internal/types"
"cursor-api-proxy/pkg/infrastructure/httputil"
)
func ChatCompletionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Read raw body first
rawBody, err := io.ReadAll(r.Body)
if err != nil {
httputil.WriteJSON(w, 400, map[string]interface{}{
"error": map[string]string{"message": "failed to read body", "code": "bad_request"},
}, nil)
return
}
var req types.ChatCompletionRequest
if err := json.Unmarshal(rawBody, &req); err != nil {
httputil.WriteJSON(w, 400, map[string]interface{}{
"error": map[string]string{"message": "invalid JSON body", "code": "bad_request"},
}, nil)
return
}
l := chat.NewChatCompletionsLogic(r.Context(), svcCtx)
if req.Stream {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
_ = l.ChatCompletionsStream(&req, w, r.Method, r.URL.Path)
} else {
resp, err := l.ChatCompletions(&req)
if err != nil {
httputil.WriteJSON(w, 500, map[string]interface{}{
"error": map[string]string{"message": err.Error(), "code": "internal_error"},
}, nil)
} else {
httputil.WriteJSON(w, 200, resp, nil)
}
}
}
}

View File

@ -0,0 +1,24 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"net/http"
"cursor-api-proxy/internal/logic/chat"
"cursor-api-proxy/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
func HealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := chat.NewHealthLogic(r.Context(), svcCtx)
resp, err := l.Health()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

View File

@ -0,0 +1,24 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"net/http"
"cursor-api-proxy/internal/logic/chat"
"cursor-api-proxy/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := chat.NewModelsLogic(r.Context(), svcCtx)
resp, err := l.Models()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

View File

@ -0,0 +1,40 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.10.1
package handler
import (
"net/http"
chat "cursor-api-proxy/internal/handler/chat"
"cursor-api-proxy/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/health",
Handler: chat.HealthHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/v1/models",
Handler: chat.ModelsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Handler: chat.ChatCompletionsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/v1/messages",
Handler: chat.AnthropicMessagesHandler(serverCtx),
},
},
)
}

View File

@ -0,0 +1,459 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"time"
"cursor-api-proxy/internal/svc"
apitypes "cursor-api-proxy/internal/types"
"cursor-api-proxy/pkg/adapter/anthropic"
"cursor-api-proxy/pkg/adapter/openai"
"cursor-api-proxy/pkg/domain/types"
"cursor-api-proxy/pkg/infrastructure/httputil"
"cursor-api-proxy/pkg/infrastructure/logger"
"cursor-api-proxy/pkg/infrastructure/parser"
"cursor-api-proxy/pkg/infrastructure/winlimit"
"cursor-api-proxy/pkg/infrastructure/workspace"
"cursor-api-proxy/pkg/usecase"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
type AnthropicMessagesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAnthropicMessagesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnthropicMessagesLogic {
return &AnthropicMessagesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AnthropicMessagesLogic) resolveModel(requested string, lastModelRef *string) string {
cfg := l.svcCtx.Config
isAuto := requested == "auto"
var explicitModel string
if requested != "" && !isAuto {
explicitModel = requested
}
if explicitModel != "" {
*lastModelRef = explicitModel
}
if isAuto {
return "auto"
}
if explicitModel != "" {
return explicitModel
}
if cfg.StrictModel && *lastModelRef != "" {
return *lastModelRef
}
if *lastModelRef != "" {
return *lastModelRef
}
return cfg.DefaultModel
}
func (l *AnthropicMessagesLogic) AnthropicMessages(req *apitypes.AnthropicRequest, w http.ResponseWriter, method, pathname string) error {
return fmt.Errorf("non-streaming not implemented for Anthropic Messages API, use stream=true")
}
func (l *AnthropicMessagesLogic) AnthropicMessagesStream(req *apitypes.AnthropicRequest, w http.ResponseWriter, method, pathname string) error {
cfg := l.svcCtx.Config.ToBridgeConfig()
requested := openai.NormalizeModelID(req.Model)
model := l.resolveModel(requested, l.svcCtx.LastModel)
cursorModel := types.ResolveToCursorModel(model)
if cursorModel == "" {
cursorModel = model
}
// Convert messages
cleanMessages := convertAnthropicMessagesToInterface(req.Messages)
cleanMessages = usecase.SanitizeMessages(cleanMessages)
// Build prompt
systemText := req.System
var systemWithTools interface{} = systemText
if len(req.Tools) > 0 {
toolsText := openai.ToolsToSystemText(convertToolsToInterface(req.Tools), nil)
if systemText != "" {
systemWithTools = systemText + "\n\n" + toolsText
} else {
systemWithTools = toolsText
}
}
prompt := anthropic.BuildPromptFromAnthropicMessages(convertToAnthropicParams(cleanMessages), systemWithTools)
// Validate max_tokens
if req.MaxTokens == 0 {
httputil.WriteJSON(w, 400, map[string]interface{}{
"error": map[string]string{"type": "invalid_request_error", "message": "max_tokens is required"},
}, nil)
return nil
}
// Log traffic
var trafficMsgs []logger.TrafficMessage
if systemText != "" {
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: "system", Content: systemText})
}
for _, m := range cleanMessages {
if mm, ok := m.(map[string]interface{}); ok {
role, _ := mm["role"].(string)
content := openai.MessageContentToText(mm["content"])
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: role, Content: content})
}
}
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, true)
// Resolve workspace
ws := workspace.ResolveWorkspace(cfg, "")
// Build command args
if cfg.Verbose {
logger.LogDebug("model=%s prompt_len=%d", cursorModel, len(prompt))
}
maxCmdline := cfg.WinCmdlineMax
if maxCmdline == 0 {
maxCmdline = 32768
}
fixedArgs := usecase.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, true)
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, maxCmdline, ws.WorkspaceDir)
if cfg.Verbose {
logger.LogDebug("cmd_args=%v", fit.Args)
}
if !fit.OK {
httputil.WriteJSON(w, 500, map[string]interface{}{
"error": map[string]string{"type": "api_error", "message": fit.Error},
}, nil)
return nil
}
if fit.Truncated {
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
}
cmdArgs := fit.Args
msgID := "msg_" + uuid.New().String()
var truncatedHeaders map[string]string
if fit.Truncated {
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
}
hasTools := len(req.Tools) > 0
var toolNames map[string]bool
if hasTools {
toolNames = usecase.CollectToolNames(convertToolsToInterface(req.Tools))
}
// Write SSE headers
httputil.WriteSSEHeaders(w, truncatedHeaders)
flusher, _ := w.(http.Flusher)
var p parser.Parser
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "message_start",
"message": map[string]interface{}{
"id": msgID,
"type": "message",
"role": "assistant",
"model": model,
"content": []interface{}{},
},
})
if hasTools {
p = createAnthropicToolParser(w, flusher, model, toolNames, cfg.Verbose)
} else {
p = createAnthropicStreamParser(w, flusher, model, cfg.Verbose)
}
configDir := l.svcCtx.AccountPool.GetNextConfigDir()
logger.LogAccountAssigned(configDir)
l.svcCtx.AccountPool.ReportRequestStart(configDir)
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
streamStart := time.Now().UnixMilli()
wrappedParser := func(line string) {
logger.LogRawLine(line)
p.Parse(line)
}
result, err := usecase.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, l.ctx)
if l.ctx.Err() == nil {
p.Flush()
}
latencyMs := time.Now().UnixMilli() - streamStart
l.svcCtx.AccountPool.ReportRequestEnd(configDir)
if l.ctx.Err() == context.DeadlineExceeded {
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
} else if l.ctx.Err() == context.Canceled {
logger.LogClientDisconnect(method, pathname, model, latencyMs)
} else if err == nil && isRateLimited(result.Stderr) {
l.svcCtx.AccountPool.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
}
if err != nil || (result.Code != 0 && l.ctx.Err() == nil) {
l.svcCtx.AccountPool.ReportRequestError(configDir, latencyMs)
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", -1, errMsg)
} else {
errMsg = result.Stderr
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", result.Code, result.Stderr)
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "error",
"error": map[string]interface{}{"type": "api_error", "message": errMsg},
})
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
} else if l.ctx.Err() == nil {
l.svcCtx.AccountPool.ReportRequestSuccess(configDir, latencyMs)
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
}
logger.LogAccountStats(cfg.Verbose, l.svcCtx.AccountPool.GetStats())
return nil
}
func createAnthropicStreamParser(w http.ResponseWriter, flusher http.Flusher, model string, verbose bool) parser.Parser {
var textBlockOpen bool
var textBlockIndex int
var thinkingOpen bool
var thinkingBlockIndex int
var blockCount int
return parser.CreateStreamParserWithThinking(
func(text string) {
if verbose {
logger.LogStreamChunk(model, text, 0)
}
if !textBlockOpen && !thinkingOpen {
textBlockIndex = blockCount
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_start",
"index": textBlockIndex,
"content_block": map[string]string{"type": "text", "text": ""},
})
textBlockOpen = true
blockCount++
}
if thinkingOpen {
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_stop", "index": thinkingBlockIndex,
})
thinkingOpen = false
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_delta",
"index": textBlockIndex,
"delta": map[string]string{"type": "text_delta", "text": text},
})
},
func(thinking string) {
if verbose {
logger.LogStreamChunk(model, thinking, 0)
}
if !thinkingOpen {
thinkingBlockIndex = blockCount
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_start",
"index": thinkingBlockIndex,
"content_block": map[string]string{"type": "thinking", "thinking": ""},
})
thinkingOpen = true
blockCount++
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_delta",
"index": thinkingBlockIndex,
"delta": map[string]string{"type": "thinking_delta", "thinking": thinking},
})
},
func() {
if textBlockOpen {
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_stop", "index": textBlockIndex,
})
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "message_delta",
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": 0},
})
writeAnthropicEvent(w, flusher, map[string]interface{}{"type": "message_stop"})
if flusher != nil {
flusher.Flush()
}
},
)
}
func createAnthropicToolParser(w http.ResponseWriter, flusher http.Flusher, model string, toolNames map[string]bool, verbose bool) parser.Parser {
var accumulated string
toolCallMarkerRe := regexp.MustCompile(`行政法规|<function_calls>`)
var toolCallMode bool
var textBlockOpen bool
var textBlockIndex int
var blockCount int
return parser.CreateStreamParserWithThinking(
func(text string) {
accumulated += text
if verbose {
logger.LogStreamChunk(model, text, 0)
}
if toolCallMode {
return
}
if toolCallMarkerRe.MatchString(text) {
if textBlockOpen {
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_stop", "index": textBlockIndex,
})
textBlockOpen = false
}
toolCallMode = true
return
}
if !textBlockOpen {
textBlockIndex = blockCount
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_start",
"index": textBlockIndex,
"content_block": map[string]string{"type": "text", "text": ""},
})
textBlockOpen = true
blockCount++
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_delta",
"index": textBlockIndex,
"delta": map[string]string{"type": "text_delta", "text": text},
})
},
func(thinking string) {},
func() {
if verbose {
logger.LogTrafficResponse(verbose, model, accumulated, true)
}
parsed := usecase.ExtractToolCalls(accumulated, toolNames)
blockIndex := 0
if textBlockOpen {
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_stop", "index": textBlockIndex,
})
blockIndex = textBlockIndex + 1
}
if parsed.HasToolCalls() {
for _, tc := range parsed.ToolCalls {
toolID := "toolu_" + uuid.New().String()[:12]
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_start", "index": blockIndex,
"content_block": map[string]interface{}{
"type": "tool_use", "id": toolID, "name": tc.Name, "input": map[string]interface{}{},
},
})
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_delta", "index": blockIndex,
"delta": map[string]interface{}{
"type": "input_json_delta", "partial_json": tc.Arguments,
},
})
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "content_block_stop", "index": blockIndex,
})
blockIndex++
}
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "message_delta",
"delta": map[string]interface{}{"stop_reason": "tool_use", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": 0},
})
} else {
writeAnthropicEvent(w, flusher, map[string]interface{}{
"type": "message_delta",
"delta": map[string]interface{}{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": 0},
})
}
writeAnthropicEvent(w, flusher, map[string]interface{}{"type": "message_stop"})
if flusher != nil {
flusher.Flush()
}
},
)
}
func writeAnthropicEvent(w http.ResponseWriter, flusher http.Flusher, evt interface{}) {
data, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
}
func convertAnthropicMessagesToInterface(msgs []apitypes.Message) []interface{} {
result := make([]interface{}, len(msgs))
for i, m := range msgs {
result[i] = map[string]interface{}{
"role": m.Role,
"content": m.Content,
}
}
return result
}
func convertToAnthropicParams(msgs []interface{}) []anthropic.MessageParam {
result := make([]anthropic.MessageParam, len(msgs))
for i, m := range msgs {
if mm, ok := m.(map[string]interface{}); ok {
result[i] = anthropic.MessageParam{
Role: mm["role"].(string),
Content: mm["content"],
}
}
}
return result
}
func convertToolsToInterface(tools []apitypes.Tool) []interface{} {
if tools == nil {
return nil
}
result := make([]interface{}, len(tools))
for i, t := range tools {
result[i] = map[string]interface{}{
"type": t.Type,
"function": map[string]interface{}{
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": t.Function.Parameters,
},
}
}
return result
}

View File

@ -0,0 +1,483 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/internal/svc"
apitypes "cursor-api-proxy/internal/types"
"cursor-api-proxy/pkg/adapter/openai"
"cursor-api-proxy/pkg/domain/types"
"cursor-api-proxy/pkg/infrastructure/httputil"
"cursor-api-proxy/pkg/infrastructure/logger"
"cursor-api-proxy/pkg/infrastructure/parser"
"cursor-api-proxy/pkg/infrastructure/winlimit"
"cursor-api-proxy/pkg/infrastructure/workspace"
"cursor-api-proxy/pkg/usecase"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
var rateLimitRe = regexp.MustCompile(`(?i)\b429\b|rate.?limit|too many requests`)
var retryAfterRe = regexp.MustCompile(`(?i)retry-after:\s*(\d+)`)
func isRateLimited(stderr string) bool {
return rateLimitRe.MatchString(stderr)
}
func extractRetryAfterMs(stderr string) int64 {
if m := retryAfterRe.FindStringSubmatch(stderr); len(m) > 1 {
if secs, err := strconv.ParseInt(m[1], 10, 64); err == nil && secs > 0 {
return secs * 1000
}
}
return 60000
}
type ChatCompletionsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewChatCompletionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatCompletionsLogic {
return &ChatCompletionsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ChatCompletionsLogic) resolveModel(requested string, lastModelRef *string) string {
cfg := l.svcCtx.Config
isAuto := requested == "auto"
var explicitModel string
if requested != "" && !isAuto {
explicitModel = requested
}
if explicitModel != "" {
*lastModelRef = explicitModel
}
if isAuto {
return "auto"
}
if explicitModel != "" {
return explicitModel
}
if cfg.StrictModel && *lastModelRef != "" {
return *lastModelRef
}
if *lastModelRef != "" {
return *lastModelRef
}
return cfg.DefaultModel
}
func (l *ChatCompletionsLogic) ChatCompletions(req *apitypes.ChatCompletionRequest) (*apitypes.ChatCompletionResponse, error) {
return nil, fmt.Errorf("non-streaming not yet implemented, use stream=true")
}
func (l *ChatCompletionsLogic) ChatCompletionsStream(req *apitypes.ChatCompletionRequest, w http.ResponseWriter, method, pathname string) error {
cfg := configToBridge(l.svcCtx.Config)
rawModel := req.Model
requested := openai.NormalizeModelID(rawModel)
lastModelRef := new(string)
model := l.resolveModel(requested, lastModelRef)
cursorModel := types.ResolveToCursorModel(model)
if cursorModel == "" {
cursorModel = model
}
messages := convertMessages(req.Messages)
tools := convertTools(req.Tools)
functions := convertFunctions(req.Functions)
cleanMessages := usecase.SanitizeMessages(messages)
toolsText := openai.ToolsToSystemText(tools, functions)
messagesWithTools := cleanMessages
if toolsText != "" {
messagesWithTools = append([]interface{}{
map[string]interface{}{"role": "system", "content": toolsText},
}, cleanMessages...)
}
prompt := openai.BuildPromptFromMessages(messagesWithTools)
var trafficMsgs []logger.TrafficMessage
for _, raw := range cleanMessages {
if m, ok := raw.(map[string]interface{}); ok {
role, _ := m["role"].(string)
content := openai.MessageContentToText(m["content"])
trafficMsgs = append(trafficMsgs, logger.TrafficMessage{Role: role, Content: content})
}
}
logger.LogTrafficRequest(cfg.Verbose, model, trafficMsgs, true)
ws := workspace.ResolveWorkspace(cfg, "")
promptLen := len(prompt)
if cfg.Verbose {
if promptLen > 200 {
logger.LogDebug("model=%s prompt_len=%d prompt_start=%q", cursorModel, promptLen, prompt[:200])
} else {
logger.LogDebug("model=%s prompt_len=%d prompt=%q", cursorModel, promptLen, prompt)
}
}
maxCmdline := cfg.WinCmdlineMax
if maxCmdline == 0 {
maxCmdline = 32768
}
fixedArgs := usecase.BuildAgentFixedArgs(cfg, ws.WorkspaceDir, cursorModel, true)
fit := winlimit.FitPromptToWinCmdline(cfg.AgentBin, fixedArgs, prompt, maxCmdline, ws.WorkspaceDir)
if l.svcCtx.Config.Verbose {
logger.LogDebug("cmd=%s args=%v", cfg.AgentBin, fit.Args)
}
if !fit.OK {
httputil.WriteJSON(w, 500, map[string]interface{}{
"error": map[string]string{"message": fit.Error, "code": "windows_cmdline_limit"},
}, nil)
return nil
}
if fit.Truncated {
logger.LogTruncation(fit.OriginalLength, fit.FinalPromptLength)
}
cmdArgs := fit.Args
id := "chatcmpl_" + uuid.New().String()
created := time.Now().Unix()
var truncatedHeaders map[string]string
if fit.Truncated {
truncatedHeaders = map[string]string{"X-Cursor-Proxy-Prompt-Truncated": "true"}
}
hasTools := len(tools) > 0 || len(functions) > 0
var toolNames map[string]bool
if hasTools {
toolNames = usecase.CollectToolNames(tools)
for _, f := range functions {
if fm, ok := f.(map[string]interface{}); ok {
if name, ok := fm["name"].(string); ok {
toolNames[name] = true
}
}
}
}
httputil.WriteSSEHeaders(w, truncatedHeaders)
flusher, _ := w.(http.Flusher)
var accumulated string
var chunkNum int
var p parser.Parser
toolCallMarkerRe := regexp.MustCompile(`\x1e|<function_calls>`)
if hasTools {
var toolCallMode bool
p = parser.CreateStreamParserWithThinking(
func(text string) {
accumulated += text
chunkNum++
logger.LogStreamChunk(model, text, chunkNum)
if toolCallMode {
return
}
if toolCallMarkerRe.MatchString(text) {
toolCallMode = true
return
}
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
},
func(thinking string) {
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{"reasoning_content": thinking}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
},
func() {
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
parsed := usecase.ExtractToolCalls(accumulated, toolNames)
if parsed.HasToolCalls() {
if parsed.TextContent != "" && toolCallMode {
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{"role": "assistant", "content": parsed.TextContent}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
}
for i, tc := range parsed.ToolCalls {
callID := "call_" + uuid.New().String()[:8]
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{
"tool_calls": []map[string]interface{}{
{
"index": i,
"id": callID,
"type": "function",
"function": map[string]interface{}{
"name": tc.Name,
"arguments": tc.Arguments,
},
},
},
}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
}
stopChunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "tool_calls"},
},
}
data, _ := json.Marshal(stopChunk)
fmt.Fprintf(w, "data: %s\n\n", data)
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
} else {
stopChunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
},
}
data, _ := json.Marshal(stopChunk)
fmt.Fprintf(w, "data: %s\n\n", data)
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
}
},
)
} else {
p = parser.CreateStreamParserWithThinking(
func(text string) {
accumulated += text
chunkNum++
logger.LogStreamChunk(model, text, chunkNum)
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
},
func(thinking string) {
chunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{"reasoning_content": thinking}, "finish_reason": nil},
},
}
data, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", data)
if flusher != nil {
flusher.Flush()
}
},
func() {
logger.LogTrafficResponse(cfg.Verbose, model, accumulated, true)
stopChunk := map[string]interface{}{
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
"choices": []map[string]interface{}{
{"index": 0, "delta": map[string]interface{}{}, "finish_reason": "stop"},
},
}
data, _ := json.Marshal(stopChunk)
fmt.Fprintf(w, "data: %s\n\n", data)
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
},
)
}
configDir := l.svcCtx.AccountPool.GetNextConfigDir()
logger.LogAccountAssigned(configDir)
l.svcCtx.AccountPool.ReportRequestStart(configDir)
logger.LogRequestStart(method, pathname, model, cfg.TimeoutMs, true)
streamStart := time.Now().UnixMilli()
wrappedParser := func(line string) {
logger.LogRawLine(line)
p.Parse(line)
}
result, err := usecase.RunAgentStreamWithContext(cfg, ws.WorkspaceDir, cmdArgs, wrappedParser, ws.TempDir, configDir, l.ctx)
if l.ctx.Err() == nil {
p.Flush()
}
latencyMs := time.Now().UnixMilli() - streamStart
l.svcCtx.AccountPool.ReportRequestEnd(configDir)
if l.ctx.Err() == context.DeadlineExceeded {
logger.LogRequestTimeout(method, pathname, model, cfg.TimeoutMs)
} else if l.ctx.Err() == context.Canceled {
logger.LogClientDisconnect(method, pathname, model, latencyMs)
} else if err == nil && isRateLimited(result.Stderr) {
l.svcCtx.AccountPool.ReportRateLimit(configDir, extractRetryAfterMs(result.Stderr))
}
if err != nil || (result.Code != 0 && l.ctx.Err() == nil) {
l.svcCtx.AccountPool.ReportRequestError(configDir, latencyMs)
if err != nil {
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", -1, err.Error())
} else {
logger.LogAgentError(cfg.SessionsLogPath, method, pathname, "", result.Code, result.Stderr)
}
logger.LogRequestDone(method, pathname, model, latencyMs, result.Code)
} else if l.ctx.Err() == nil {
l.svcCtx.AccountPool.ReportRequestSuccess(configDir, latencyMs)
logger.LogRequestDone(method, pathname, model, latencyMs, 0)
}
logger.LogAccountStats(cfg.Verbose, l.svcCtx.AccountPool.GetStats())
return nil
}
func convertMessages(msgs []apitypes.Message) []interface{} {
result := make([]interface{}, len(msgs))
for i, m := range msgs {
result[i] = map[string]interface{}{
"role": m.Role,
"content": m.Content,
}
}
return result
}
func convertTools(tools []apitypes.Tool) []interface{} {
if tools == nil {
return nil
}
result := make([]interface{}, len(tools))
for i, t := range tools {
result[i] = map[string]interface{}{
"type": t.Type,
"function": map[string]interface{}{
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": t.Function.Parameters,
},
}
}
return result
}
func convertFunctions(funcs []apitypes.Function) []interface{} {
if funcs == nil {
return nil
}
result := make([]interface{}, len(funcs))
for i, f := range funcs {
result[i] = map[string]interface{}{
"name": f.Name,
"description": f.Description,
"parameters": f.Parameters,
}
}
return result
}
func configToBridge(c config.Config) config.BridgeConfig {
host := c.Host
if host == "" {
host = "0.0.0.0"
}
return config.BridgeConfig{
AgentBin: c.AgentBin,
Host: host,
Port: c.Port,
RequiredKey: c.RequiredKey,
DefaultModel: c.DefaultModel,
Mode: "ask",
Provider: c.Provider,
Force: c.Force,
ApproveMcps: c.ApproveMcps,
StrictModel: c.StrictModel,
Workspace: c.Workspace,
TimeoutMs: c.TimeoutMs,
TLSCertPath: c.TLSCertPath,
TLSKeyPath: c.TLSKeyPath,
SessionsLogPath: c.SessionsLogPath,
ChatOnlyWorkspace: c.ChatOnlyWorkspace,
Verbose: c.Verbose,
MaxMode: c.MaxMode,
ConfigDirs: c.ConfigDirs,
MultiPort: c.MultiPort,
WinCmdlineMax: c.WinCmdlineMax,
GeminiAccountDir: c.GeminiAccountDir,
GeminiBrowserVisible: c.GeminiBrowserVisible,
GeminiMaxSessions: c.GeminiMaxSessions,
}
}
// StringsToMapSlice converts string slice for compatibility
func StringsToMapSlice(ss []string) []map[string]string {
result := make([]map[string]string, len(ss))
for i, s := range ss {
result[i] = map[string]string{"content": s}
}
return result
}
// JoinStrings joins strings with newline
func JoinStrings(ss []string) string {
return strings.Join(ss, "\n")
}

View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package chat
import (
"context"
"cursor-api-proxy/internal/svc"
"cursor-api-proxy/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type HealthLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HealthLogic {
return &HealthLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *HealthLogic) Health() (resp *types.HealthResponse, err error) {
return &types.HealthResponse{
Status: "ok",
Version: "1.0.0",
}, nil
}

View File

@ -0,0 +1,118 @@
package chat
import (
"context"
"sync"
"time"
"cursor-api-proxy/internal/svc"
apitypes "cursor-api-proxy/internal/types"
"cursor-api-proxy/pkg/domain/types"
"github.com/zeromicro/go-zero/core/logx"
)
const modelCacheTTLMs = 5 * 60 * 1000
type ModelCache struct {
At int64
Models []types.CursorCliModel
}
type ModelCacheRef struct {
mu sync.Mutex
cache *ModelCache
inflight bool
waiters []chan struct{}
}
var globalModelCache = &ModelCacheRef{}
type ModelsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ModelsLogic {
return &ModelsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ModelsLogic) Models() (resp *apitypes.ModelsResponse, err error) {
now := time.Now().UnixMilli()
globalModelCache.mu.Lock()
if globalModelCache.cache != nil && now-globalModelCache.cache.At <= modelCacheTTLMs {
cache := globalModelCache.cache
globalModelCache.mu.Unlock()
return buildModelsResponse(cache.Models), nil
}
if globalModelCache.inflight {
ch := make(chan struct{}, 1)
globalModelCache.waiters = append(globalModelCache.waiters, ch)
globalModelCache.mu.Unlock()
<-ch
globalModelCache.mu.Lock()
cache := globalModelCache.cache
globalModelCache.mu.Unlock()
return buildModelsResponse(cache.Models), nil
}
globalModelCache.inflight = true
globalModelCache.mu.Unlock()
fetched, err := types.ListCursorCliModels(l.svcCtx.Config.AgentBin, l.svcCtx.Config.TimeoutMs)
globalModelCache.mu.Lock()
globalModelCache.inflight = false
if err == nil {
globalModelCache.cache = &ModelCache{At: time.Now().UnixMilli(), Models: fetched}
}
waiters := globalModelCache.waiters
globalModelCache.waiters = nil
globalModelCache.mu.Unlock()
for _, ch := range waiters {
ch <- struct{}{}
}
if err != nil {
return nil, err
}
return buildModelsResponse(fetched), nil
}
func buildModelsResponse(mods []types.CursorCliModel) *apitypes.ModelsResponse {
models := make([]apitypes.ModelData, len(mods))
for i, m := range mods {
models[i] = apitypes.ModelData{
Id: m.ID,
Object: "model",
OwnedBy: "cursor",
}
}
ids := make([]string, len(mods))
for i, m := range mods {
ids[i] = m.ID
}
aliases := types.GetAnthropicModelAliases(ids)
for _, a := range aliases {
models = append(models, apitypes.ModelData{
Id: a.ID,
Object: "model",
OwnedBy: "cursor",
})
}
return &apitypes.ModelsResponse{
Object: "list",
Data: models,
}
}

View File

@ -1,67 +0,0 @@
// Package sanitize strips third-party AI branding, telemetry headers, and
// identifying metadata from prompts before they are forwarded to the Cursor
// CLI. Without this, the Cursor agent sees "You are Claude Code..." style
// system prompts and behaves confusingly (trying to use tools it doesn't
// own, reasoning about being "Anthropic's CLI", etc.).
//
// Ported from cursor-api-proxy/src/lib/sanitize.ts.
package sanitize
import "regexp"
type rule struct {
pattern *regexp.Regexp
replace string
}
// Note: Go regexp is RE2, no lookbehind/lookahead, but these rules don't need any.
// (?i) enables case-insensitive for that single rule.
var rules = []rule{
// Strip x-anthropic-billing-header line (injected by Claude Code CLI).
{regexp.MustCompile(`(?i)x-anthropic-billing-header:[^\n]*\n?`), ""},
// Strip individual telemetry tokens that may appear in headers or text.
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
// --- Sandbox / capability limitation stripping ---
// Claude Desktop's system prompt tells the model it's in a sandbox,
// cannot access the filesystem, is in "Ask mode" / "Cowork mode", etc.
// These phrases cause the model to refuse helpful responses. We strip
// them so the model still sees tool definitions but not the restrictions.
// "you cannot access ...", "you do not have access to ...", etc.
{regexp.MustCompile(`(?i)[^\n]*(?:you (?:cannot|can ?not|do not|don[''\x{2019}]t|are unable to) (?:access|read|write|modify|execute|run|create|delete|move|open))[^\n]*\n?`), ""},
// "you are in a sandboxed environment", "running in a sandbox", etc.
{regexp.MustCompile(`(?i)[^\n]*(?:sandbox(?:ed)?|isolated) (?:environment|mode|context)[^\n]*\n?`), ""},
// "you are in Ask mode" / "Cowork mode" / "read-only mode"
{regexp.MustCompile(`(?i)[^\n]*(?:Ask mode|Cowork(?:er)? mode|read[- ]only mode)[^\n]*\n?`), ""},
// "you don't have filesystem access" / "no filesystem access"
{regexp.MustCompile(`(?i)[^\n]*(?:no|without|lack(?:s|ing)?|limited) (?:file ?system|file|terminal|shell|command[- ]line) access[^\n]*\n?`), ""},
// "you cannot run commands on the user's machine"
{regexp.MustCompile(`(?i)[^\n]*cannot (?:run|execute) (?:commands?|scripts?|code) (?:on|in)[^\n]*\n?`), ""},
// --- Branding replacement ---
// Replace "Claude Code" product name with "Cursor" (case-sensitive on purpose).
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
// Replace full Anthropic CLI description. Handle both straight and curly apostrophes.
{regexp.MustCompile(`(?i)Anthropic['\x{2019}]s official CLI for Claude`), "Cursor AI assistant"},
// Replace remaining Anthropic brand references.
{regexp.MustCompile(`\bAnthropic\b`), "Cursor"},
// Known Anthropic domains.
{regexp.MustCompile(`(?i)anthropic\.com`), "cursor.com"},
{regexp.MustCompile(`(?i)claude\.ai`), "cursor.sh"},
// Normalise leftover leading semicolons/whitespace at start of content.
{regexp.MustCompile(`^[;,\s]+`), ""},
}
// Text applies all sanitization rules to s.
func Text(s string) string {
if s == "" {
return s
}
for _, r := range rules {
s = r.pattern.ReplaceAllString(s, r.replace)
}
return s
}

View File

@ -1,47 +0,0 @@
package sanitize
import "testing"
func TestText_StripsBillingHeader(t *testing.T) {
in := "x-anthropic-billing-header: cc_version=1.0.8; cch=abc123\nhello world"
out := Text(in)
if out != "hello world" {
t.Errorf("got %q, want %q", out, "hello world")
}
}
func TestText_StripsTelemetryTokens(t *testing.T) {
in := "request: cc_version=2.3; cc_entrypoint=cli; cch=deadbeef the rest"
out := Text(in)
if got, want := out, "request: the rest"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestText_ReplacesClaudeCodeBranding(t *testing.T) {
cases := map[string]string{
"You are Claude Code, Anthropic's official CLI for Claude.": "You are Cursor, Cursor AI assistant.",
"Powered by Anthropic.": "Powered by Cursor.",
"Visit https://claude.ai/docs or https://anthropic.com for more": "Visit https://cursor.sh/docs or https://cursor.com for more",
}
for in, want := range cases {
if got := Text(in); got != want {
t.Errorf("Text(%q)\n got: %q\n want: %q", in, got, want)
}
}
}
func TestText_EmptyStringPassesThrough(t *testing.T) {
if got := Text(""); got != "" {
t.Errorf("Text(\"\") = %q, want empty", got)
}
}
func TestText_IsIdempotent(t *testing.T) {
in := "Claude Code says hi at anthropic.com"
first := Text(in)
second := Text(first)
if first != second {
t.Errorf("sanitize is not idempotent:\n first: %q\n second: %q", first, second)
}
}

View File

@ -1,190 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/daniel/cursor-adapter/internal/sanitize"
"github.com/daniel/cursor-adapter/internal/types"
)
// systemReminderRe matches <system-reminder>...</system-reminder> blocks
// that Claude Desktop embeds inside user messages.
var systemReminderRe = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>\s*`)
// buildPromptFromAnthropicMessages flattens an Anthropic Messages request
// into a single prompt string suitable for `agent --print`.
//
// "Pure brain + remote executors" design:
// - DROP all client system messages (mode descriptions / sandbox warnings
// that make the model refuse).
// - USE ONLY the adapter's injected system prompt.
// - RENDER req.Tools as a plain-text inventory of executors that the
// caller (Claude Desktop / Claude Code / opencode) owns. The brain must
// know it has remote hands.
// - RENDER assistant tool_use and user tool_result blocks as readable
// transcript, so multi-turn ReAct loops keep working.
// - STRIP <system-reminder> blocks embedded in user messages.
func buildPromptFromAnthropicMessages(req types.AnthropicMessagesRequest, injectedSystemPrompt string) string {
var prompt strings.Builder
if injectedSystemPrompt != "" {
prompt.WriteString("System:\n")
prompt.WriteString(injectedSystemPrompt)
prompt.WriteString("\n\n")
}
if hints := renderMountHints(extractMountHints(req)); hints != "" {
prompt.WriteString(hints)
prompt.WriteString("\n")
}
if toolsBlock := renderToolsForBrain(req.Tools); toolsBlock != "" {
prompt.WriteString(toolsBlock)
prompt.WriteString("\n")
}
for _, msg := range req.Messages {
text := renderMessageBlocks(msg.Role, msg.Content)
if text == "" {
continue
}
switch msg.Role {
case "assistant":
prompt.WriteString("Assistant: ")
default:
prompt.WriteString("User: ")
}
prompt.WriteString(text)
prompt.WriteString("\n\n")
}
prompt.WriteString("Assistant:")
return prompt.String()
}
// renderToolsForBrain converts the Anthropic tools[] array into a readable
// inventory the brain can reason about. The brain is told it MUST emit
// <tool_call>{...}</tool_call> sentinels when it wants to invoke one; the
// proxy translates that into real Anthropic tool_use blocks for the caller.
func renderToolsForBrain(tools []types.AnthropicTool) string {
if len(tools) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Available executors (the caller will run these for you):\n")
for _, t := range tools {
b.WriteString("- ")
b.WriteString(t.Name)
if desc := strings.TrimSpace(t.Description); desc != "" {
b.WriteString(": ")
b.WriteString(singleLine(desc))
}
if len(t.InputSchema) > 0 {
b.WriteString("\n input_schema: ")
b.WriteString(compactJSON(t.InputSchema))
}
b.WriteString("\n")
}
b.WriteString("\nTo invoke a tool, output EXACTLY one fenced block (and nothing else for that turn):\n")
b.WriteString("<tool_call>\n")
b.WriteString(`{"name":"<tool_name>","input":{...}}` + "\n")
b.WriteString("</tool_call>\n")
b.WriteString("If you do NOT need a tool, just answer in plain text.\n")
return b.String()
}
// renderMessageBlocks renders a single message's content blocks into a
// transcript snippet. Text blocks are sanitised; tool_use blocks render as
// `[tool_call name=... input=...]`; tool_result blocks render as
// `[tool_result for=... ok|error] ...`.
func renderMessageBlocks(role string, content types.AnthropicContent) string {
var parts []string
for _, block := range content {
switch block.Type {
case "text":
if block.Text == "" {
continue
}
cleaned := systemReminderRe.ReplaceAllString(block.Text, "")
cleaned = sanitize.Text(cleaned)
cleaned = strings.TrimSpace(cleaned)
if cleaned != "" {
parts = append(parts, cleaned)
}
case "tool_use":
parts = append(parts, fmt.Sprintf(
"[tool_call name=%q input=%s]",
block.Name, compactJSON(block.Input),
))
case "tool_result":
status := "ok"
if block.IsError {
status = "error"
}
body := renderToolResultContent(block.Content)
if body == "" {
body = "(empty)"
}
parts = append(parts, fmt.Sprintf(
"[tool_result for=%s status=%s]\n%s",
block.ToolUseID, status, body,
))
case "image", "document":
parts = append(parts, fmt.Sprintf("[%s attached]", block.Type))
}
}
return strings.Join(parts, "\n")
}
// renderToolResultContent flattens a tool_result.content payload (which can
// be a string or an array of {type:"text",text:...} blocks) to plain text.
func renderToolResultContent(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &blocks); err == nil {
var out []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
out = append(out, b.Text)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
return strings.TrimSpace(string(raw))
}
func compactJSON(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.Marshal(v)
if err != nil {
return string(raw)
}
return string(out)
}
func singleLine(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return strings.TrimSpace(s)
}

View File

@ -1,336 +0,0 @@
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/daniel/cursor-adapter/internal/converter"
"github.com/daniel/cursor-adapter/internal/types"
)
func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
bodyBytes, readErr := io.ReadAll(r.Body)
if readErr != nil {
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("read body: "+readErr.Error(), "invalid_request_error", ""))
return
}
r.Body.Close()
var req types.AnthropicMessagesRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
return
}
if req.MaxTokens <= 0 {
req.MaxTokens = 16384
}
if len(req.Messages) == 0 {
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
return
}
model := req.Model
if model == "" || model == "auto" {
model = s.cfg.DefaultModel
}
cursorModel := converter.ResolveToCursorModel(model)
sessionKey := ensureSessionHeader(w, r)
// Surface caller-side knobs in the log: which tool names the brain is
// about to see, and (if no explicit X-Cursor-Workspace header was set)
// any host directory the caller's prompt happens to mention. The
// detected directory is promoted onto the request header so the
// downstream bridge picks it up via the standard ctx override path.
if len(req.Tools) > 0 {
toolNames := make([]string, 0, len(req.Tools))
for _, t := range req.Tools {
toolNames = append(toolNames, t.Name)
}
log.Printf("[tools] caller has %d executors: %v", len(toolNames), toolNames)
}
if r.Header.Get(workspaceHeaderName) == "" {
if detected := detectAnthropicCwd(req); detected != "" {
log.Printf("[workspace] detected caller cwd from prompt: %s", detected)
r.Header.Set(workspaceHeaderName, detected)
}
}
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
prompt := buildPromptFromAnthropicMessages(req, s.cfg.SystemPrompt)
if req.Stream {
s.streamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
return
}
s.nonStreamAnthropicMessages(w, r, prompt, cursorModel, model, msgID, sessionKey)
}
func (s *Server) streamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
sse := NewSSEWriter(w)
parser := converter.NewStreamParser(msgID)
tcParser := NewToolCallStreamParser()
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
defer cancel()
go func() {
<-r.Context().Done()
cancel()
}()
outputChan, errChan := s.br.Execute(ctx, prompt, cursorModel, sessionKey)
writeAnthropicSSE(sse, map[string]interface{}{
"type": "message_start",
"message": map[string]interface{}{
"id": msgID,
"type": "message",
"role": "assistant",
"model": displayModel,
"content": []interface{}{},
},
})
st := &anthropicStreamState{
sse: sse,
blockIndex: 0,
}
emitText := func(text string) {
if text == "" {
return
}
st.ensureTextBlockOpen()
writeAnthropicSSE(sse, map[string]interface{}{
"type": "content_block_delta",
"index": st.blockIndex,
"delta": map[string]interface{}{"type": "text_delta", "text": text},
})
st.outChars += len(text)
}
emitToolCall := func(call ParsedToolCall) {
st.closeTextBlockIfOpen()
st.blockIndex++
toolID := newToolUseID()
writeAnthropicSSE(sse, map[string]interface{}{
"type": "content_block_start",
"index": st.blockIndex,
"content_block": map[string]interface{}{
"type": "tool_use",
"id": toolID,
"name": call.Name,
"input": map[string]interface{}{},
},
})
writeAnthropicSSE(sse, map[string]interface{}{
"type": "content_block_delta",
"index": st.blockIndex,
"delta": map[string]interface{}{
"type": "input_json_delta",
"partial_json": string(call.Input),
},
})
writeAnthropicSSE(sse, map[string]interface{}{
"type": "content_block_stop",
"index": st.blockIndex,
})
st.toolCallsEmitted++
}
feedDelta := func(content string) bool {
emit, calls, err := tcParser.Feed(content)
emitText(emit)
for _, c := range calls {
emitToolCall(c)
}
if err != nil {
log.Printf("[tool_call] parse error: %v", err)
}
return true
}
for line := range outputChan {
result := parser.Parse(line)
if result.Skip {
continue
}
if result.Error != nil {
if strings.Contains(result.Error.Error(), "unmarshal error") {
result = parser.ParseRawText(line)
if result.Skip {
continue
}
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
feedDelta(*c)
continue
}
}
}
writeAnthropicSSE(sse, map[string]interface{}{
"type": "error",
"error": map[string]interface{}{"type": "api_error", "message": result.Error.Error()},
})
return
}
if result.Chunk != nil && len(result.Chunk.Choices) > 0 {
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
feedDelta(*c)
}
}
if result.Done {
break
}
}
if leftover, err := tcParser.Flush(); leftover != "" {
emitText(leftover)
if err != nil {
log.Printf("[tool_call] flush warning: %v", err)
}
}
st.closeTextBlockIfOpen()
stopReason := "end_turn"
if st.toolCallsEmitted > 0 {
stopReason = "tool_use"
}
outTokens := maxInt(1, st.outChars/4)
writeAnthropicSSE(sse, map[string]interface{}{
"type": "message_delta",
"delta": map[string]interface{}{"stop_reason": stopReason, "stop_sequence": nil},
"usage": map[string]interface{}{"output_tokens": outTokens},
})
writeAnthropicSSE(sse, map[string]interface{}{
"type": "message_stop",
})
select {
case <-errChan:
default:
}
}
func (s *Server) nonStreamAnthropicMessages(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, msgID, sessionKey string) {
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
defer cancel()
go func() {
<-r.Context().Done()
cancel()
}()
rawContent, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
if err != nil {
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "api_error", ""))
return
}
cleanText, calls := ExtractAllToolCalls(rawContent)
usage := estimateUsage(prompt, rawContent)
var content []types.AnthropicResponseBlock
if cleanText != "" {
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: cleanText})
}
for _, c := range calls {
content = append(content, types.AnthropicResponseBlock{
Type: "tool_use",
ID: newToolUseID(),
Name: c.Name,
Input: c.Input,
})
}
if len(content) == 0 {
content = append(content, types.AnthropicResponseBlock{Type: "text", Text: ""})
}
stopReason := "end_turn"
if len(calls) > 0 {
stopReason = "tool_use"
}
resp := types.AnthropicMessagesResponse{
ID: msgID,
Type: "message",
Role: "assistant",
Content: content,
Model: displayModel,
StopReason: stopReason,
Usage: types.AnthropicUsage{
InputTokens: usage.PromptTokens,
OutputTokens: usage.CompletionTokens,
},
}
writeJSON(w, http.StatusOK, resp)
}
// anthropicStreamState tracks per-request streaming state: which content
// block index we are on, whether the current text block is open, output
// character count for usage estimation, and how many tool_use blocks were
// emitted so we can pick stop_reason.
type anthropicStreamState struct {
sse *SSEWriter
blockIndex int
textOpen bool
outChars int
toolCallsEmitted int
}
func (st *anthropicStreamState) ensureTextBlockOpen() {
if st.textOpen {
return
}
writeAnthropicSSE(st.sse, map[string]interface{}{
"type": "content_block_start",
"index": st.blockIndex,
"content_block": map[string]interface{}{"type": "text", "text": ""},
})
st.textOpen = true
}
func (st *anthropicStreamState) closeTextBlockIfOpen() {
if !st.textOpen {
return
}
writeAnthropicSSE(st.sse, map[string]interface{}{
"type": "content_block_stop",
"index": st.blockIndex,
})
st.textOpen = false
}
func newToolUseID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("toolu_%d", time.Now().UnixNano())
}
return "toolu_" + hex.EncodeToString(b[:])
}
func writeAnthropicSSE(sse *SSEWriter, event interface{}) {
data, err := json.Marshal(event)
if err != nil {
return
}
fmt.Fprintf(sse.w, "data: %s\n\n", data)
if sse.flush != nil {
sse.flush.Flush()
}
}

View File

@ -1,102 +0,0 @@
package server
import (
"os"
"path/filepath"
"regexp"
"strings"
"github.com/daniel/cursor-adapter/internal/types"
)
// cwdPatterns matches the most common ways callers (Claude Code, opencode,
// Cursor CLI itself, custom clients) advertise their host working
// directory inside the prompt.
//
// Patterns must capture an absolute path in group 1.
var cwdPatterns = []*regexp.Regexp{
// Claude Code style:
// <env>
// Working directory: /Users/x/proj
// Is directory a git repo: Yes
// ...
// </env>
regexp.MustCompile(`(?si)<env>.*?working directory:\s*(\S+)`),
// Generic <cwd>...</cwd> wrapper.
regexp.MustCompile(`(?i)<cwd>\s*([^<\s][^<]*?)\s*</cwd>`),
// "Working directory: /abs/path" on its own line.
regexp.MustCompile(`(?im)^\s*working directory:\s*(/[^\s<>]+)\s*$`),
// "Current working directory is /abs/path" / "current working directory: /abs/path"
regexp.MustCompile(`(?i)current working directory(?: is)?[:\s]+(/[^\s<>]+)`),
// Loose "cwd: /abs/path" / "cwd=/abs/path".
regexp.MustCompile(`(?i)\bcwd\s*[:=]\s*(/[^\s<>]+)`),
}
// detectCallerWorkspace returns the first absolute, host-resident directory
// it can extract from corpus. It rejects:
// - non-absolute paths (e.g. "src/")
// - paths that don't exist on the host (e.g. "/sessions/..." sandbox
// paths sent by Claude Desktop's Cowork VM)
// - paths that point to a file rather than a directory
//
// Returning "" simply means "no usable workspace hint found", and callers
// should fall back to config defaults.
func detectCallerWorkspace(corpus string) string {
for _, p := range cwdPatterns {
m := p.FindStringSubmatch(corpus)
if len(m) < 2 {
continue
}
cand := strings.TrimSpace(m[1])
// Strip trailing punctuation that often follows a path in prose.
cand = strings.TrimRight(cand, `.,;:"'`+"`)>")
if cand == "" || !filepath.IsAbs(cand) {
continue
}
info, err := os.Stat(cand)
if err != nil || !info.IsDir() {
continue
}
return cand
}
return ""
}
// detectAnthropicCwd scans an Anthropic Messages request for a workspace
// hint. It walks system blocks first (Claude Code / opencode usually put
// the <env> block there), then user/assistant text blocks (some clients
// embed it as <system-reminder> inside the first user message).
func detectAnthropicCwd(req types.AnthropicMessagesRequest) string {
var sb strings.Builder
for _, b := range req.System {
if b.Type == "text" && b.Text != "" {
sb.WriteString(b.Text)
sb.WriteByte('\n')
}
}
for _, m := range req.Messages {
for _, b := range m.Content {
if b.Type == "text" && b.Text != "" {
sb.WriteString(b.Text)
sb.WriteByte('\n')
}
}
}
return detectCallerWorkspace(sb.String())
}
// detectOpenAICwd scans an OpenAI-style chat completion request for a
// workspace hint, including system messages (which the brain prompt
// builder otherwise drops).
func detectOpenAICwd(req types.ChatCompletionRequest) string {
var sb strings.Builder
for _, m := range req.Messages {
sb.WriteString(string(m.Content))
sb.WriteByte('\n')
}
return detectCallerWorkspace(sb.String())
}

View File

@ -1,108 +0,0 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/daniel/cursor-adapter/internal/types"
)
func TestDetectCallerWorkspace_ClaudeCodeEnvBlock(t *testing.T) {
dir := t.TempDir()
corpus := "<env>\nWorking directory: " + dir + "\nIs directory a git repo: Yes\n</env>"
got := detectCallerWorkspace(corpus)
if got != dir {
t.Fatalf("got %q, want %q", got, dir)
}
}
func TestDetectCallerWorkspace_RejectsNonExistentSandboxPath(t *testing.T) {
corpus := "Working directory: /sessions/gracious-magical-franklin/proj"
got := detectCallerWorkspace(corpus)
if got != "" {
t.Fatalf("expected empty (path doesn't exist on host), got %q", got)
}
}
func TestDetectCallerWorkspace_RejectsRelativePath(t *testing.T) {
corpus := "cwd: src/"
got := detectCallerWorkspace(corpus)
if got != "" {
t.Fatalf("expected empty for relative path, got %q", got)
}
}
func TestDetectCallerWorkspace_RejectsFilePath(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "file.txt")
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
corpus := "Working directory: " + f
got := detectCallerWorkspace(corpus)
if got != "" {
t.Fatalf("expected empty for file path, got %q", got)
}
}
func TestDetectAnthropicCwd_FromSystemBlock(t *testing.T) {
dir := t.TempDir()
req := types.AnthropicMessagesRequest{
System: []types.AnthropicBlock{
{Type: "text", Text: "<env>\nWorking directory: " + dir + "\n</env>"},
},
Messages: []types.AnthropicMessage{
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "hi"}}},
},
}
if got := detectAnthropicCwd(req); got != dir {
t.Fatalf("got %q, want %q", got, dir)
}
}
func TestDetectAnthropicCwd_FromUserMessage(t *testing.T) {
dir := t.TempDir()
req := types.AnthropicMessagesRequest{
Messages: []types.AnthropicMessage{
{Role: "user", Content: []types.AnthropicBlock{
{Type: "text", Text: "<system-reminder>Current working directory: " + dir + "</system-reminder>\nHelp me"},
}},
},
}
if got := detectAnthropicCwd(req); got != dir {
t.Fatalf("got %q, want %q", got, dir)
}
}
func TestDetectAnthropicCwd_TrimsTrailingPunctuation(t *testing.T) {
dir := t.TempDir()
corpus := "Working directory: " + dir + "."
if got := detectCallerWorkspace(corpus); got != dir {
t.Fatalf("got %q, want %q (trailing dot should be stripped)", got, dir)
}
}
func TestDetectAnthropicCwd_NoneFound(t *testing.T) {
req := types.AnthropicMessagesRequest{
Messages: []types.AnthropicMessage{
{Role: "user", Content: []types.AnthropicBlock{{Type: "text", Text: "just a question"}}},
},
}
if got := detectAnthropicCwd(req); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
// Sanity check that none of our regexes mis-eat absolute paths inside
// regular sentences without a cwd marker.
func TestDetectCallerWorkspace_IgnoresUnmarkedAbsolutePaths(t *testing.T) {
corpus := "I edited /tmp/foo earlier."
if !strings.HasPrefix(corpus, "I edited") { // keep the import used
t.Fatal("test fixture changed")
}
if got := detectCallerWorkspace(corpus); got != "" {
t.Fatalf("got %q, want empty (no cwd marker)", got)
}
}

View File

@ -1,289 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"strings"
"sync"
"time"
"github.com/daniel/cursor-adapter/internal/converter"
"github.com/daniel/cursor-adapter/internal/sanitize"
"github.com/daniel/cursor-adapter/internal/types"
)
var (
modelCacheMu sync.Mutex
modelCacheData []string
modelCacheAt time.Time
modelCacheTTL = 5 * time.Minute
)
func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) {
models, err := s.cachedListModels(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "internal_error", ""))
return
}
ts := time.Now().Unix()
data := make([]types.ModelInfo, 0, len(models)*2)
for _, m := range models {
data = append(data, types.ModelInfo{ID: m, Object: "model", Created: ts, OwnedBy: "cursor"})
}
aliases := converter.GetAnthropicModelAliases(models)
for _, a := range aliases {
data = append(data, types.ModelInfo{ID: a.ID, Object: "model", Created: ts, OwnedBy: "cursor"})
}
writeJSON(w, http.StatusOK, types.ModelList{Object: "list", Data: data})
}
func (s *Server) cachedListModels(ctx context.Context) ([]string, error) {
modelCacheMu.Lock()
defer modelCacheMu.Unlock()
if modelCacheData != nil && time.Since(modelCacheAt) < modelCacheTTL {
return modelCacheData, nil
}
models, err := s.br.ListModels(ctx)
if err != nil {
return nil, err
}
modelCacheData = models
modelCacheAt = time.Now()
return models, nil
}
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
var req types.ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("invalid request body: "+err.Error(), "invalid_request_error", ""))
return
}
defer r.Body.Close()
if len(req.Messages) == 0 {
writeJSON(w, http.StatusBadRequest, types.NewErrorResponse("messages must not be empty", "invalid_request_error", ""))
return
}
// --- Pure brain: only our system prompt, drop the client's ---
var parts []string
if s.cfg.SystemPrompt != "" {
parts = append(parts, "system: "+s.cfg.SystemPrompt)
}
for _, m := range req.Messages {
// Drop client system messages (mode descriptions, tool schemas).
if m.Role == "system" {
continue
}
text := sanitize.Text(string(m.Content))
// Strip <system-reminder> blocks embedded in messages.
text = systemReminderRe.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
if text == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s: %s", m.Role, text))
}
prompt := strings.Join(parts, "\n")
model := req.Model
if model == "" {
model = s.cfg.DefaultModel
}
cursorModel := converter.ResolveToCursorModel(model)
sessionKey := ensureSessionHeader(w, r)
if r.Header.Get(workspaceHeaderName) == "" {
if detected := detectOpenAICwd(req); detected != "" {
slog.Debug("workspace detected from prompt", "path", detected)
r.Header.Set(workspaceHeaderName, detected)
}
}
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
created := time.Now().Unix()
if req.Stream {
s.streamChat(w, r, prompt, cursorModel, model, chatID, created, sessionKey)
return
}
s.nonStreamChat(w, r, prompt, cursorModel, model, chatID, created, sessionKey)
}
func (s *Server) streamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
sse := NewSSEWriter(w)
parser := converter.NewStreamParser(chatID)
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
defer cancel()
go func() {
<-r.Context().Done()
cancel()
}()
outputChan, errChan := s.br.Execute(ctx, prompt, cursorModel, sessionKey)
roleAssistant := "assistant"
initChunk := types.NewChatCompletionChunk(chatID, created, displayModel, types.Delta{
Role: &roleAssistant,
})
if err := sse.WriteChunk(initChunk); err != nil {
return
}
var accumulated strings.Builder
for line := range outputChan {
result := parser.Parse(line)
if result.Skip {
continue
}
if result.Error != nil {
if strings.Contains(result.Error.Error(), "unmarshal error") {
result = parser.ParseRawText(line)
if result.Skip {
continue
}
if result.Chunk != nil {
result.Chunk.Created = created
result.Chunk.Model = displayModel
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
accumulated.WriteString(*c)
}
if err := sse.WriteChunk(*result.Chunk); err != nil {
return
}
continue
}
}
sse.WriteError(result.Error.Error())
return
}
if result.Chunk != nil {
result.Chunk.Created = created
result.Chunk.Model = displayModel
if len(result.Chunk.Choices) > 0 {
if c := result.Chunk.Choices[0].Delta.Content; c != nil {
accumulated.WriteString(*c)
}
}
if err := sse.WriteChunk(*result.Chunk); err != nil {
return
}
}
if result.Done {
break
}
}
promptTokens := maxInt(1, int(math.Round(float64(len(prompt))/4.0)))
completionTokens := maxInt(1, int(math.Round(float64(accumulated.Len())/4.0)))
usage := &types.Usage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}
select {
case err := <-errChan:
if err != nil {
slog.Error("stream bridge error", "err", err)
sse.WriteError(err.Error())
return
}
default:
}
stopReason := "stop"
finalChunk := types.NewChatCompletionChunk(chatID, created, displayModel, types.Delta{})
finalChunk.Choices[0].FinishReason = &stopReason
finalChunk.Usage = usage
sse.WriteChunk(finalChunk)
sse.WriteDone()
}
func (s *Server) nonStreamChat(w http.ResponseWriter, r *http.Request, prompt, cursorModel, displayModel, chatID string, created int64, sessionKey string) {
ctx, cancel := context.WithTimeout(requestContext(r), time.Duration(s.cfg.Timeout)*time.Second)
defer cancel()
go func() {
<-r.Context().Done()
cancel()
}()
content, err := s.br.ExecuteSync(ctx, prompt, cursorModel, sessionKey)
if err != nil {
writeJSON(w, http.StatusInternalServerError, types.NewErrorResponse(err.Error(), "internal_error", ""))
return
}
usage := estimateUsage(prompt, content)
stopReason := "stop"
resp := types.ChatCompletionResponse{
ID: chatID,
Object: "chat.completion",
Created: created,
Model: displayModel,
Choices: []types.Choice{
{
Index: 0,
Message: types.ChatMessage{Role: "assistant", Content: types.ChatMessageContent(content)},
FinishReason: &stopReason,
},
},
Usage: usage,
}
writeJSON(w, http.StatusOK, resp)
}
func estimateUsage(prompt, content string) types.Usage {
promptTokens := maxInt(1, int(math.Round(float64(len(prompt))/4.0)))
completionTokens := maxInt(1, int(math.Round(float64(len(content))/4.0)))
return types.Usage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
status := "ok"
cliStatus := "available"
if err := s.br.CheckHealth(ctx); err != nil {
cliStatus = fmt.Sprintf("unavailable: %v", err)
}
writeJSON(w, http.StatusOK, map[string]string{
"status": status,
"cursor_cli": cliStatus,
"version": "0.2.0",
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@ -1,490 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/daniel/cursor-adapter/internal/config"
)
type mockBridge struct {
executeLines []string
executeErr error
executeSync string
executeSyncErr error
lastPrompt string
lastSessionKey string
models []string
healthErr error
}
func (m *mockBridge) Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error) {
m.lastPrompt = prompt
m.lastSessionKey = sessionKey
outputChan := make(chan string, len(m.executeLines))
errChan := make(chan error, 1)
go func() {
defer close(outputChan)
defer close(errChan)
for _, line := range m.executeLines {
select {
case <-ctx.Done():
errChan <- ctx.Err()
return
case outputChan <- line:
}
}
if m.executeErr != nil {
errChan <- m.executeErr
}
}()
return outputChan, errChan
}
func (m *mockBridge) ListModels(ctx context.Context) ([]string, error) {
return m.models, nil
}
func (m *mockBridge) ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error) {
m.lastPrompt = prompt
m.lastSessionKey = sessionKey
if m.executeSyncErr != nil {
return "", m.executeSyncErr
}
if m.executeSync != "" {
return m.executeSync, nil
}
return "", nil
}
func (m *mockBridge) CheckHealth(ctx context.Context) error {
return m.healthErr
}
func TestAnthropicMessages_NonStreamingResponse(t *testing.T) {
cfg := config.Defaults()
srv := New(&cfg, &mockBridge{
executeSync: "Hello",
})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"messages":[{"role":"user","content":"Say hello"}],
"stream":false
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Type != "message" {
t.Fatalf("type = %q, want %q", resp.Type, "message")
}
if resp.Role != "assistant" {
t.Fatalf("role = %q, want %q", resp.Role, "assistant")
}
if len(resp.Content) != 1 || resp.Content[0].Text != "Hello" {
t.Fatalf("content = %+v, want single text block 'Hello'", resp.Content)
}
if resp.StopReason != "end_turn" {
t.Fatalf("stop_reason = %q, want %q", resp.StopReason, "end_turn")
}
if resp.Usage.InputTokens <= 0 || resp.Usage.OutputTokens <= 0 {
t.Fatalf("usage should be estimated and > 0, got %+v", resp.Usage)
}
}
func TestAnthropicMessages_StreamingResponse(t *testing.T) {
cfg := config.Defaults()
srv := New(&cfg, &mockBridge{
executeLines: []string{
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}`,
`{"type":"result","subtype":"success","usage":{"inputTokens":9,"outputTokens":1}}`,
},
})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"messages":[{"role":"user","content":"Say hi"}],
"stream":true
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`"type":"message_start"`,
`"type":"content_block_start"`,
`"type":"content_block_delta"`,
`"text":"Hi"`,
`"type":"content_block_stop"`,
`"type":"message_delta"`,
`"stop_reason":"end_turn"`,
`"type":"message_stop"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("stream body missing %q: %s", want, body)
}
}
}
func TestChatCompletions_ForwardsProvidedSessionHeader(t *testing.T) {
cfg := config.Defaults()
br := &mockBridge{executeSync: "Hello"}
srv := New(&cfg, br)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"auto",
"messages":[{"role":"user","content":"hello"}],
"stream":false
}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(sessionHeaderName, "sess_frontend_123")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if br.lastSessionKey != "sess_frontend_123" {
t.Fatalf("bridge session key = %q, want %q", br.lastSessionKey, "sess_frontend_123")
}
if got := rec.Header().Get(sessionHeaderName); got != "sess_frontend_123" {
t.Fatalf("response session header = %q, want %q", got, "sess_frontend_123")
}
}
func TestChatCompletions_AcceptsArrayContentBlocks(t *testing.T) {
cfg := config.Defaults()
br := &mockBridge{executeSync: "Hello"}
srv := New(&cfg, br)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"auto",
"messages":[
{"role":"system","content":[{"type":"text","text":"You are terse."}]},
{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":" world"}]}
],
"stream":false
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
// Client system messages should be DROPPED (pure brain mode).
if strings.Contains(br.lastPrompt, "You are terse.") {
t.Fatalf("prompt should NOT contain client system message, got: %q", br.lastPrompt)
}
// User text should still be present and concatenated.
if !strings.Contains(br.lastPrompt, "user: hello world") {
t.Fatalf("prompt = %q, want concatenated user text content", br.lastPrompt)
}
}
func TestChatCompletions_StreamingEmitsRoleFinishReasonAndUsage(t *testing.T) {
cfg := config.Defaults()
srv := New(&cfg, &mockBridge{
executeLines: []string{
// system + user chunks should be skipped entirely, never echoed as content
`{"type":"system","subtype":"init","session_id":"abc","cwd":"/tmp"}`,
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"user: hello"}]}}`,
// incremental assistant fragments
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`,
// cumulative duplicate (Cursor CLI sometimes finalises with the full text)
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
`{"type":"result","subtype":"success","result":"你好","usage":{"inputTokens":3,"outputTokens":2}}`,
},
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"auto",
"messages":[{"role":"user","content":"hi"}],
"stream":true
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Must never leak system/user JSON as "content"
if strings.Contains(body, `"subtype":"init"`) || strings.Contains(body, `"type":"user"`) {
t.Fatalf("stream body leaked system/user JSON into SSE content: %s", body)
}
// First delta chunk must carry role:assistant (not content)
if !strings.Contains(body, `"delta":{"role":"assistant"}`) {
t.Fatalf("first chunk missing role=assistant delta: %s", body)
}
// Content deltas must be plain text — not JSON-stringified Cursor lines
if !strings.Contains(body, `"delta":{"content":"你"}`) {
t.Fatalf("first content delta not plain text: %s", body)
}
if !strings.Contains(body, `"delta":{"content":"好"}`) {
t.Fatalf("second content delta missing: %s", body)
}
// Final cumulative message that equals accumulated text must be suppressed
// (accumulated = "你好" after the two fragments; final "你好" should be Skip'd)
count := strings.Count(body, `"你好"`)
if count > 0 {
t.Fatalf("duplicate final cumulative message should have been skipped (found %d occurrences of full text as delta): %s", count, body)
}
// Final chunk must have finish_reason=stop and usage at top level
if !strings.Contains(body, `"finish_reason":"stop"`) {
t.Fatalf("final chunk missing finish_reason=stop: %s", body)
}
if !strings.Contains(body, `"usage":{`) {
t.Fatalf("final chunk missing usage: %s", body)
}
if !strings.Contains(body, `data: [DONE]`) {
t.Fatalf("stream missing [DONE] terminator: %s", body)
}
}
func TestAnthropicMessages_StreamingEmitsNoDuplicateFinalText(t *testing.T) {
cfg := config.Defaults()
srv := New(&cfg, &mockBridge{
executeLines: []string{
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
`{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`,
},
})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"messages":[{"role":"user","content":"hi"}],
"stream":true
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Count(body, `"text":"你好"`) > 0 {
t.Fatalf("final cumulative duplicate should be suppressed: %s", body)
}
for _, want := range []string{
`"text":"你"`,
`"text":"好"`,
`"type":"message_stop"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q in stream: %s", want, body)
}
}
}
func TestAnthropicMessages_PromptIncludesToolsAndToolHistory(t *testing.T) {
cfg := config.Defaults()
br := &mockBridge{executeSync: "ok"}
srv := New(&cfg, br)
body := `{
"model":"auto",
"max_tokens":128,
"tools":[{"name":"bash","description":"Run a shell command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],
"messages":[
{"role":"user","content":[{"type":"text","text":"clean up my desktop"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"bash","input":{"command":"ls ~/Desktop"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.png\nb.txt"}]}
],
"stream":false
}`
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
prompt := br.lastPrompt
for _, want := range []string{
"Available executors",
"- bash",
"Run a shell command",
"<tool_call>",
"clean up my desktop",
`[tool_call name="bash" input=`,
"[tool_result for=toolu_1 status=ok]",
"a.png",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q\nprompt:\n%s", want, prompt)
}
}
}
func TestAnthropicMessages_NonStreamTranslatesToolCallToToolUse(t *testing.T) {
cfg := config.Defaults()
br := &mockBridge{
executeSync: "I'll run it now.\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"mkdir -p ~/Desktop/screenshots\"}}\n</tool_call>",
}
srv := New(&cfg, br)
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"tools":[{"name":"bash"}],
"messages":[{"role":"user","content":"organize desktop"}],
"stream":false
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`"stop_reason":"tool_use"`,
`"type":"tool_use"`,
`"name":"bash"`,
`"command":"mkdir -p ~/Desktop/screenshots"`,
`"type":"text"`,
`I'll run it now.`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %q\nbody=%s", want, body)
}
}
}
func TestAnthropicMessages_StreamTranslatesToolCallToToolUseSSE(t *testing.T) {
cfg := config.Defaults()
srv := New(&cfg, &mockBridge{
executeLines: []string{
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n"}]}}`,
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>"}]}}`,
`{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`,
},
})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"tools":[{"name":"bash"}],
"messages":[{"role":"user","content":"go"}],
"stream":true
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`"type":"message_start"`,
`"type":"content_block_start"`,
`"type":"text"`,
`"text":"running`,
`"type":"tool_use"`,
`"name":"bash"`,
`"type":"input_json_delta"`,
`\"command\":\"ls\"`,
`"stop_reason":"tool_use"`,
`"type":"message_stop"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("stream missing %q\nbody=%s", want, body)
}
}
if strings.Contains(body, "<tool_call>") {
t.Fatalf("stream leaked raw <tool_call> sentinel: %s", body)
}
}
func TestAnthropicMessages_GeneratesSessionHeaderWhenMissing(t *testing.T) {
cfg := config.Defaults()
br := &mockBridge{executeSync: "Hello"}
srv := New(&cfg, br)
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{
"model":"auto",
"max_tokens":128,
"messages":[{"role":"user","content":"hello"}],
"stream":false
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if br.lastSessionKey == "" {
t.Fatal("expected generated session key to be forwarded to bridge")
}
if got := rec.Header().Get(sessionHeaderName); got == "" {
t.Fatal("expected generated session header in response")
}
if got := rec.Header().Get(exposeHeadersName); !strings.Contains(got, sessionHeaderName) {
t.Fatalf("expose headers = %q, want to contain %q", got, sessionHeaderName)
}
}

View File

@ -1,60 +0,0 @@
package server
import (
"regexp"
"sort"
"strings"
"github.com/daniel/cursor-adapter/internal/types"
)
// Cowork-style mount path: /sessions/<adjective>-<adjective>-<color>/mnt/<folder>
// (and any deeper subpath; we capture only the mount root).
var mountPathRe = regexp.MustCompile(`/sessions/[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+/mnt/[^\s/'"]+`)
// extractMountHints walks all prior tool_result blocks in the conversation
// and returns any Cowork-style /sessions/<id>/mnt/<folder> mount roots
// they reveal, deduped & sorted.
//
// This is purely stateless — we re-derive the set from the request body
// every turn. No server-side cache to invalidate, and it survives proxy
// restarts because the caller (Claude Desktop) replays the full history
// on each request anyway.
func extractMountHints(req types.AnthropicMessagesRequest) []string {
seen := map[string]struct{}{}
for _, m := range req.Messages {
for _, b := range m.Content {
if b.Type != "tool_result" {
continue
}
for _, p := range mountPathRe.FindAllString(renderToolResultContent(b.Content), -1) {
seen[p] = struct{}{}
}
}
}
if len(seen) == 0 {
return nil
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
// renderMountHints turns a list of mount roots into a prompt section the
// brain can refer to. Returns "" when there are no hints.
func renderMountHints(hints []string) string {
if len(hints) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Known host-mount paths (discovered earlier in this conversation, prefer these for any host file work):\n")
for _, h := range hints {
b.WriteString("- ")
b.WriteString(h)
b.WriteByte('\n')
}
return b.String()
}

View File

@ -1,66 +0,0 @@
package server
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/daniel/cursor-adapter/internal/bridge"
"github.com/daniel/cursor-adapter/internal/config"
)
type Server struct {
cfg *config.Config
br bridge.Bridge
mux *chi.Mux
}
func New(cfg *config.Config, br bridge.Bridge) *Server {
s := &Server{cfg: cfg, br: br}
s.mux = s.buildRouter()
return s
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Cursor-Session-ID, X-Cursor-Workspace")
w.Header().Set("Access-Control-Expose-Headers", "X-Cursor-Session-ID")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) buildRouter() *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)
r.Use(corsMiddleware)
r.Get("/v1/models", s.handleListModels)
r.Post("/v1/chat/completions", s.handleChatCompletions)
r.Post("/v1/messages", s.handleAnthropicMessages)
r.Get("/health", s.handleHealth)
// Claude Desktop sends HEAD / as a health check before making API calls.
// Return 200 so it doesn't error with "K.text.trim" before sending the real request.
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.Head("/", rootHandler)
r.Get("/", rootHandler)
return r
}
func (s *Server) Run() error {
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
return http.ListenAndServe(addr, s.mux)
}

View File

@ -1,44 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/daniel/cursor-adapter/internal/bridge"
)
const sessionHeaderName = "X-Cursor-Session-ID"
const workspaceHeaderName = "X-Cursor-Workspace"
const exposeHeadersName = "Access-Control-Expose-Headers"
// requestContext attaches per-request bridge knobs (currently: workspace
// override) read from headers onto ctx.
func requestContext(r *http.Request) context.Context {
ctx := r.Context()
ws := strings.TrimSpace(r.Header.Get(workspaceHeaderName))
if ws != "" && filepath.IsAbs(ws) {
ctx = bridge.WithWorkspaceOverride(ctx, ws)
}
return ctx
}
func ensureSessionHeader(w http.ResponseWriter, r *http.Request) string {
sessionKey := strings.TrimSpace(r.Header.Get(sessionHeaderName))
if sessionKey == "" {
sessionKey = fmt.Sprintf("csess_%d", time.Now().UnixNano())
}
w.Header().Set(sessionHeaderName, sessionKey)
existing := w.Header().Get(exposeHeadersName)
if existing == "" {
w.Header().Set(exposeHeadersName, sessionHeaderName)
} else if !strings.Contains(strings.ToLower(existing), strings.ToLower(sessionHeaderName)) {
w.Header().Set(exposeHeadersName, existing+", "+sessionHeaderName)
}
return sessionKey
}

View File

@ -1,57 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/daniel/cursor-adapter/internal/types"
)
// SSEWriter 封裝 http.ResponseWriter 用於 SSE streaming。
type SSEWriter struct {
w http.ResponseWriter
flush http.Flusher
}
// NewSSEWriter 建立 SSEWriter設定必要的 headers。
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, _ := w.(http.Flusher)
return &SSEWriter{w: w, flush: flusher}
}
// WriteChunk 寫入一個 SSE chunk。
func (s *SSEWriter) WriteChunk(chunk types.ChatCompletionChunk) error {
data, err := json.Marshal(chunk)
if err != nil {
return fmt.Errorf("marshal chunk: %w", err)
}
fmt.Fprintf(s.w, "data: %s\n\n", data)
if s.flush != nil {
s.flush.Flush()
}
return nil
}
// WriteDone 寫入 SSE 結束標記。
func (s *SSEWriter) WriteDone() {
fmt.Fprint(s.w, "data: [DONE]\n\n")
if s.flush != nil {
s.flush.Flush()
}
}
// WriteError 寫入 SSE 格式的錯誤。
func (s *SSEWriter) WriteError(errMsg string) {
stopReason := "stop"
chunk := types.NewChatCompletionChunk("error", 0, "", types.Delta{Content: &errMsg})
chunk.Choices[0].FinishReason = &stopReason
s.WriteChunk(chunk)
s.WriteDone()
}

View File

@ -1,77 +0,0 @@
package server
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/daniel/cursor-adapter/internal/types"
)
func TestNewSSEWriter(t *testing.T) {
rec := httptest.NewRecorder()
sse := NewSSEWriter(rec)
if sse == nil {
t.Fatal("NewSSEWriter returned nil")
}
headers := rec.Header()
if got := headers.Get("Content-Type"); got != "text/event-stream" {
t.Errorf("Content-Type = %q, want %q", got, "text/event-stream")
}
if got := headers.Get("Cache-Control"); got != "no-cache" {
t.Errorf("Cache-Control = %q, want %q", got, "no-cache")
}
if got := headers.Get("Connection"); got != "keep-alive" {
t.Errorf("Connection = %q, want %q", got, "keep-alive")
}
if got := headers.Get("X-Accel-Buffering"); got != "no" {
t.Errorf("X-Accel-Buffering = %q, want %q", got, "no")
}
}
func TestWriteChunk(t *testing.T) {
rec := httptest.NewRecorder()
sse := NewSSEWriter(rec)
content := "hello"
chunk := types.NewChatCompletionChunk("test-id", 0, "", types.Delta{Content: &content})
if err := sse.WriteChunk(chunk); err != nil {
t.Fatalf("WriteChunk returned error: %v", err)
}
body := rec.Body.String()
if !strings.HasPrefix(body, "data: ") {
t.Errorf("WriteChunk output missing 'data: ' prefix, got %q", body)
}
if !strings.HasSuffix(body, "\n\n") {
t.Errorf("WriteChunk output missing trailing newlines, got %q", body)
}
jsonStr := strings.TrimPrefix(body, "data: ")
jsonStr = strings.TrimSuffix(jsonStr, "\n\n")
var parsed types.ChatCompletionChunk
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
t.Fatalf("failed to unmarshal chunk JSON: %v", err)
}
if parsed.ID != "test-id" {
t.Errorf("parsed chunk ID = %q, want %q", parsed.ID, "test-id")
}
if len(parsed.Choices) != 1 || *parsed.Choices[0].Delta.Content != "hello" {
t.Errorf("parsed chunk content mismatch, got %v", parsed)
}
}
func TestWriteDone(t *testing.T) {
rec := httptest.NewRecorder()
sse := NewSSEWriter(rec)
sse.WriteDone()
body := rec.Body.String()
want := "data: [DONE]\n\n"
if body != want {
t.Errorf("WriteDone output = %q, want %q", body, want)
}
}

View File

@ -1,200 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"strings"
)
// Sentinels the brain is instructed to wrap tool calls with. We use XML-ish
// tags rather than markdown fences because they are unambiguous and easy to
// detect mid-stream without confusing them with normal code blocks.
const (
toolCallOpen = "<tool_call>"
toolCallClose = "</tool_call>"
)
// ParsedToolCall is a successfully extracted tool invocation request from
// the brain's text stream.
type ParsedToolCall struct {
Name string
Input json.RawMessage
}
// ToolCallStreamParser is a small streaming state machine that splits an
// incoming text stream into:
// - safe-to-emit plain text (everything outside <tool_call>...</tool_call>)
// - one or more ParsedToolCall (everything between sentinels)
//
// It buffers just enough trailing bytes to avoid emitting half of an opening
// sentinel as text.
type ToolCallStreamParser struct {
buf strings.Builder
inToolCall bool
}
// NewToolCallStreamParser returns a fresh parser.
func NewToolCallStreamParser() *ToolCallStreamParser {
return &ToolCallStreamParser{}
}
// Feed appends s to the parser's buffer and returns:
// - emitText: text safe to forward as text_delta to the caller now
// - calls: tool calls fully extracted in this Feed
// - err: a malformed tool_call block (invalid JSON inside sentinels)
//
// Feed never returns text that could be the prefix of an opening sentinel —
// such bytes stay buffered until the next Feed/Flush.
func (p *ToolCallStreamParser) Feed(s string) (emitText string, calls []ParsedToolCall, err error) {
p.buf.WriteString(s)
var emitted strings.Builder
for {
current := p.buf.String()
if p.inToolCall {
closeIdx := strings.Index(current, toolCallClose)
if closeIdx < 0 {
return emitted.String(), calls, nil
}
payload := current[:closeIdx]
call, perr := parseToolCallPayload(payload)
rest := current[closeIdx+len(toolCallClose):]
rest = strings.TrimPrefix(rest, "\r")
rest = strings.TrimPrefix(rest, "\n")
p.buf.Reset()
p.buf.WriteString(rest)
p.inToolCall = false
if perr != nil {
return emitted.String(), calls, perr
}
calls = append(calls, call)
continue
}
openIdx := strings.Index(current, toolCallOpen)
if openIdx >= 0 {
emitted.WriteString(current[:openIdx])
rest := current[openIdx+len(toolCallOpen):]
rest = strings.TrimPrefix(rest, "\r")
rest = strings.TrimPrefix(rest, "\n")
p.buf.Reset()
p.buf.WriteString(rest)
p.inToolCall = true
continue
}
// No open sentinel yet. Emit everything except a potential prefix
// of `<tool_call>` lurking at the tail of the buffer.
hold := potentialSentinelSuffix(current, toolCallOpen)
if hold == 0 {
emitted.WriteString(current)
p.buf.Reset()
return emitted.String(), calls, nil
}
emitted.WriteString(current[:len(current)-hold])
tail := current[len(current)-hold:]
p.buf.Reset()
p.buf.WriteString(tail)
return emitted.String(), calls, nil
}
}
// Flush returns any remaining buffered text and resets the parser. If we
// ended mid-`<tool_call>` block (no closing sentinel), the partial content
// is returned as plain text — better the caller sees something than data
// loss.
func (p *ToolCallStreamParser) Flush() (string, error) {
leftover := p.buf.String()
p.buf.Reset()
if p.inToolCall {
p.inToolCall = false
return toolCallOpen + leftover, fmt.Errorf("unterminated %s block", toolCallOpen)
}
return leftover, nil
}
// ExtractAllToolCalls is the non-streaming counterpart: scan the full text
// once, return cleaned text (with tool_call blocks removed) plus extracted
// calls. Any malformed block is preserved verbatim in the returned text.
func ExtractAllToolCalls(text string) (cleanText string, calls []ParsedToolCall) {
var out strings.Builder
rest := text
for {
i := strings.Index(rest, toolCallOpen)
if i < 0 {
out.WriteString(rest)
break
}
out.WriteString(rest[:i])
after := rest[i+len(toolCallOpen):]
j := strings.Index(after, toolCallClose)
if j < 0 {
// Unterminated; keep the rest verbatim.
out.WriteString(toolCallOpen)
out.WriteString(after)
break
}
payload := after[:j]
if call, err := parseToolCallPayload(payload); err == nil {
calls = append(calls, call)
} else {
// Keep malformed block as-is so the user can see it.
out.WriteString(toolCallOpen)
out.WriteString(payload)
out.WriteString(toolCallClose)
}
rest = strings.TrimPrefix(after[j+len(toolCallClose):], "\n")
}
return strings.TrimSpace(out.String()), calls
}
func parseToolCallPayload(payload string) (ParsedToolCall, error) {
trimmed := strings.TrimSpace(payload)
// Allow the brain to wrap the JSON in ```json fences too.
trimmed = strings.TrimPrefix(trimmed, "```json")
trimmed = strings.TrimPrefix(trimmed, "```")
trimmed = strings.TrimSuffix(trimmed, "```")
trimmed = strings.TrimSpace(trimmed)
if trimmed == "" {
return ParsedToolCall{}, fmt.Errorf("empty tool_call body")
}
var raw struct {
Name string `json:"name"`
Tool string `json:"tool"`
Input json.RawMessage `json:"input"`
Args json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal([]byte(trimmed), &raw); err != nil {
return ParsedToolCall{}, fmt.Errorf("invalid tool_call json: %w", err)
}
name := raw.Name
if name == "" {
name = raw.Tool
}
if name == "" {
return ParsedToolCall{}, fmt.Errorf("tool_call missing name")
}
input := raw.Input
if len(input) == 0 {
input = raw.Args
}
if len(input) == 0 {
input = json.RawMessage(`{}`)
}
return ParsedToolCall{Name: name, Input: input}, nil
}
// potentialSentinelSuffix returns the length of the longest suffix of s
// that is a strict prefix of sentinel.
func potentialSentinelSuffix(s, sentinel string) int {
maxLen := len(sentinel) - 1
if maxLen > len(s) {
maxLen = len(s)
}
for i := maxLen; i > 0; i-- {
if strings.HasPrefix(sentinel, s[len(s)-i:]) {
return i
}
}
return 0
}

View File

@ -1,98 +0,0 @@
package server
import (
"strings"
"testing"
)
func TestToolCallStreamParser_PlainTextPassThrough(t *testing.T) {
p := NewToolCallStreamParser()
emit, calls, err := p.Feed("hello world\n")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(calls) != 0 {
t.Fatalf("expected no calls, got %+v", calls)
}
if emit != "hello world\n" {
t.Fatalf("emit = %q, want passthrough", emit)
}
rest, err := p.Flush()
if err != nil {
t.Fatalf("flush error: %v", err)
}
if rest != "" {
t.Fatalf("flush leftover = %q, want empty", rest)
}
}
func TestToolCallStreamParser_ExtractsCompleteCall(t *testing.T) {
p := NewToolCallStreamParser()
in := "before\n<tool_call>\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n</tool_call>\nafter"
emit, calls, err := p.Feed(in)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "bash" {
t.Fatalf("name = %q", calls[0].Name)
}
if !strings.Contains(string(calls[0].Input), `"command":"ls"`) {
t.Fatalf("input = %s", calls[0].Input)
}
if !strings.Contains(emit, "before") || !strings.Contains(emit, "after") {
t.Fatalf("emit lost surrounding text: %q", emit)
}
}
func TestToolCallStreamParser_HoldsPartialOpenSentinel(t *testing.T) {
p := NewToolCallStreamParser()
// Feed a chunk ending with a partial "<tool_ca". Parser must not emit it.
emit, calls, err := p.Feed("text<tool_ca")
if err != nil {
t.Fatalf("error: %v", err)
}
if len(calls) != 0 {
t.Fatalf("calls = %+v", calls)
}
if emit != "text" {
t.Fatalf("emit = %q, want %q", emit, "text")
}
emit2, calls2, err := p.Feed("ll>{\"name\":\"x\"}</tool_call>")
if err != nil {
t.Fatalf("error 2: %v", err)
}
if emit2 != "" {
t.Fatalf("emit2 = %q, want empty (only call extracted)", emit2)
}
if len(calls2) != 1 || calls2[0].Name != "x" {
t.Fatalf("calls2 = %+v", calls2)
}
}
func TestToolCallStreamParser_RejectsInvalidJSON(t *testing.T) {
p := NewToolCallStreamParser()
_, _, err := p.Feed("<tool_call>not json</tool_call>")
if err == nil {
t.Fatal("expected parse error for invalid JSON inside sentinels")
}
}
func TestExtractAllToolCalls_MultipleAndCleanText(t *testing.T) {
in := "preamble\n<tool_call>{\"name\":\"a\",\"input\":{}}</tool_call>\nmiddle\n<tool_call>{\"tool\":\"b\",\"arguments\":{\"x\":1}}</tool_call>\nend"
clean, calls := ExtractAllToolCalls(in)
if len(calls) != 2 {
t.Fatalf("calls = %d", len(calls))
}
if calls[0].Name != "a" || calls[1].Name != "b" {
t.Fatalf("names = %q, %q", calls[0].Name, calls[1].Name)
}
if !strings.Contains(clean, "preamble") || !strings.Contains(clean, "middle") || !strings.Contains(clean, "end") {
t.Fatalf("clean text wrong: %q", clean)
}
if strings.Contains(clean, "<tool_call>") {
t.Fatalf("clean text still contains sentinels: %q", clean)
}
}

View File

@ -0,0 +1,28 @@
package svc
import (
"cursor-api-proxy/internal/config"
domainrepo "cursor-api-proxy/pkg/domain/repository"
"cursor-api-proxy/pkg/repository"
)
type ServiceContext struct {
Config config.Config
// Domain services
AccountPool domainrepo.AccountPool
// Last model for sticky model mode
LastModel *string
}
func NewServiceContext(c config.Config) *ServiceContext {
accountPool := repository.NewAccountPool(c.ConfigDirs)
lastModel := c.DefaultModel
return &ServiceContext{
Config: c,
AccountPool: accountPool,
LastModel: &lastModel,
}
}

View File

@ -1,114 +0,0 @@
package types
import "encoding/json"
// AnthropicBlock is a single content block in Anthropic Messages API. It can
// be text, image, document, tool_use, or tool_result. We decode the raw JSON
// once and keep the rest as RawData so downstream code (prompt building) can
// render every type faithfully.
type AnthropicBlock struct {
Type string `json:"type"`
// type=text
Text string `json:"text,omitempty"`
// type=tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// type=tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
// type=image / document
Source json.RawMessage `json:"source,omitempty"`
Title string `json:"title,omitempty"`
}
// AnthropicTextBlock kept for response serialisation of plain text content.
// Deprecated: use AnthropicResponseBlock for outputs that may also carry
// tool_use blocks.
type AnthropicTextBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// AnthropicResponseBlock is a polymorphic content block emitted by the
// proxy. It can be a "text" block or a synthetic "tool_use" block produced
// by translating a brain-side <tool_call>...</tool_call> sentinel.
type AnthropicResponseBlock struct {
Type string `json:"type"`
// type=text
Text string `json:"text,omitempty"`
// type=tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
// AnthropicContent is a flexible field: it can be a plain string OR an array
// of blocks. Claude Code always sends the array form.
type AnthropicContent []AnthropicBlock
func (c *AnthropicContent) UnmarshalJSON(data []byte) error {
var text string
if err := json.Unmarshal(data, &text); err == nil {
*c = []AnthropicBlock{{Type: "text", Text: text}}
return nil
}
var blocks []AnthropicBlock
if err := json.Unmarshal(data, &blocks); err != nil {
return err
}
*c = blocks
return nil
}
type AnthropicSystem AnthropicContent
func (s *AnthropicSystem) UnmarshalJSON(data []byte) error {
return (*AnthropicContent)(s).UnmarshalJSON(data)
}
type AnthropicMessage struct {
Role string `json:"role"`
Content AnthropicContent `json:"content"`
}
// AnthropicTool mirrors Anthropic's `tools` entry shape. InputSchema is left
// as RawMessage so we can render it verbatim in a system prompt without
// caring about the exact JSON Schema structure.
type AnthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
}
type AnthropicMessagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []AnthropicMessage `json:"messages"`
System AnthropicSystem `json:"system,omitempty"`
Stream bool `json:"stream"`
Tools []AnthropicTool `json:"tools,omitempty"`
}
type AnthropicMessagesResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []AnthropicResponseBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Usage AnthropicUsage `json:"usage"`
}
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}

View File

@ -1,57 +1,43 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.10.1
package types
import (
"encoding/json"
"strings"
)
func StringPtr(s string) *string { return &s }
// Request
type ChatMessageContent string
type ChatMessageContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
type AnthropicRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,optional"`
System string `json:"system,optional"`
Tools []Tool `json:"tools,optional"`
}
func (c *ChatMessageContent) UnmarshalJSON(data []byte) error {
var text string
if err := json.Unmarshal(data, &text); err == nil {
*c = ChatMessageContent(text)
return nil
}
var parts []ChatMessageContentPart
if err := json.Unmarshal(data, &parts); err != nil {
return err
}
var content strings.Builder
for _, part := range parts {
if part.Type == "text" {
content.WriteString(part.Text)
}
}
*c = ChatMessageContent(content.String())
return nil
type AnthropicResponse struct {
Id string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
Usage AnthropicUsage `json:"usage"`
}
type ChatMessage struct {
Role string `json:"role"`
Content ChatMessageContent `json:"content"`
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature *float64 `json:"temperature,omitempty"`
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream,optional"`
Tools []Tool `json:"tools,optional"`
Functions []Function `json:"functions,optional"`
MaxTokens int `json:"max_tokens,optional"`
Temperature float64 `json:"temperature,optional"`
}
// Response (non-streaming)
type ChatCompletionResponse struct {
ID string `json:"id"`
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
@ -61,8 +47,83 @@ type ChatCompletionResponse struct {
type Choice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason *string `json:"finish_reason"`
Message RespMessage `json:"message,optional"`
Delta Delta `json:"delta,optional"`
FinishReason string `json:"finish_reason"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,optional"`
}
type Delta struct {
Role string `json:"role,optional"`
Content string `json:"content,optional"`
ReasoningContent string `json:"reasoning_content,optional"`
ToolCalls []ToolCall `json:"tool_calls,optional"`
}
type Function struct {
Name string `json:"name"`
Description string `json:"description,optional"`
Parameters interface{} `json:"parameters,optional"`
}
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type HealthRequest struct {
}
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
type Message struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
type ModelData struct {
Id string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
}
type ModelsRequest struct {
}
type ModelsResponse struct {
Object string `json:"object"`
Data []ModelData `json:"data"`
}
type RespMessage struct {
Role string `json:"role"`
Content string `json:"content,optional"`
ToolCalls []ToolCall `json:"tool_calls,optional"`
}
type Tool struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
type ToolCall struct {
Index int `json:"index"`
Id string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
type ToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
}
type Usage struct {
@ -70,73 +131,3 @@ type Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Streaming chunk
type ChatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model,omitempty"`
Choices []ChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
type ChunkChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type Delta struct {
Role *string `json:"role,omitempty"`
Content *string `json:"content,omitempty"`
}
// Models list
type ModelList struct {
Object string `json:"object"`
Data []ModelInfo `json:"data"`
}
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
// Error response
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
type ErrorBody struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code,omitempty"`
}
func NewErrorResponse(message, errType, code string) ErrorResponse {
return ErrorResponse{
Error: ErrorBody{
Message: message,
Type: errType,
Code: code,
},
}
}
func NewChatCompletionChunk(id string, created int64, model string, delta Delta) ChatCompletionChunk {
return ChatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []ChunkChoice{
{
Index: 0,
Delta: delta,
},
},
}
}

View File

@ -1,132 +0,0 @@
// Package workspace sets up an isolated temp directory for each Cursor CLI /
// ACP child. It pre-populates a minimal .cursor config so the agent does not
// load the real user's global rules from ~/.cursor, and returns a set of
// environment overrides (HOME, CURSOR_CONFIG_DIR, XDG_CONFIG_HOME, APPDATA…)
// so the child cannot escape back to the real profile.
//
// Ported from cursor-api-proxy/src/lib/workspace.ts.
package workspace
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
)
// ChatOnly prepares an isolated temp workspace and returns:
// - dir: the absolute path of the new temp directory (caller is responsible
// for removing it when the child process exits).
// - env: a map of environment variables to override on the child.
//
// Auth is the tricky part. The Cursor CLI resolves login tokens from the
// real user profile (macOS keychain on darwin, ~/.cursor/agent-cli-state.json
// elsewhere); if we override HOME to the temp dir, `agent --print` dies with
// "Authentication required. Please run 'agent login' first…". So we keep
// HOME untouched unless either:
// - CURSOR_API_KEY is set (the CLI uses the env var and doesn't need HOME), or
// - authConfigDir is non-empty (account-pool mode, not used here yet).
//
// We *do* always override CURSOR_CONFIG_DIR → tempDir/.cursor. That's the
// setting Cursor uses to locate rules/, cli-config.json, and per-project
// state, so this single override is enough to stop the agent from loading
// the user's real ~/.cursor/rules/* into the prompt.
func ChatOnly(authConfigDir string) (dir string, env map[string]string, err error) {
tempDir, err := os.MkdirTemp("", "cursor-adapter-ws-*")
if err != nil {
return "", nil, fmt.Errorf("mkdtemp: %w", err)
}
cursorDir := filepath.Join(tempDir, ".cursor")
if err := os.MkdirAll(filepath.Join(cursorDir, "rules"), 0o755); err != nil {
_ = os.RemoveAll(tempDir)
return "", nil, fmt.Errorf("mkdir .cursor/rules: %w", err)
}
minimalConfig := map[string]any{
"version": 1,
"editor": map[string]any{"vimMode": false},
"permissions": map[string]any{
"allow": []any{},
"deny": []any{},
},
}
cfgBytes, _ := json.Marshal(minimalConfig)
if err := os.WriteFile(filepath.Join(cursorDir, "cli-config.json"), cfgBytes, 0o644); err != nil {
_ = os.RemoveAll(tempDir)
return "", nil, fmt.Errorf("write cli-config.json: %w", err)
}
env = map[string]string{}
if authConfigDir != "" {
env["CURSOR_CONFIG_DIR"] = authConfigDir
return tempDir, env, nil
}
env["CURSOR_CONFIG_DIR"] = cursorDir
// Only fully isolate HOME if the child will auth via CURSOR_API_KEY.
// With keychain/home-based auth, replacing HOME makes agent exit 1.
if os.Getenv("CURSOR_API_KEY") != "" {
env["HOME"] = tempDir
env["USERPROFILE"] = tempDir
if runtime.GOOS == "windows" {
appDataRoaming := filepath.Join(tempDir, "AppData", "Roaming")
appDataLocal := filepath.Join(tempDir, "AppData", "Local")
_ = os.MkdirAll(appDataRoaming, 0o755)
_ = os.MkdirAll(appDataLocal, 0o755)
env["APPDATA"] = appDataRoaming
env["LOCALAPPDATA"] = appDataLocal
} else {
xdg := filepath.Join(tempDir, ".config")
_ = os.MkdirAll(xdg, 0o755)
env["XDG_CONFIG_HOME"] = xdg
}
}
return tempDir, env, nil
}
// MergeEnv takes the current process env (as "KEY=VALUE" strings) and
// overlays overrides on top, returning a new slice suitable for exec.Cmd.Env.
// Keys from overrides replace any existing entries with the same key.
func MergeEnv(base []string, overrides map[string]string) []string {
if len(overrides) == 0 {
return base
}
out := make([]string, 0, len(base)+len(overrides))
seen := make(map[string]bool, len(overrides))
for _, kv := range base {
eq := indexOf(kv, '=')
if eq < 0 {
out = append(out, kv)
continue
}
key := kv[:eq]
if v, ok := overrides[key]; ok {
out = append(out, key+"="+v)
seen[key] = true
} else {
out = append(out, kv)
}
}
for k, v := range overrides {
if !seen[k] {
out = append(out, k+"="+v)
}
}
return out
}
func indexOf(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}

View File

@ -1,103 +0,0 @@
package workspace
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestChatOnly_NoApiKey_KeepsHome(t *testing.T) {
t.Setenv("CURSOR_API_KEY", "")
dir, env, err := ChatOnly("")
if err != nil {
t.Fatalf("ChatOnly: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if !filepath.IsAbs(dir) {
t.Errorf("expected absolute path, got %q", dir)
}
cfgPath := filepath.Join(dir, ".cursor", "cli-config.json")
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("expected cli-config.json to exist: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("cli-config.json is not valid JSON: %v", err)
}
if env["CURSOR_CONFIG_DIR"] != filepath.Join(dir, ".cursor") {
t.Errorf("CURSOR_CONFIG_DIR override wrong: %q", env["CURSOR_CONFIG_DIR"])
}
if _, ok := env["HOME"]; ok {
t.Errorf("HOME should NOT be overridden without CURSOR_API_KEY, got %q (would break keychain auth)", env["HOME"])
}
}
func TestChatOnly_WithApiKey_IsolatesHome(t *testing.T) {
t.Setenv("CURSOR_API_KEY", "sk-fake-for-test")
dir, env, err := ChatOnly("")
if err != nil {
t.Fatalf("ChatOnly: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if env["HOME"] != dir {
t.Errorf("HOME override = %q, want %q", env["HOME"], dir)
}
if runtime.GOOS != "windows" && env["XDG_CONFIG_HOME"] != filepath.Join(dir, ".config") {
t.Errorf("XDG_CONFIG_HOME override wrong: %q", env["XDG_CONFIG_HOME"])
}
}
func TestChatOnly_WithAuthConfigDir_OnlySetsCursorConfigDir(t *testing.T) {
dir, env, err := ChatOnly("/tmp/fake-auth")
if err != nil {
t.Fatalf("ChatOnly: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if env["CURSOR_CONFIG_DIR"] != "/tmp/fake-auth" {
t.Errorf("expected CURSOR_CONFIG_DIR to be the auth dir, got %q", env["CURSOR_CONFIG_DIR"])
}
if _, ok := env["HOME"]; ok {
t.Errorf("HOME should not be overridden when authConfigDir is set, got %q", env["HOME"])
}
}
func TestMergeEnv_OverridesExistingKeys(t *testing.T) {
base := []string{"FOO=1", "HOME=/old", "BAR=2"}
out := MergeEnv(base, map[string]string{
"HOME": "/new",
"BAZ": "3",
})
joined := strings.Join(out, "\n")
if !strings.Contains(joined, "HOME=/new") {
t.Errorf("expected HOME=/new in merged env, got: %v", out)
}
if strings.Contains(joined, "HOME=/old") {
t.Errorf("old HOME should have been replaced, got: %v", out)
}
if !strings.Contains(joined, "FOO=1") || !strings.Contains(joined, "BAR=2") {
t.Errorf("unchanged keys should pass through, got: %v", out)
}
if !strings.Contains(joined, "BAZ=3") {
t.Errorf("new key should be appended, got: %v", out)
}
}
func TestMergeEnv_EmptyOverridesReturnsSameSlice(t *testing.T) {
base := []string{"FOO=1"}
out := MergeEnv(base, nil)
if len(out) != 1 || out[0] != "FOO=1" {
t.Errorf("expected base unchanged, got %v", out)
}
}

146
main.go
View File

@ -1,102 +1,90 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"github.com/spf13/cobra"
"github.com/daniel/cursor-adapter/internal/bridge"
"github.com/daniel/cursor-adapter/internal/config"
"github.com/daniel/cursor-adapter/internal/server"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/internal/handler"
"cursor-api-proxy/internal/svc"
cmd "cursor-api-proxy/cmd/cli"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/rest"
)
var (
configPath string
port int
debug bool
useACP bool
chatOnlySet bool
chatOnlyFlag bool
)
const version = "1.0.0"
var configFile = flag.String("f", "etc/chat-api.yaml", "the config file")
func main() {
rootCmd := &cobra.Command{
Use: "cursor-adapter",
Short: "OpenAI-compatible proxy for Cursor CLI",
RunE: run,
// Check for CLI commands first (before flag.Parse)
args := os.Args[1:]
if len(args) > 0 {
parsed, err := cmd.ParseArgs(args)
if err != nil {
// Not a CLI command, proceed to HTTP server
} else if handleCLICommand(parsed) {
return
}
}
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "config file path (default: ~/.cursor-adapter/config.yaml)")
rootCmd.Flags().IntVarP(&port, "port", "p", 0, "server port (overrides config)")
rootCmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
rootCmd.Flags().BoolVar(&useACP, "use-acp", false, "use Cursor ACP transport instead of CLI stream-json")
rootCmd.Flags().BoolVar(&chatOnlyFlag, "chat-only-workspace", true, "isolate Cursor CLI in an empty temp workspace with overridden HOME/CURSOR_CONFIG_DIR (set to false to let Cursor agent see the adapter's cwd)")
rootCmd.PreRun = func(cmd *cobra.Command, args []string) {
chatOnlySet = cmd.Flags().Changed("chat-only-workspace")
}
// HTTP server mode (go-zero)
flag.Parse()
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
var c config.Config
conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}
func run(cmd *cobra.Command, args []string) error {
var logLevel slog.Level
if debug {
logLevel = slog.LevelDebug
} else {
logLevel = slog.LevelInfo
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
slog.SetDefault(logger)
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
func handleCLICommand(args cmd.ParsedArgs) bool {
if args.Help {
cmd.PrintHelp(version)
return true
}
if port > 0 {
cfg.Port = port
}
if useACP {
cfg.UseACP = true
}
if chatOnlySet {
cfg.ChatOnlyWorkspace = chatOnlyFlag
if args.Login {
if err := cmd.HandleLogin(args.AccountName, args.Proxies); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
}
br := bridge.NewBridge(bridge.Options{
CursorPath: cfg.CursorCLIPath,
Logger: logger,
UseACP: cfg.UseACP,
ChatOnly: cfg.ChatOnlyWorkspace,
MaxConcurrent: cfg.MaxConcurrent,
Timeout: time.Duration(cfg.Timeout) * time.Second,
Mode: cfg.CursorMode,
WorkspaceRoot: cfg.WorkspaceRoot,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := br.CheckHealth(ctx); err != nil {
return fmt.Errorf("cursor cli not available: %w", err)
if args.Logout {
if err := cmd.HandleLogout(args.AccountName); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
}
logger.Info("Cursor CLI OK")
srv := server.New(cfg, br)
mode := "CLI"
if cfg.UseACP {
mode = "ACP"
if args.AccountsList {
if err := cmd.HandleAccountsList(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
}
logger.Info("Starting cursor-adapter",
"port", cfg.Port,
"transport", mode,
"cursor_mode", cfg.CursorMode,
"workspace_root", cfg.WorkspaceRoot,
"chat_only_workspace", cfg.ChatOnlyWorkspace,
)
return srv.Run()
if args.ResetHwid {
if err := cmd.HandleResetHwid(args.DeepClean, args.DryRun); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return true
}
// Not a CLI command
return false
}

View File

@ -0,0 +1,174 @@
package anthropic
import (
"cursor-api-proxy/pkg/adapter/openai"
"encoding/json"
"fmt"
"strings"
)
type MessageParam struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
type MessagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []MessageParam `json:"messages"`
System interface{} `json:"system"`
Stream bool `json:"stream"`
Tools []interface{} `json:"tools"`
}
func systemToText(system interface{}) string {
if system == nil {
return ""
}
switch v := system.(type) {
case string:
return strings.TrimSpace(v)
case []interface{}:
var parts []string
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
if m["type"] == "text" {
if t, ok := m["text"].(string); ok {
parts = append(parts, t)
}
}
}
}
return strings.Join(parts, "\n")
}
return ""
}
func anthropicBlockToText(p interface{}) string {
if p == nil {
return ""
}
switch v := p.(type) {
case string:
return v
case map[string]interface{}:
typ, _ := v["type"].(string)
switch typ {
case "text":
if t, ok := v["text"].(string); ok {
return t
}
case "image":
if src, ok := v["source"].(map[string]interface{}); ok {
srcType, _ := src["type"].(string)
switch srcType {
case "base64":
mt, _ := src["media_type"].(string)
if mt == "" {
mt = "image"
}
return "[Image: base64 " + mt + "]"
case "url":
url, _ := src["url"].(string)
return "[Image: " + url + "]"
}
}
return "[Image]"
case "document":
title, _ := v["title"].(string)
if title == "" {
if src, ok := v["source"].(map[string]interface{}); ok {
title, _ = src["url"].(string)
}
}
if title != "" {
return "[Document: " + title + "]"
}
return "[Document]"
case "tool_use":
name, _ := v["name"].(string)
id, _ := v["id"].(string)
input := v["input"]
inputJSON, _ := json.Marshal(input)
if inputJSON == nil {
inputJSON = []byte("{}")
}
tag := fmt.Sprintf("<tool_call>\n{\"name\": \"%s\", \"arguments\": %s}\n</tool_call>", name, string(inputJSON))
if id != "" {
tag = fmt.Sprintf("[tool_use_id=%s] ", id) + tag
}
return tag
case "tool_result":
toolUseID, _ := v["tool_use_id"].(string)
content := v["content"]
var contentText string
switch c := content.(type) {
case string:
contentText = c
case []interface{}:
var parts []string
for _, block := range c {
if bm, ok := block.(map[string]interface{}); ok {
if bm["type"] == "text" {
if t, ok := bm["text"].(string); ok {
parts = append(parts, t)
}
}
}
}
contentText = strings.Join(parts, "\n")
}
label := "Tool result"
if toolUseID != "" {
label += " [id=" + toolUseID + "]"
}
return label + ": " + contentText
}
}
return ""
}
func anthropicContentToText(content interface{}) string {
switch v := content.(type) {
case string:
return v
case []interface{}:
var parts []string
for _, p := range v {
if t := anthropicBlockToText(p); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, " ")
}
return ""
}
func BuildPromptFromAnthropicMessages(messages []MessageParam, system interface{}) string {
var oaiMessages []interface{}
systemText := systemToText(system)
if systemText != "" {
oaiMessages = append(oaiMessages, map[string]interface{}{
"role": "system",
"content": systemText,
})
}
for _, m := range messages {
text := anthropicContentToText(m.Content)
if text == "" {
continue
}
role := m.Role
if role != "user" && role != "assistant" {
role = "user"
}
oaiMessages = append(oaiMessages, map[string]interface{}{
"role": role,
"content": text,
})
}
return openai.BuildPromptFromMessages(oaiMessages)
}

View File

@ -0,0 +1,109 @@
package anthropic_test
import (
"cursor-api-proxy/pkg/adapter/anthropic"
"strings"
"testing"
)
func TestBuildPromptFromAnthropicMessages_Simple(t *testing.T) {
messages := []anthropic.MessageParam{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
if !strings.Contains(prompt, "Hello") {
t.Errorf("prompt missing user message: %q", prompt)
}
if !strings.Contains(prompt, "Hi there") {
t.Errorf("prompt missing assistant message: %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_WithSystem(t *testing.T) {
messages := []anthropic.MessageParam{
{Role: "user", Content: "ping"},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, "You are a helpful bot.")
if !strings.Contains(prompt, "You are a helpful bot.") {
t.Errorf("prompt missing system: %q", prompt)
}
if !strings.Contains(prompt, "ping") {
t.Errorf("prompt missing user: %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_SystemArray(t *testing.T) {
system := []interface{}{
map[string]interface{}{"type": "text", "text": "Part A"},
map[string]interface{}{"type": "text", "text": "Part B"},
}
messages := []anthropic.MessageParam{
{Role: "user", Content: "test"},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, system)
if !strings.Contains(prompt, "Part A") {
t.Errorf("prompt missing Part A: %q", prompt)
}
if !strings.Contains(prompt, "Part B") {
t.Errorf("prompt missing Part B: %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_ContentBlocks(t *testing.T) {
content := []interface{}{
map[string]interface{}{"type": "text", "text": "block one"},
map[string]interface{}{"type": "text", "text": "block two"},
}
messages := []anthropic.MessageParam{
{Role: "user", Content: content},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
if !strings.Contains(prompt, "block one") {
t.Errorf("prompt missing 'block one': %q", prompt)
}
if !strings.Contains(prompt, "block two") {
t.Errorf("prompt missing 'block two': %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_ImageBlock(t *testing.T) {
content := []interface{}{
map[string]interface{}{
"type": "image",
"source": map[string]interface{}{
"type": "base64",
"media_type": "image/png",
"data": "abc123",
},
},
}
messages := []anthropic.MessageParam{
{Role: "user", Content: content},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
if !strings.Contains(prompt, "[Image") {
t.Errorf("prompt missing [Image]: %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_EmptyContentSkipped(t *testing.T) {
messages := []anthropic.MessageParam{
{Role: "user", Content: ""},
{Role: "assistant", Content: "response"},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
if !strings.Contains(prompt, "response") {
t.Errorf("prompt missing 'response': %q", prompt)
}
}
func TestBuildPromptFromAnthropicMessages_UnknownRoleBecomesUser(t *testing.T) {
messages := []anthropic.MessageParam{
{Role: "system", Content: "system-as-user"},
}
prompt := anthropic.BuildPromptFromAnthropicMessages(messages, nil)
if !strings.Contains(prompt, "system-as-user") {
t.Errorf("prompt missing 'system-as-user': %q", prompt)
}
}

View File

@ -0,0 +1,243 @@
package openai
import (
"encoding/json"
"fmt"
"strings"
)
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []interface{} `json:"messages"`
Stream bool `json:"stream"`
Tools []interface{} `json:"tools"`
ToolChoice interface{} `json:"tool_choice"`
Functions []interface{} `json:"functions"`
FunctionCall interface{} `json:"function_call"`
}
func NormalizeModelID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parts := strings.Split(trimmed, "/")
last := parts[len(parts)-1]
if last == "" {
return ""
}
return last
}
func imageURLToText(imageURL interface{}) string {
if imageURL == nil {
return "[Image]"
}
var url string
switch v := imageURL.(type) {
case string:
url = v
case map[string]interface{}:
if u, ok := v["url"].(string); ok {
url = u
}
}
if url == "" {
return "[Image]"
}
if strings.HasPrefix(url, "data:") {
end := strings.Index(url, ";")
mime := "image"
if end > 5 {
mime = url[5:end]
}
return "[Image: base64 " + mime + "]"
}
return "[Image: " + url + "]"
}
func MessageContentToText(content interface{}) string {
if content == nil {
return ""
}
switch v := content.(type) {
case string:
return v
case []interface{}:
var parts []string
for _, p := range v {
if p == nil {
continue
}
switch part := p.(type) {
case string:
parts = append(parts, part)
case map[string]interface{}:
typ, _ := part["type"].(string)
switch typ {
case "text":
if t, ok := part["text"].(string); ok {
parts = append(parts, t)
}
case "image_url":
parts = append(parts, imageURLToText(part["image_url"]))
case "image":
src := part["source"]
if src == nil {
src = part["url"]
}
parts = append(parts, imageURLToText(src))
}
}
}
return strings.Join(parts, " ")
}
return ""
}
func ToolsToSystemText(tools []interface{}, functions []interface{}) string {
var defs []interface{}
for _, t := range tools {
if m, ok := t.(map[string]interface{}); ok {
if m["type"] == "function" {
if fn := m["function"]; fn != nil {
defs = append(defs, fn)
}
} else {
defs = append(defs, t)
}
}
}
defs = append(defs, functions...)
if len(defs) == 0 {
return ""
}
var lines []string
lines = append(lines, "Available tools (respond with a JSON object to call one):", "")
for _, raw := range defs {
fn, ok := raw.(map[string]interface{})
if !ok {
continue
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params := "{}"
if p := fn["parameters"]; p != nil {
if b, err := json.MarshalIndent(p, "", " "); err == nil {
params = string(b)
}
} else if p := fn["input_schema"]; p != nil {
if b, err := json.MarshalIndent(p, "", " "); err == nil {
params = string(b)
}
}
lines = append(lines, "Function: "+name+"\nDescription: "+desc+"\nParameters: "+params)
}
lines = append(lines, "",
"When you want to call a tool, use this EXACT format:",
"",
"<tool_call>",
`{"name": "function_name", "arguments": {"param1": "value1"}}`,
"</tool_call>",
"",
"Rules:",
"- Write your reasoning BEFORE the tool call",
"- You may make multiple tool calls by using multiple <tool_call> blocks",
"- STOP writing after the last </tool_call> tag",
"- If no tool is needed, respond normally without <tool_call> tags",
)
return strings.Join(lines, "\n")
}
type SimpleMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func BuildPromptFromMessages(messages []interface{}) string {
var systemParts []string
var convo []string
for _, raw := range messages {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
role, _ := m["role"].(string)
text := MessageContentToText(m["content"])
switch role {
case "system", "developer":
if text != "" {
systemParts = append(systemParts, text)
}
case "user":
if text != "" {
convo = append(convo, "User: "+text)
}
case "assistant":
toolCalls, _ := m["tool_calls"].([]interface{})
if len(toolCalls) > 0 {
var parts []string
if text != "" {
parts = append(parts, text)
}
for _, tc := range toolCalls {
tcMap, ok := tc.(map[string]interface{})
if !ok {
continue
}
fn, _ := tcMap["function"].(map[string]interface{})
if fn == nil {
continue
}
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
if args == "" {
args = "{}"
}
parts = append(parts, fmt.Sprintf("<tool_call>\n{\"name\": \"%s\", \"arguments\": %s}\n</tool_call>", name, args))
}
if len(parts) > 0 {
convo = append(convo, "Assistant: "+strings.Join(parts, "\n"))
}
} else if text != "" {
convo = append(convo, "Assistant: "+text)
}
case "tool", "function":
name, _ := m["name"].(string)
toolCallID, _ := m["tool_call_id"].(string)
label := "Tool result"
if name != "" {
label = "Tool result (" + name + ")"
}
if toolCallID != "" {
label += " [id=" + toolCallID + "]"
}
if text != "" {
convo = append(convo, label+": "+text)
}
}
}
system := ""
if len(systemParts) > 0 {
system = "System:\n" + strings.Join(systemParts, "\n\n") + "\n\n"
}
transcript := strings.Join(convo, "\n\n")
return system + transcript + "\n\nAssistant:"
}
func BuildPromptFromSimpleMessages(messages []SimpleMessage) string {
ifaces := make([]interface{}, len(messages))
for i, m := range messages {
ifaces[i] = map[string]interface{}{"role": m.Role, "content": m.Content}
}
return BuildPromptFromMessages(ifaces)
}

View File

@ -0,0 +1,80 @@
package openai
import "testing"
func TestNormalizeModelID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"gpt-4", "gpt-4"},
{"openai/gpt-4", "gpt-4"},
{"anthropic/claude-3", "claude-3"},
{"", ""},
{" ", ""},
{"a/b/c", "c"},
}
for _, tc := range tests {
got := NormalizeModelID(tc.input)
if got != tc.want {
t.Errorf("NormalizeModelID(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestBuildPromptFromMessages(t *testing.T) {
messages := []interface{}{
map[string]interface{}{"role": "system", "content": "You are helpful."},
map[string]interface{}{"role": "user", "content": "Hello"},
map[string]interface{}{"role": "assistant", "content": "Hi there"},
}
got := BuildPromptFromMessages(messages)
if got == "" {
t.Fatal("expected non-empty prompt")
}
containsSystem := false
containsUser := false
containsAssistant := false
for i := 0; i < len(got)-10; i++ {
if got[i:i+6] == "System" {
containsSystem = true
}
if got[i:i+4] == "User" {
containsUser = true
}
if got[i:i+9] == "Assistant" {
containsAssistant = true
}
}
if !containsSystem || !containsUser || !containsAssistant {
t.Errorf("prompt missing sections: system=%v user=%v assistant=%v\n%s",
containsSystem, containsUser, containsAssistant, got)
}
}
func TestToolsToSystemText(t *testing.T) {
tools := []interface{}{
map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "get_weather",
"description": "Get weather",
"parameters": map[string]interface{}{"type": "object"},
},
},
}
got := ToolsToSystemText(tools, nil)
if got == "" {
t.Fatal("expected non-empty tools text")
}
if len(got) < 10 {
t.Errorf("tools text too short: %q", got)
}
}
func TestToolsToSystemTextEmpty(t *testing.T) {
got := ToolsToSystemText(nil, nil)
if got != "" {
t.Errorf("expected empty string for no tools, got %q", got)
}
}

View File

@ -0,0 +1,22 @@
package entity
// Account represents an account in the pool
type Account struct {
ConfigDir string
ActiveRequests int
LastUsed int64
RateLimitUntil int64
}
// AccountStat represents account statistics
type AccountStat struct {
ConfigDir string
ActiveRequests int
TotalRequests int
TotalSuccess int
TotalErrors int
TotalRateLimits int
TotalLatencyMs int64
IsRateLimited bool
RateLimitUntil int64
}

View File

@ -0,0 +1,20 @@
package entity
// ChunkType represents the type of stream chunk
type ChunkType int
const (
ChunkText ChunkType = iota
ChunkThinking
ChunkToolCall
ChunkDone
)
// StreamChunk represents a chunk in SSE streaming
type StreamChunk struct {
Type ChunkType
Text string
Thinking string
ToolCall *ToolCall
Done bool
}

View File

@ -0,0 +1,33 @@
package entity
// Message represents a chat message
type Message struct {
Role string
Content interface{}
}
// Tool represents a tool definition
type Tool struct {
Type string
Function ToolFunction
}
// ToolFunction represents a tool function definition
type ToolFunction struct {
Name string
Description string
Parameters interface{}
}
// ToolCall represents a tool call result
type ToolCall struct {
ID string
Name string
Arguments string
}
// FunctionCall represents a function call
type FunctionCall struct {
Name string
Arguments string
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"cursor-api-proxy/pkg/domain/entity"
)
// AccountPool defines the interface for account pool management
type AccountPool interface {
GetNextConfigDir() string
ReportRequestStart(configDir string)
ReportRequestEnd(configDir string)
ReportRequestSuccess(configDir string, latencyMs int64)
ReportRequestError(configDir string, latencyMs int64)
ReportRateLimit(configDir string, penaltyMs int64)
GetStats() []entity.AccountStat
Count() int
}
// Provider defines the interface for AI providers
type Provider interface {
Name() string
Generate(ctx context.Context, model string, messages []entity.Message,
tools []entity.Tool, callback func(entity.StreamChunk)) error
Close() error
}

View File

@ -0,0 +1,13 @@
package types
import "errors"
var (
ErrInvalidRequest = errors.New("invalid request")
ErrProviderNotFound = errors.New("provider not found")
ErrAccountExhausted = errors.New("all accounts exhausted")
ErrRateLimited = errors.New("rate limited")
ErrTimeout = errors.New("request timeout")
ErrClientDisconnect = errors.New("client disconnected")
ErrAgentError = errors.New("agent execution error")
)

174
pkg/domain/types/models.go Normal file
View File

@ -0,0 +1,174 @@
package types
import (
"fmt"
"os"
"regexp"
"strings"
"cursor-api-proxy/pkg/infrastructure/process"
)
type CursorCliModel struct {
ID string
Name string
}
type ModelAlias struct {
CursorID string
AnthropicID string
Name string
}
var anthropicToCursor = map[string]string{
"claude-opus-4-6": "opus-4.6",
"claude-opus-4.6": "opus-4.6",
"claude-sonnet-4-6": "sonnet-4.6",
"claude-sonnet-4.6": "sonnet-4.6",
"claude-opus-4-5": "opus-4.5",
"claude-opus-4.5": "opus-4.5",
"claude-sonnet-4-5": "sonnet-4.5",
"claude-sonnet-4.5": "sonnet-4.5",
"claude-opus-4": "opus-4.6",
"claude-sonnet-4": "sonnet-4.6",
"claude-haiku-4-5-20251001": "sonnet-4.5",
"claude-haiku-4-5": "sonnet-4.5",
"claude-haiku-4-6": "sonnet-4.6",
"claude-haiku-4": "sonnet-4.5",
"claude-opus-4-6-thinking": "opus-4.6-thinking",
"claude-sonnet-4-6-thinking": "sonnet-4.6-thinking",
"claude-opus-4-5-thinking": "opus-4.5-thinking",
"claude-sonnet-4-5-thinking": "sonnet-4.5-thinking",
"claude-3-5-sonnet": "claude-3.5-sonnet",
"claude-3-5-sonnet-20241022": "claude-3.5-sonnet",
"claude-3-5-haiku": "claude-3.5-haiku",
"claude-3-opus": "claude-3-opus",
"claude-3-sonnet": "claude-3-sonnet",
"claude-3-haiku": "claude-3-haiku",
}
var cursorToAnthropicAlias = []ModelAlias{
{"opus-4.6", "claude-opus-4-6", "Claude 4.6 Opus"},
{"opus-4.6-thinking", "claude-opus-4-6-thinking", "Claude 4.6 Opus (Thinking)"},
{"sonnet-4.6", "claude-sonnet-4-6", "Claude 4.6 Sonnet"},
{"sonnet-4.6-thinking", "claude-sonnet-4-6-thinking", "Claude 4.6 Sonnet (Thinking)"},
{"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)"},
}
var modelLineRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+-\s+(.*)$`)
var trailingParenRe = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
var cursorModelPattern = regexp.MustCompile(`^([a-zA-Z]+)-(\d+)\.(\d+)(-thinking)?$`)
var reverseDynamicPattern = regexp.MustCompile(`^claude-([a-zA-Z]+)-(\d+)-(\d+)(-thinking)?$`)
type AnthropicAlias struct {
ID string
Name string
}
func ParseCursorCliModels(output string) []CursorCliModel {
lines := strings.Split(output, "\n")
seen := make(map[string]CursorCliModel)
var order []string
for _, line := range lines {
line = strings.TrimSpace(line)
m := modelLineRe.FindStringSubmatch(line)
if m == nil {
continue
}
id := m[1]
rawName := m[2]
name := strings.TrimSpace(trailingParenRe.ReplaceAllString(rawName, ""))
if name == "" {
name = id
}
if _, exists := seen[id]; !exists {
seen[id] = CursorCliModel{ID: id, Name: name}
order = append(order, id)
}
}
result := make([]CursorCliModel, 0, len(order))
for _, id := range order {
result = append(result, seen[id])
}
return result
}
func ListCursorCliModels(agentBin string, timeoutMs int) ([]CursorCliModel, error) {
tmpDir := os.TempDir()
result, err := process.Run(agentBin, []string{"--print-models_oneline"}, process.RunOptions{
Cwd: tmpDir,
TimeoutMs: timeoutMs,
})
if err != nil {
return nil, err
}
if result.Code != 0 {
return nil, fmt.Errorf("cursor cli failed: %s", result.Stderr)
}
return ParseCursorCliModels(result.Stdout), nil
}
func generateDynamicAlias(cursorID string) (AnthropicAlias, bool) {
m := cursorModelPattern.FindStringSubmatch(cursorID)
if m == nil {
return AnthropicAlias{}, false
}
family := m[1]
major := m[2]
minor := m[3]
thinking := m[4]
anthropicID := "claude-" + family + "-" + major + "-" + minor + thinking
capFamily := strings.ToUpper(family[:1]) + family[1:]
name := capFamily + " " + major + "." + minor
if thinking == "-thinking" {
name += " (Thinking)"
}
return AnthropicAlias{ID: anthropicID, Name: name}, true
}
func reverseDynamicAlias(anthropicID string) (string, bool) {
m := reverseDynamicPattern.FindStringSubmatch(anthropicID)
if m == nil {
return "", false
}
return m[1] + "-" + m[2] + "." + m[3] + m[4], true
}
func ResolveToCursorModel(requested string) string {
if mapped, ok := anthropicToCursor[requested]; ok {
return mapped
}
if cursorID, ok := reverseDynamicAlias(requested); ok {
return cursorID
}
return requested
}
func GetAnthropicModelAliases(cursorIDs []string) []AnthropicAlias {
result := make([]AnthropicAlias, 0, len(cursorToAnthropicAlias)+len(cursorIDs))
seen := make(map[string]bool)
for _, a := range cursorToAnthropicAlias {
result = append(result, AnthropicAlias{
ID: a.AnthropicID,
Name: a.Name,
})
seen[a.CursorID] = true
}
for _, id := range cursorIDs {
if seen[id] {
continue
}
if alias, ok := generateDynamicAlias(id); ok {
result = append(result, alias)
}
}
return result
}

View File

@ -0,0 +1,47 @@
package usecase
import (
"context"
"cursor-api-proxy/pkg/domain/entity"
)
// ChatUsecase defines the interface for chat operations
type ChatUsecase interface {
Execute(ctx context.Context, input ChatInput) (ChatOutput, error)
Stream(ctx context.Context, input ChatInput, callback func(entity.StreamChunk)) error
}
// ChatInput represents the input for chat operations
type ChatInput struct {
Model string
Messages []entity.Message
Tools []entity.Tool
Stream bool
}
// ChatOutput represents the output from chat operations
type ChatOutput struct {
Content string
Thinking string
ToolCalls []entity.ToolCall
}
// AgentRunner defines the interface for running AI agents
type AgentRunner interface {
RunSync(ctx context.Context, config interface{}, args []string) (RunResult, error)
RunStream(ctx context.Context, config interface{}, args []string, onLine func(string)) (StreamResult, error)
}
// RunResult represents the result of a synchronous agent run
type RunResult struct {
Code int
Stdout string
Stderr string
}
// StreamResult represents the result of a streaming agent run
type StreamResult struct {
Code int
Stderr string
}

381
pkg/infrastructure/env/env.go vendored Normal file
View File

@ -0,0 +1,381 @@
package env
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
type EnvSource map[string]string
type LoadedEnv struct {
AgentBin string
AgentNode string
AgentScript string
CommandShell string
Host string
Port int
RequiredKey string
DefaultModel string
Provider string
Force bool
ApproveMcps bool
StrictModel bool
Workspace string
TimeoutMs int
TLSCertPath string
TLSKeyPath string
SessionsLogPath string
ChatOnlyWorkspace bool
Verbose bool
MaxMode bool
ConfigDirs []string
MultiPort bool
WinCmdlineMax int
GeminiAccountDir string
GeminiBrowserVisible bool
GeminiMaxSessions int
}
type AgentCommand struct {
Command string
Args []string
Env map[string]string
WindowsVerbatimArguments bool
AgentScriptPath string
ConfigDir string
}
func getEnvVal(e EnvSource, names []string) string {
for _, name := range names {
if v, ok := e[name]; ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func envBool(e EnvSource, names []string, def bool) bool {
raw := getEnvVal(e, names)
if raw == "" {
return def
}
switch strings.ToLower(raw) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
return def
}
func envInt(e EnvSource, names []string, def int) int {
raw := getEnvVal(e, names)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
func normalizeModelId(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "auto"
}
parts := strings.Split(raw, "/")
last := parts[len(parts)-1]
if last == "" {
return "auto"
}
return last
}
func resolveAbs(raw, cwd string) string {
if raw == "" {
return ""
}
if filepath.IsAbs(raw) {
return raw
}
return filepath.Join(cwd, raw)
}
func isAuthenticatedAccountDir(dir string) bool {
data, err := os.ReadFile(filepath.Join(dir, "cli-config.json"))
if err != nil {
return false
}
var cfg struct {
AuthInfo *struct {
Email string `json:"email"`
} `json:"authInfo"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return false
}
return cfg.AuthInfo != nil && cfg.AuthInfo.Email != ""
}
func discoverAccountDirs(homeDir string) []string {
if homeDir == "" {
return nil
}
accountsDir := filepath.Join(homeDir, ".cursor-api-proxy", "accounts")
entries, err := os.ReadDir(accountsDir)
if err != nil {
return nil
}
var dirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(accountsDir, e.Name())
if isAuthenticatedAccountDir(dir) {
dirs = append(dirs, dir)
}
}
return dirs
}
func parseDotEnv(path string) EnvSource {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
m := make(EnvSource)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
return m
}
func OsEnvToMap(cwdHint ...string) EnvSource {
m := make(EnvSource)
for _, kv := range os.Environ() {
parts := strings.SplitN(kv, "=", 2)
if len(parts) == 2 {
m[parts[0]] = parts[1]
}
}
cwd := ""
if len(cwdHint) > 0 && cwdHint[0] != "" {
cwd = cwdHint[0]
} else {
cwd, _ = os.Getwd()
}
if dotenv := parseDotEnv(filepath.Join(cwd, ".env")); dotenv != nil {
for k, v := range dotenv {
if _, exists := m[k]; !exists {
m[k] = v
}
}
}
return m
}
func LoadEnvConfig(e EnvSource, cwd string) LoadedEnv {
if e == nil {
e = OsEnvToMap()
}
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
cwd = "."
}
}
host := getEnvVal(e, []string{"CURSOR_BRIDGE_HOST"})
if host == "" {
host = "127.0.0.1"
}
port := envInt(e, []string{"CURSOR_BRIDGE_PORT"}, 8765)
if port <= 0 {
port = 8765
}
home := getEnvVal(e, []string{"HOME", "USERPROFILE"})
sessionsLogPath := func() string {
if p := resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_SESSIONS_LOG"}), cwd); p != "" {
return p
}
if home != "" {
return filepath.Join(home, ".cursor-api-proxy", "sessions.log")
}
return filepath.Join(cwd, "sessions.log")
}()
var configDirs []string
if raw := getEnvVal(e, []string{"CURSOR_CONFIG_DIRS", "CURSOR_ACCOUNT_DIRS"}); raw != "" {
for _, d := range strings.Split(raw, ",") {
d = strings.TrimSpace(d)
if d != "" {
if p := resolveAbs(d, cwd); p != "" {
configDirs = append(configDirs, p)
}
}
}
}
if len(configDirs) == 0 {
configDirs = discoverAccountDirs(home)
}
winMax := envInt(e, []string{"CURSOR_BRIDGE_WIN_CMDLINE_MAX"}, 30000)
if winMax < 4096 {
winMax = 4096
}
if winMax > 32700 {
winMax = 32700
}
agentBin := getEnvVal(e, []string{"CURSOR_AGENT_BIN", "CURSOR_CLI_BIN", "CURSOR_CLI_PATH"})
if agentBin == "" {
agentBin = "agent"
}
commandShell := getEnvVal(e, []string{"COMSPEC"})
if commandShell == "" {
commandShell = "cmd.exe"
}
workspace := resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_WORKSPACE"}), cwd)
if workspace == "" {
workspace = cwd
}
geminiAccountDir := getEnvVal(e, []string{"GEMINI_ACCOUNT_DIR"})
if geminiAccountDir == "" {
geminiAccountDir = filepath.Join(home, ".cursor-api-proxy", "gemini-accounts")
} else {
geminiAccountDir = resolveAbs(geminiAccountDir, cwd)
}
return LoadedEnv{
AgentBin: agentBin,
AgentNode: getEnvVal(e, []string{"CURSOR_AGENT_NODE"}),
AgentScript: getEnvVal(e, []string{"CURSOR_AGENT_SCRIPT"}),
CommandShell: commandShell,
Host: host,
Port: port,
RequiredKey: getEnvVal(e, []string{"CURSOR_BRIDGE_API_KEY"}),
DefaultModel: normalizeModelId(getEnvVal(e, []string{"CURSOR_BRIDGE_DEFAULT_MODEL"})),
Provider: getEnvVal(e, []string{"CURSOR_BRIDGE_PROVIDER"}),
Force: envBool(e, []string{"CURSOR_BRIDGE_FORCE"}, false),
ApproveMcps: envBool(e, []string{"CURSOR_BRIDGE_APPROVE_MCPS"}, false),
StrictModel: envBool(e, []string{"CURSOR_BRIDGE_STRICT_MODEL"}, true),
Workspace: workspace,
TimeoutMs: envInt(e, []string{"CURSOR_BRIDGE_TIMEOUT_MS"}, 300000),
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),
Verbose: envBool(e, []string{"CURSOR_BRIDGE_VERBOSE"}, false),
MaxMode: envBool(e, []string{"CURSOR_BRIDGE_MAX_MODE"}, false),
ConfigDirs: configDirs,
MultiPort: envBool(e, []string{"CURSOR_BRIDGE_MULTI_PORT"}, false),
WinCmdlineMax: winMax,
GeminiAccountDir: geminiAccountDir,
GeminiBrowserVisible: envBool(e, []string{"GEMINI_BROWSER_VISIBLE"}, false),
GeminiMaxSessions: envInt(e, []string{"GEMINI_MAX_SESSIONS"}, 3),
}
}
func ResolveAgentCommand(cmd string, args []string, e EnvSource, cwd string) AgentCommand {
if e == nil {
e = OsEnvToMap()
}
loaded := LoadEnvConfig(e, cwd)
cloneEnv := func() map[string]string {
m := make(map[string]string, len(e))
for k, v := range e {
m[k] = v
}
return m
}
if runtime.GOOS == "windows" {
if loaded.AgentNode != "" && loaded.AgentScript != "" {
agentScriptPath := loaded.AgentScript
if !filepath.IsAbs(agentScriptPath) {
agentScriptPath = filepath.Join(cwd, agentScriptPath)
}
agentDir := filepath.Dir(agentScriptPath)
configDir := filepath.Join(agentDir, "..", "data", "config")
env2 := cloneEnv()
env2["CURSOR_INVOKED_AS"] = "agent.cmd"
ac := AgentCommand{
Command: loaded.AgentNode,
Args: append([]string{loaded.AgentScript}, args...),
Env: env2,
AgentScriptPath: agentScriptPath,
}
if _, err := os.Stat(filepath.Join(configDir, "cli-config.json")); err == nil {
ac.ConfigDir = configDir
}
return ac
}
if strings.HasSuffix(strings.ToLower(cmd), ".cmd") {
cmdResolved := cmd
if !filepath.IsAbs(cmd) {
cmdResolved = filepath.Join(cwd, cmd)
}
dir := filepath.Dir(cmdResolved)
nodeBin := filepath.Join(dir, "node.exe")
script := filepath.Join(dir, "index.js")
if _, err1 := os.Stat(nodeBin); err1 == nil {
if _, err2 := os.Stat(script); err2 == nil {
configDir := filepath.Join(dir, "..", "data", "config")
env2 := cloneEnv()
env2["CURSOR_INVOKED_AS"] = "agent.cmd"
ac := AgentCommand{
Command: nodeBin,
Args: append([]string{script}, args...),
Env: env2,
AgentScriptPath: script,
}
if _, err := os.Stat(filepath.Join(configDir, "cli-config.json")); err == nil {
ac.ConfigDir = configDir
}
return ac
}
}
quotedArgs := make([]string, len(args))
for i, a := range args {
if strings.Contains(a, " ") {
quotedArgs[i] = `"` + a + `"`
} else {
quotedArgs[i] = a
}
}
cmdLine := `""` + cmd + `" ` + strings.Join(quotedArgs, " ") + `"`
return AgentCommand{
Command: loaded.CommandShell,
Args: []string{"/d", "/s", "/c", cmdLine},
Env: cloneEnv(),
WindowsVerbatimArguments: true,
}
}
}
return AgentCommand{Command: cmd, Args: args, Env: cloneEnv()}
}

65
pkg/infrastructure/env/env_test.go vendored Normal file
View File

@ -0,0 +1,65 @@
package env
import "testing"
func TestLoadEnvConfigDefaults(t *testing.T) {
e := EnvSource{}
loaded := LoadEnvConfig(e, "/tmp")
if loaded.Host != "127.0.0.1" {
t.Errorf("expected 127.0.0.1, got %s", loaded.Host)
}
if loaded.Port != 8765 {
t.Errorf("expected 8765, got %d", loaded.Port)
}
if loaded.DefaultModel != "auto" {
t.Errorf("expected auto, got %s", loaded.DefaultModel)
}
if loaded.AgentBin != "agent" {
t.Errorf("expected agent, got %s", loaded.AgentBin)
}
if !loaded.StrictModel {
t.Error("expected strictModel=true by default")
}
}
func TestLoadEnvConfigOverride(t *testing.T) {
e := EnvSource{
"CURSOR_BRIDGE_HOST": "0.0.0.0",
"CURSOR_BRIDGE_PORT": "9000",
"CURSOR_BRIDGE_DEFAULT_MODEL": "gpt-4",
"CURSOR_AGENT_BIN": "/usr/local/bin/agent",
}
loaded := LoadEnvConfig(e, "/tmp")
if loaded.Host != "0.0.0.0" {
t.Errorf("expected 0.0.0.0, got %s", loaded.Host)
}
if loaded.Port != 9000 {
t.Errorf("expected 9000, got %d", loaded.Port)
}
if loaded.DefaultModel != "gpt-4" {
t.Errorf("expected gpt-4, got %s", loaded.DefaultModel)
}
if loaded.AgentBin != "/usr/local/bin/agent" {
t.Errorf("expected /usr/local/bin/agent, got %s", loaded.AgentBin)
}
}
func TestNormalizeModelID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"gpt-4", "gpt-4"},
{"openai/gpt-4", "gpt-4"},
{"", "auto"},
{" ", "auto"},
}
for _, tc := range tests {
got := normalizeModelId(tc.input)
if got != tc.want {
t.Errorf("normalizeModelId(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}

View File

@ -0,0 +1,50 @@
package httputil
import (
"encoding/json"
"io"
"net/http"
"regexp"
)
var bearerRe = regexp.MustCompile(`(?i)^Bearer\s+(.+)$`)
func ExtractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
m := bearerRe.FindStringSubmatch(h)
if m == nil {
return ""
}
return m[1]
}
func WriteJSON(w http.ResponseWriter, status int, body interface{}, extraHeaders map[string]string) {
for k, v := range extraHeaders {
w.Header().Set(k, v)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func WriteSSEHeaders(w http.ResponseWriter, extraHeaders map[string]string) {
for k, v := range extraHeaders {
w.Header().Set(k, v)
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(200)
}
func ReadBody(r *http.Request) (string, error) {
data, err := io.ReadAll(r.Body)
if err != nil {
return "", err
}
return string(data), nil
}

View File

@ -0,0 +1,50 @@
package httputil
import (
"net/http/httptest"
"testing"
)
func TestExtractBearerToken(t *testing.T) {
tests := []struct {
header string
want string
}{
{"Bearer mytoken123", "mytoken123"},
{"bearer MYTOKEN", "MYTOKEN"},
{"", ""},
{"Basic abc", ""},
{"Bearer ", ""},
}
for _, tc := range tests {
req := httptest.NewRequest("GET", "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
got := ExtractBearerToken(req)
if got != tc.want {
t.Errorf("ExtractBearerToken(%q) = %q, want %q", tc.header, got, tc.want)
}
}
}
func TestWriteJSON(t *testing.T) {
w := httptest.NewRecorder()
WriteJSON(w, 200, map[string]string{"ok": "true"}, nil)
if w.Code != 200 {
t.Errorf("expected 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("expected application/json, got %s", w.Header().Get("Content-Type"))
}
}
func TestWriteJSONWithExtraHeaders(t *testing.T) {
w := httptest.NewRecorder()
WriteJSON(w, 201, nil, map[string]string{"X-Custom": "value"})
if w.Header().Get("X-Custom") != "value" {
t.Errorf("expected X-Custom=value, got %s", w.Header().Get("X-Custom"))
}
}

View File

@ -0,0 +1,310 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/pkg/domain/entity"
)
const (
cReset = "\x1b[0m"
cBold = "\x1b[1m"
cDim = "\x1b[2m"
cCyan = "\x1b[36m"
cBCyan = "\x1b[1;96m"
cGreen = "\x1b[32m"
cBGreen = "\x1b[1;92m"
cYellow = "\x1b[33m"
cMagenta = "\x1b[35m"
cBMagenta = "\x1b[1;95m"
cRed = "\x1b[31m"
cGray = "\x1b[90m"
cWhite = "\x1b[97m"
)
var roleStyle = map[string]string{
"system": cYellow,
"user": cCyan,
"assistant": cGreen,
}
var roleEmoji = map[string]string{
"system": "🔧",
"user": "👤",
"assistant": "🤖",
}
func ts() string {
return cGray + time.Now().UTC().Format("15:04:05") + cReset
}
func tsDate() string {
return cGray + time.Now().UTC().Format("2006-01-02 15:04:05") + cReset
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
head := int(float64(max) * 0.6)
tail := max - head
omitted := len(s) - head - tail
return s[:head] + fmt.Sprintf("%s … (%d chars omitted) … ", cDim, omitted) + s[len(s)-tail:] + cReset
}
func hr(ch string, length int) string {
return cGray + strings.Repeat(ch, length) + cReset
}
type TrafficMessage struct {
Role string
Content string
}
func LogDebug(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
fmt.Printf("%s %s[DEBUG]%s %s\n", ts(), cGray, cReset, msg)
}
func LogServerStart(version, scheme, host string, port int, cfg config.BridgeConfig) {
provider := cfg.Provider
if provider == "" {
provider = "cursor"
}
fmt.Printf("\n%s%s╔══════════════════════════════════════════╗%s\n", cBold, cBCyan, cReset)
fmt.Printf("%s%s cursor-api-proxy %sv%s%s%s%s ready%s\n",
cBold, cBCyan, cReset, cBold, cWhite, version, cBCyan, cReset)
fmt.Printf("%s%s╚══════════════════════════════════════════╝%s\n\n", cBold, cBCyan, cReset)
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
fmt.Printf(" %s●%s listening %s%s%s\n", cBGreen, cReset, cBold, url, cReset)
fmt.Printf(" %s▸%s provider %s%s%s\n", cCyan, cReset, cBold, provider, cReset)
fmt.Printf(" %s▸%s agent %s%s%s\n", cCyan, cReset, cDim, cfg.AgentBin, cReset)
fmt.Printf(" %s▸%s workspace %s%s%s\n", cCyan, cReset, cDim, cfg.Workspace, cReset)
fmt.Printf(" %s▸%s model %s%s%s\n", cCyan, cReset, cDim, cfg.DefaultModel, cReset)
fmt.Printf(" %s▸%s mode %s%s%s\n", cCyan, cReset, cDim, cfg.Mode, cReset)
fmt.Printf(" %s▸%s timeout %s%d ms%s\n", cCyan, cReset, cDim, cfg.TimeoutMs, cReset)
// 顯示 Gemini Web Provider 相關設定
if provider == "gemini-web" {
fmt.Printf(" %s▸%s gemini-dir %s%s%s\n", cCyan, cReset, cDim, cfg.GeminiAccountDir, cReset)
fmt.Printf(" %s▸%s max-sess %s%d%s\n", cCyan, cReset, cDim, cfg.GeminiMaxSessions, cReset)
}
flags := []string{}
if cfg.Force {
flags = append(flags, "force")
}
if cfg.ApproveMcps {
flags = append(flags, "approve-mcps")
}
if cfg.MaxMode {
flags = append(flags, "max-mode")
}
if cfg.Verbose {
flags = append(flags, "verbose")
}
if cfg.ChatOnlyWorkspace {
flags = append(flags, "chat-only")
}
if cfg.RequiredKey != "" {
flags = append(flags, "api-key-required")
}
if len(flags) > 0 {
fmt.Printf(" %s▸%s flags %s%s%s\n", cCyan, cReset, cYellow, strings.Join(flags, " · "), cReset)
}
if len(cfg.ConfigDirs) > 0 {
fmt.Printf(" %s▸%s pool %s%d accounts%s\n", cCyan, cReset, cBGreen, len(cfg.ConfigDirs), cReset)
}
fmt.Println()
}
func LogShutdown(sig string) {
fmt.Printf("\n%s %s⊘ %s received — shutting down gracefully…%s\n", tsDate(), cYellow, sig, cReset)
}
func LogRequestStart(method, pathname, model string, timeoutMs int, isStream bool) {
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
if isStream {
modeTag = fmt.Sprintf("%s⚡ stream%s", cBCyan, cReset)
}
fmt.Printf("%s %s▶%s %s %s %s timeout:%dms %s\n",
ts(), cBCyan, cReset, method, pathname, model, timeoutMs, modeTag)
}
func LogRequestDone(method, pathname, model string, latencyMs int64, code int) {
statusColor := cBGreen
if code != 0 {
statusColor = cRed
}
fmt.Printf("%s %s■%s %s %s %s %s%dms exit:%d%s\n",
ts(), statusColor, cReset, method, pathname, model, cDim, latencyMs, code, cReset)
}
func LogRequestTimeout(method, pathname, model string, timeoutMs int) {
fmt.Printf("%s %s⏱%s %s %s %s %stimed-out after %dms%s\n",
ts(), cRed, cReset, method, pathname, model, cRed, timeoutMs, cReset)
}
func LogClientDisconnect(method, pathname, model string, latencyMs int64) {
fmt.Printf("%s %s⚡%s %s %s %s %sclient disconnected after %dms%s\n",
ts(), cYellow, cReset, method, pathname, model, cYellow, latencyMs, cReset)
}
func LogStreamChunk(model string, text string, chunkNum int) {
preview := truncate(strings.ReplaceAll(text, "\n", "↵ "), 120)
fmt.Printf("%s %s▸%s #%d %s%s%s\n",
ts(), cDim, cReset, chunkNum, cWhite, preview, cReset)
}
func LogRawLine(line string) {
preview := truncate(strings.ReplaceAll(line, "\n", "↵ "), 200)
fmt.Printf("%s %s│%s %sraw%s %s\n",
ts(), cGray, cReset, cDim, cReset, preview)
}
func LogIncoming(method, pathname, remoteAddress string) {
methodColor := cBCyan
switch method {
case "POST":
methodColor = cBMagenta
case "GET":
methodColor = cBCyan
case "DELETE":
methodColor = cRed
}
fmt.Printf("%s %s%s%s%s %s%s%s %s(%s)%s\n",
ts(),
methodColor, cBold, method, cReset,
cWhite, pathname, cReset,
cDim, remoteAddress, cReset,
)
}
func LogAccountAssigned(configDir string) {
if configDir == "" {
return
}
name := filepath.Base(configDir)
fmt.Printf("%s %s→%s account %s%s%s\n", ts(), cBCyan, cReset, cBold, name, cReset)
}
func LogAccountStats(verbose bool, stats []entity.AccountStat) {
if !verbose || len(stats) == 0 {
return
}
now := time.Now().UnixMilli()
fmt.Printf("%s┌─ Account Stats %s┐%s\n", cGray, strings.Repeat("─", 44), cReset)
for _, s := range stats {
name := fmt.Sprintf("%-20s", filepath.Base(s.ConfigDir))
active := fmt.Sprintf("%sactive:0%s", cDim, cReset)
if s.ActiveRequests > 0 {
active = fmt.Sprintf("%sactive:%d%s", cBCyan, s.ActiveRequests, cReset)
}
total := fmt.Sprintf("total:%s%d%s", cBold, s.TotalRequests, cReset)
ok := fmt.Sprintf("%sok:%d%s", cGreen, s.TotalSuccess, cReset)
errStr := fmt.Sprintf("%serr:0%s", cDim, cReset)
if s.TotalErrors > 0 {
errStr = fmt.Sprintf("%serr:%d%s", cRed, s.TotalErrors, cReset)
}
rl := fmt.Sprintf("%srl:0%s", cDim, cReset)
if s.TotalRateLimits > 0 {
rl = fmt.Sprintf("%srl:%d%s", cYellow, s.TotalRateLimits, cReset)
}
avg := "avg:-"
if s.TotalRequests > 0 {
avg = fmt.Sprintf("avg:%dms", s.TotalLatencyMs/int64(s.TotalRequests))
}
status := fmt.Sprintf("%s✓%s", cGreen, cReset)
if s.IsRateLimited {
recovers := time.UnixMilli(s.RateLimitUntil).UTC().Format(time.RFC3339)
_ = now
status = fmt.Sprintf("%s⛔ rate-limited (recovers %s)%s", cRed, recovers, cReset)
}
fmt.Printf(" %s%s%s %s %s %s %s %s %s%s%s %s\n",
cBold, name, cReset, active, total, ok, errStr, rl, cDim, avg, cReset, status)
}
fmt.Printf("%s└%s┘%s\n", cGray, strings.Repeat("─", 60), cReset)
}
func LogTrafficRequest(verbose bool, model string, messages []TrafficMessage, isStream bool) {
if !verbose {
return
}
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
if isStream {
modeTag = fmt.Sprintf("%s⚡ stream%s", cBCyan, cReset)
}
modelStr := fmt.Sprintf("%s✦ %s%s", cBMagenta, model, cReset)
fmt.Println(hr("─", 60))
fmt.Printf("%s 📤 %s%sREQUEST%s %s %s\n", ts(), cBCyan, cBold, cReset, modelStr, modeTag)
for _, m := range messages {
roleColor := cWhite
if c, ok := roleStyle[m.Role]; ok {
roleColor = c
}
emoji := "💬"
if e, ok := roleEmoji[m.Role]; ok {
emoji = e
}
label := fmt.Sprintf("%s%s[%s]%s", roleColor, cBold, m.Role, cReset)
charCount := fmt.Sprintf("%s(%d chars)%s", cDim, len(m.Content), cReset)
preview := truncate(strings.ReplaceAll(m.Content, "\n", "↵ "), 280)
fmt.Printf(" %s %s %s\n", emoji, label, charCount)
fmt.Printf(" %s%s%s\n", cDim, preview, cReset)
}
}
func LogTrafficResponse(verbose bool, model, text string, isStream bool) {
if !verbose {
return
}
modeTag := fmt.Sprintf("%ssync%s", cDim, cReset)
if isStream {
modeTag = fmt.Sprintf("%s⚡ stream%s", cBGreen, cReset)
}
modelStr := fmt.Sprintf("%s✦ %s%s", cBMagenta, model, cReset)
charCount := fmt.Sprintf("%s%d%s%s chars%s", cBold, len(text), cReset, cDim, cReset)
preview := truncate(strings.ReplaceAll(text, "\n", "↵ "), 480)
fmt.Printf("%s 📥 %s%sRESPONSE%s %s %s %s\n", ts(), cBGreen, cBold, cReset, modelStr, modeTag, charCount)
fmt.Printf(" 🤖 %s%s%s\n", cGreen, preview, cReset)
fmt.Println(hr("─", 60))
}
func AppendSessionLine(logPath, method, pathname, remoteAddress string, statusCode int) {
line := fmt.Sprintf("%s %s %s %s %d\n", time.Now().UTC().Format(time.RFC3339), method, pathname, remoteAddress, statusCode)
dir := filepath.Dir(logPath)
if err := os.MkdirAll(dir, 0755); err == nil {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err == nil {
_, _ = f.WriteString(line)
f.Close()
}
}
}
func LogTruncation(originalLen, finalLen int) {
fmt.Printf("%s %s⚠ prompt truncated%s %s(%d → %d chars, tail preserved)%s\n",
ts(), cYellow, cReset, cDim, originalLen, finalLen, cReset)
}
func LogAgentError(logPath, method, pathname, remoteAddress string, exitCode int, stderr string) string {
errMsg := fmt.Sprintf("Cursor CLI failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
fmt.Fprintf(os.Stderr, "%s %s✗ agent error%s %s%s%s\n", ts(), cRed, cReset, cDim, errMsg, cReset)
truncated := strings.TrimSpace(stderr)
if len(truncated) > 200 {
truncated = truncated[:200]
}
truncated = strings.ReplaceAll(truncated, "\n", " ")
line := fmt.Sprintf("%s ERROR %s %s %s agent_exit_%d %s\n",
time.Now().UTC().Format(time.RFC3339), method, pathname, remoteAddress, exitCode, truncated)
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
_, _ = f.WriteString(line)
f.Close()
}
return errMsg
}

View File

@ -0,0 +1,110 @@
package parser
import "encoding/json"
type StreamParser func(line string)
type Parser struct {
Parse StreamParser
Flush func()
}
// CreateStreamParser 建立串流解析器(向後相容,不傳遞 thinking
func CreateStreamParser(onText func(string), onDone func()) Parser {
return CreateStreamParserWithThinking(onText, nil, onDone)
}
// CreateStreamParserWithThinking 建立串流解析器,支援思考過程輸出。
// onThinking 可為 nil表示忽略思考過程。
func CreateStreamParserWithThinking(onText func(string), onThinking func(string), onDone func()) Parser {
// accumulated 是所有已輸出內容的串接
accumulatedText := ""
accumulatedThinking := ""
done := false
parse := func(line string) {
if done {
return
}
var obj struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message *struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"content"`
} `json:"message"`
}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return
}
if obj.Type == "assistant" && obj.Message != nil {
fullText := ""
fullThinking := ""
for _, p := range obj.Message.Content {
switch p.Type {
case "text":
if p.Text != "" {
fullText += p.Text
}
case "thinking":
if p.Thinking != "" {
fullThinking += p.Thinking
}
}
}
// 處理思考過程(不因去重而 return避免跳過同行的文字內容
if onThinking != nil && fullThinking != "" && fullThinking != accumulatedThinking {
// 增量模式:新內容以 accumulated 為前綴
if len(fullThinking) >= len(accumulatedThinking) && fullThinking[:len(accumulatedThinking)] == accumulatedThinking {
delta := fullThinking[len(accumulatedThinking):]
if delta != "" {
onThinking(delta)
}
accumulatedThinking = fullThinking
} else {
// 獨立片段:直接輸出,但 accumulated 要串接
onThinking(fullThinking)
accumulatedThinking = accumulatedThinking + fullThinking
}
}
// 處理一般文字
if fullText == "" || fullText == accumulatedText {
return
}
// 增量模式:新內容以 accumulated 為前綴
if len(fullText) >= len(accumulatedText) && fullText[:len(accumulatedText)] == accumulatedText {
delta := fullText[len(accumulatedText):]
if delta != "" {
onText(delta)
}
accumulatedText = fullText
} else {
// 獨立片段:直接輸出,但 accumulated 要串接
onText(fullText)
accumulatedText = accumulatedText + fullText
}
}
if obj.Type == "result" && obj.Subtype == "success" {
done = true
onDone()
}
}
flush := func() {
if !done {
done = true
onDone()
}
}
return Parser{Parse: parse, Flush: flush}
}

View File

@ -0,0 +1,304 @@
package parser
import (
"encoding/json"
"testing"
)
func makeAssistantLine(text string) string {
obj := map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{
"content": []map[string]interface{}{
{"type": "text", "text": text},
},
},
}
b, _ := json.Marshal(obj)
return string(b)
}
func makeResultLine() string {
b, _ := json.Marshal(map[string]string{"type": "result", "subtype": "success"})
return string(b)
}
func TestStreamParserFragmentMode(t *testing.T) {
// cursor --stream-partial-output 模式:每個訊息是獨立 token fragment
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
p.Parse(makeAssistantLine("你"))
p.Parse(makeAssistantLine("好!有"))
p.Parse(makeAssistantLine("什"))
p.Parse(makeAssistantLine("麼"))
if len(texts) != 4 {
t.Fatalf("expected 4 fragments, got %d: %v", len(texts), texts)
}
if texts[0] != "你" || texts[1] != "好!有" || texts[2] != "什" || texts[3] != "麼" {
t.Fatalf("unexpected texts: %v", texts)
}
}
func TestStreamParserDeduplicatesFinalFullText(t *testing.T) {
// 最後一個訊息是完整的累積文字,應被跳過(去重)
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
p.Parse(makeAssistantLine("Hello"))
p.Parse(makeAssistantLine(" world"))
// 最後一個是完整累積文字,應被去重
p.Parse(makeAssistantLine("Hello world"))
if len(texts) != 2 {
t.Fatalf("expected 2 fragments (final full text deduplicated), got %d: %v", len(texts), texts)
}
if texts[0] != "Hello" || texts[1] != " world" {
t.Fatalf("unexpected texts: %v", texts)
}
}
func TestStreamParserCallsOnDone(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse(makeResultLine())
if doneCount != 1 {
t.Fatalf("expected onDone called once, got %d", doneCount)
}
if len(texts) != 0 {
t.Fatalf("expected no text, got %v", texts)
}
}
func TestStreamParserIgnoresLinesAfterDone(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse(makeResultLine())
p.Parse(makeAssistantLine("late"))
if len(texts) != 0 {
t.Fatalf("expected no text after done, got %v", texts)
}
if doneCount != 1 {
t.Fatalf("expected onDone called once, got %d", doneCount)
}
}
func TestStreamParserIgnoresNonAssistantLines(t *testing.T) {
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
b1, _ := json.Marshal(map[string]interface{}{"type": "user", "message": map[string]interface{}{}})
p.Parse(string(b1))
b2, _ := json.Marshal(map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{"content": []interface{}{}},
})
p.Parse(string(b2))
p.Parse(`{"type":"assistant","message":{"content":[{"type":"code","text":"x"}]}}`)
if len(texts) != 0 {
t.Fatalf("expected no texts, got %v", texts)
}
}
func TestStreamParserIgnoresParseErrors(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse("not json")
p.Parse("{")
p.Parse("")
if len(texts) != 0 || doneCount != 0 {
t.Fatalf("expected nothing, got texts=%v done=%d", texts, doneCount)
}
}
func TestStreamParserJoinsMultipleTextParts(t *testing.T) {
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
obj := map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{
"content": []map[string]interface{}{
{"type": "text", "text": "Hello"},
{"type": "text", "text": " "},
{"type": "text", "text": "world"},
},
},
}
b, _ := json.Marshal(obj)
p.Parse(string(b))
if len(texts) != 1 || texts[0] != "Hello world" {
t.Fatalf("expected ['Hello world'], got %v", texts)
}
}
func TestStreamParserFlushTriggersDone(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse(makeAssistantLine("Hello"))
// agent 結束但沒有 result/success手動 flush
p.Flush()
if doneCount != 1 {
t.Fatalf("expected onDone called once after Flush, got %d", doneCount)
}
// 再 flush 不應重複觸發
p.Flush()
if doneCount != 1 {
t.Fatalf("expected onDone called only once, got %d", doneCount)
}
}
func TestStreamParserFlushAfterDoneIsNoop(t *testing.T) {
doneCount := 0
p := CreateStreamParser(
func(text string) {},
func() { doneCount++ },
)
p.Parse(makeResultLine())
p.Flush()
if doneCount != 1 {
t.Fatalf("expected onDone called once, got %d", doneCount)
}
}
func makeThinkingLine(thinking string) string {
obj := map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{
"content": []map[string]interface{}{
{"type": "thinking", "thinking": thinking},
},
},
}
b, _ := json.Marshal(obj)
return string(b)
}
func makeThinkingAndTextLine(thinking, text string) string {
obj := map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{
"content": []map[string]interface{}{
{"type": "thinking", "thinking": thinking},
{"type": "text", "text": text},
},
},
}
b, _ := json.Marshal(obj)
return string(b)
}
func TestStreamParserWithThinkingCallsOnThinking(t *testing.T) {
var texts []string
var thinkings []string
p := CreateStreamParserWithThinking(
func(text string) { texts = append(texts, text) },
func(thinking string) { thinkings = append(thinkings, thinking) },
func() {},
)
p.Parse(makeThinkingLine("思考中..."))
p.Parse(makeAssistantLine("回答"))
if len(thinkings) != 1 || thinkings[0] != "思考中..." {
t.Fatalf("expected thinkings=['思考中...'], got %v", thinkings)
}
if len(texts) != 1 || texts[0] != "回答" {
t.Fatalf("expected texts=['回答'], got %v", texts)
}
}
func TestStreamParserWithThinkingNilOnThinkingIgnoresThinking(t *testing.T) {
var texts []string
p := CreateStreamParserWithThinking(
func(text string) { texts = append(texts, text) },
nil,
func() {},
)
p.Parse(makeThinkingLine("忽略的思考"))
p.Parse(makeAssistantLine("文字"))
if len(texts) != 1 || texts[0] != "文字" {
t.Fatalf("expected texts=['文字'], got %v", texts)
}
}
func TestStreamParserWithThinkingDeduplication(t *testing.T) {
var thinkings []string
p := CreateStreamParserWithThinking(
func(text string) {},
func(thinking string) { thinkings = append(thinkings, thinking) },
func() {},
)
p.Parse(makeThinkingLine("A"))
p.Parse(makeThinkingLine("B"))
// 重複的完整思考,應被跳過
p.Parse(makeThinkingLine("AB"))
if len(thinkings) != 2 || thinkings[0] != "A" || thinkings[1] != "B" {
t.Fatalf("expected thinkings=['A','B'], got %v", thinkings)
}
}
// TestStreamParserThinkingDuplicateButTextStillEmitted 驗證 bug 修復:
// 當 thinking 重複(去重跳過)但同一行有 text 時text 仍必須輸出。
func TestStreamParserThinkingDuplicateButTextStillEmitted(t *testing.T) {
var texts []string
var thinkings []string
p := CreateStreamParserWithThinking(
func(text string) { texts = append(texts, text) },
func(thinking string) { thinkings = append(thinkings, thinking) },
func() {},
)
// 第一行thinking="思考中" + textthinking 為新增,兩者都應輸出)
p.Parse(makeThinkingAndTextLine("思考中", "第一段"))
// 第二行thinking 與上一行相同(去重),但 text 是新的text 仍應輸出
p.Parse(makeThinkingAndTextLine("思考中", "第二段"))
if len(thinkings) != 1 || thinkings[0] != "思考中" {
t.Fatalf("expected thinkings=['思考中'], got %v", thinkings)
}
if len(texts) != 2 || texts[0] != "第一段" || texts[1] != "第二段" {
t.Fatalf("expected texts=['第一段','第二段'], got %v", texts)
}
}

View File

@ -0,0 +1,21 @@
//go:build !windows
package process
import (
"os/exec"
"syscall"
)
func killProcessGroup(c *exec.Cmd) error {
if c.Process == nil {
return nil
}
// 殺死整個 process group負號表示 group
pgid, err := syscall.Getpgid(c.Process.Pid)
if err == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
}
// 同時也 kill 主程序,以防萬一
return c.Process.Kill()
}

View File

@ -0,0 +1,14 @@
//go:build windows
package process
import (
"os/exec"
)
func killProcessGroup(c *exec.Cmd) error {
if c.Process == nil {
return nil
}
return c.Process.Kill()
}

View File

@ -0,0 +1,283 @@
package process_test
import (
"context"
"cursor-api-proxy/pkg/infrastructure/process"
"os"
"testing"
"time"
)
// sh 是跨平台 shell 執行小 script 的輔助函式
func sh(t *testing.T, script string, opts process.RunOptions) (process.RunResult, error) {
t.Helper()
return process.Run("sh", []string{"-c", script}, opts)
}
func TestRun_StdoutAndStderr(t *testing.T) {
result, err := sh(t, "echo hello; echo world >&2", process.RunOptions{})
if err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Errorf("Code = %d, want 0", result.Code)
}
if result.Stdout != "hello\n" {
t.Errorf("Stdout = %q, want %q", result.Stdout, "hello\n")
}
if result.Stderr != "world\n" {
t.Errorf("Stderr = %q, want %q", result.Stderr, "world\n")
}
}
func TestRun_BasicSpawn(t *testing.T) {
result, err := sh(t, "printf ok", process.RunOptions{})
if err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Errorf("Code = %d, want 0", result.Code)
}
if result.Stdout != "ok" {
t.Errorf("Stdout = %q, want ok", result.Stdout)
}
}
func TestRun_ConfigDir_Propagated(t *testing.T) {
result, err := process.Run("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`},
process.RunOptions{ConfigDir: "/test/account/dir"})
if err != nil {
t.Fatal(err)
}
if result.Stdout != "/test/account/dir" {
t.Errorf("Stdout = %q, want /test/account/dir", result.Stdout)
}
}
func TestRun_ConfigDir_Absent(t *testing.T) {
// 確保沒有殘留的環境變數
_ = os.Unsetenv("CURSOR_CONFIG_DIR")
result, err := process.Run("sh", []string{"-c", `printf "${CURSOR_CONFIG_DIR:-unset}"`},
process.RunOptions{})
if err != nil {
t.Fatal(err)
}
if result.Stdout != "unset" {
t.Errorf("Stdout = %q, want unset", result.Stdout)
}
}
func TestRun_NonZeroExit(t *testing.T) {
result, err := sh(t, "exit 42", process.RunOptions{})
if err != nil {
t.Fatal(err)
}
if result.Code != 42 {
t.Errorf("Code = %d, want 42", result.Code)
}
}
func TestRun_Timeout(t *testing.T) {
start := time.Now()
result, err := sh(t, "sleep 30", process.RunOptions{TimeoutMs: 300})
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if result.Code == 0 {
t.Error("expected non-zero exit code after timeout")
}
if elapsed > 2*time.Second {
t.Errorf("elapsed %v, want < 2s", elapsed)
}
}
func TestRunStreaming_OnLine(t *testing.T) {
var lines []string
result, err := process.RunStreaming("sh", []string{"-c", "printf 'a\nb\nc\n'"},
process.RunStreamingOptions{
OnLine: func(line string) { lines = append(lines, line) },
})
if err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Errorf("Code = %d, want 0", result.Code)
}
if len(lines) != 3 {
t.Errorf("got %d lines, want 3: %v", len(lines), lines)
}
if lines[0] != "a" || lines[1] != "b" || lines[2] != "c" {
t.Errorf("lines = %v, want [a b c]", lines)
}
}
func TestRunStreaming_FlushFinalLine(t *testing.T) {
var lines []string
result, err := process.RunStreaming("sh", []string{"-c", "printf tail"},
process.RunStreamingOptions{
OnLine: func(line string) { lines = append(lines, line) },
})
if err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Errorf("Code = %d, want 0", result.Code)
}
if len(lines) != 1 {
t.Errorf("got %d lines, want 1: %v", len(lines), lines)
}
if lines[0] != "tail" {
t.Errorf("lines[0] = %q, want tail", lines[0])
}
}
func TestRunStreaming_ConfigDir(t *testing.T) {
var lines []string
_, err := process.RunStreaming("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`},
process.RunStreamingOptions{
RunOptions: process.RunOptions{ConfigDir: "/my/config/dir"},
OnLine: func(line string) { lines = append(lines, line) },
})
if err != nil {
t.Fatal(err)
}
if len(lines) != 1 || lines[0] != "/my/config/dir" {
t.Errorf("lines = %v, want [/my/config/dir]", lines)
}
}
func TestRunStreaming_Stderr(t *testing.T) {
result, err := process.RunStreaming("sh", []string{"-c", "echo err-output >&2"},
process.RunStreamingOptions{OnLine: func(string) {}})
if err != nil {
t.Fatal(err)
}
if result.Stderr == "" {
t.Error("expected stderr to contain output")
}
}
func TestRunStreaming_Timeout(t *testing.T) {
start := time.Now()
result, err := process.RunStreaming("sh", []string{"-c", "sleep 30"},
process.RunStreamingOptions{
RunOptions: process.RunOptions{TimeoutMs: 300},
OnLine: func(string) {},
})
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if result.Code == 0 {
t.Error("expected non-zero exit code after timeout")
}
if elapsed > 2*time.Second {
t.Errorf("elapsed %v, want < 2s", elapsed)
}
}
func TestRunStreaming_Concurrent(t *testing.T) {
var lines1, lines2 []string
done := make(chan struct{}, 2)
run := func(label string, target *[]string) {
process.RunStreaming("sh", []string{"-c", "printf '" + label + "'"},
process.RunStreamingOptions{
OnLine: func(line string) { *target = append(*target, line) },
})
done <- struct{}{}
}
go run("stream1", &lines1)
go run("stream2", &lines2)
<-done
<-done
if len(lines1) != 1 || lines1[0] != "stream1" {
t.Errorf("lines1 = %v, want [stream1]", lines1)
}
if len(lines2) != 1 || lines2[0] != "stream2" {
t.Errorf("lines2 = %v, want [stream2]", lines2)
}
}
func TestRunStreaming_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
start := time.Now()
done := make(chan struct{})
go func() {
process.RunStreaming("sh", []string{"-c", "sleep 30"},
process.RunStreamingOptions{
RunOptions: process.RunOptions{Ctx: ctx},
OnLine: func(string) {},
})
close(done)
}()
time.AfterFunc(100*time.Millisecond, cancel)
<-done
elapsed := time.Since(start)
if elapsed > 2*time.Second {
t.Errorf("elapsed %v, want < 2s", elapsed)
}
}
func TestRun_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
start := time.Now()
done := make(chan process.RunResult, 1)
go func() {
r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx})
done <- r
}()
time.AfterFunc(100*time.Millisecond, cancel)
result := <-done
elapsed := time.Since(start)
if result.Code == 0 {
t.Error("expected non-zero exit code after cancel")
}
if elapsed > 2*time.Second {
t.Errorf("elapsed %v, want < 2s", elapsed)
}
}
func TestRun_AlreadyCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // 已取消
start := time.Now()
result, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx})
elapsed := time.Since(start)
if result.Code == 0 {
t.Error("expected non-zero exit code")
}
if elapsed > 2*time.Second {
t.Errorf("elapsed %v, want < 2s", elapsed)
}
}
func TestKillAllChildProcesses(t *testing.T) {
done := make(chan process.RunResult, 1)
go func() {
r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{})
done <- r
}()
time.Sleep(80 * time.Millisecond)
process.KillAllChildProcesses()
result := <-done
if result.Code == 0 {
t.Error("expected non-zero exit code after kill")
}
// 再次呼叫不應 panic
process.KillAllChildProcesses()
}

View File

@ -0,0 +1,250 @@
package process
import (
"bufio"
"context"
"cursor-api-proxy/pkg/infrastructure/env"
"fmt"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
type RunResult struct {
Code int
Stdout string
Stderr string
}
type RunOptions struct {
Cwd string
TimeoutMs int
MaxMode bool
ConfigDir string
Ctx context.Context
}
type RunStreamingOptions struct {
RunOptions
OnLine func(line string)
}
// ─── Global child process registry ──────────────────────────────────────────
var (
activeMu sync.Mutex
activeChildren []*exec.Cmd
)
func registerChild(c *exec.Cmd) {
activeMu.Lock()
activeChildren = append(activeChildren, c)
activeMu.Unlock()
}
func unregisterChild(c *exec.Cmd) {
activeMu.Lock()
for i, ch := range activeChildren {
if ch == c {
activeChildren = append(activeChildren[:i], activeChildren[i+1:]...)
break
}
}
activeMu.Unlock()
}
func KillAllChildProcesses() {
activeMu.Lock()
all := make([]*exec.Cmd, len(activeChildren))
copy(all, activeChildren)
activeChildren = nil
activeMu.Unlock()
for _, c := range all {
killProcessGroup(c)
}
}
// ─── Spawn ────────────────────────────────────────────────────────────────
func spawnChild(cmdStr string, args []string, opts *RunOptions, maxModeFn func(scriptPath, configDir string)) *exec.Cmd {
envSrc := env.OsEnvToMap()
resolved := env.ResolveAgentCommand(cmdStr, args, envSrc, opts.Cwd)
if opts.MaxMode && maxModeFn != nil {
maxModeFn(resolved.AgentScriptPath, opts.ConfigDir)
}
envMap := make(map[string]string, len(resolved.Env))
for k, v := range resolved.Env {
envMap[k] = v
}
if opts.ConfigDir != "" {
envMap["CURSOR_CONFIG_DIR"] = opts.ConfigDir
} else if resolved.ConfigDir != "" {
if _, exists := envMap["CURSOR_CONFIG_DIR"]; !exists {
envMap["CURSOR_CONFIG_DIR"] = resolved.ConfigDir
}
}
envSlice := make([]string, 0, len(envMap))
for k, v := range envMap {
envSlice = append(envSlice, k+"="+v)
}
ctx := opts.Ctx
if ctx == nil {
ctx = context.Background()
}
// 使用 WaitDelay 確保 context cancel 後子程序 goroutine 能及時退出
c := exec.CommandContext(ctx, resolved.Command, resolved.Args...)
c.Dir = opts.Cwd
c.Env = envSlice
// 設定新的 process group使 kill 能傳遞給所有子孫程序
c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// WaitDelaycontext cancel 後額外等待這麼久再強制關閉 pipes
c.WaitDelay = 5 * time.Second
// Cancel 函式:殺死整個 process group
c.Cancel = func() error {
return killProcessGroup(c)
}
return c
}
// MaxModeFn is set by the agent package to avoid import cycle.
var MaxModeFn func(agentScriptPath, configDir string)
func Run(cmdStr string, args []string, opts RunOptions) (RunResult, error) {
ctx := opts.Ctx
var cancel context.CancelFunc
if opts.TimeoutMs > 0 {
if ctx == nil {
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond)
} else {
ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond)
}
defer cancel()
opts.Ctx = ctx
} else if ctx == nil {
opts.Ctx = context.Background()
}
c := spawnChild(cmdStr, args, &opts, MaxModeFn)
var stdoutBuf, stderrBuf strings.Builder
c.Stdout = &stdoutBuf
c.Stderr = &stderrBuf
if err := c.Start(); err != nil {
// context 已取消或命令找不到時
if opts.Ctx != nil && opts.Ctx.Err() != nil {
return RunResult{Code: -1}, nil
}
if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") {
return RunResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr)
}
return RunResult{}, err
}
registerChild(c)
defer unregisterChild(c)
err := c.Wait()
code := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
if code == 0 {
code = -1
}
} else {
// context cancelled or killed — return -1 but no error
return RunResult{Code: -1, Stdout: stdoutBuf.String(), Stderr: stderrBuf.String()}, nil
}
}
return RunResult{
Code: code,
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
}, nil
}
type StreamResult struct {
Code int
Stderr string
}
func RunStreaming(cmdStr string, args []string, opts RunStreamingOptions) (StreamResult, error) {
ctx := opts.Ctx
var cancel context.CancelFunc
if opts.TimeoutMs > 0 {
if ctx == nil {
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond)
} else {
ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond)
}
defer cancel()
opts.RunOptions.Ctx = ctx
} else if opts.RunOptions.Ctx == nil {
opts.RunOptions.Ctx = context.Background()
}
c := spawnChild(cmdStr, args, &opts.RunOptions, MaxModeFn)
stdoutPipe, err := c.StdoutPipe()
if err != nil {
return StreamResult{}, err
}
stderrPipe, err := c.StderrPipe()
if err != nil {
return StreamResult{}, err
}
if err := c.Start(); err != nil {
if strings.Contains(err.Error(), "exec: ") || strings.Contains(err.Error(), "no such file") {
return StreamResult{}, fmt.Errorf("command not found: %s. Install Cursor CLI (agent) or set CURSOR_AGENT_BIN to its path", cmdStr)
}
return StreamResult{}, err
}
registerChild(c)
defer unregisterChild(c)
var stderrBuf strings.Builder
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stdoutPipe)
scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
opts.OnLine(line)
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stderrPipe)
scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024)
for scanner.Scan() {
stderrBuf.WriteString(scanner.Text())
stderrBuf.WriteString("\n")
}
}()
wg.Wait()
err = c.Wait()
code := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
if code == 0 {
code = -1
}
}
}
return StreamResult{Code: code, Stderr: stderrBuf.String()}, nil
}

View File

@ -0,0 +1,181 @@
package winlimit
import (
"cursor-api-proxy/pkg/infrastructure/env"
"runtime"
)
const WinPromptOmissionPrefix = "[Earlier messages omitted: Windows command-line length limit.]\n\n"
const LinuxPromptOmissionPrefix = "[Earlier messages omitted: Linux ARG_MAX command-line length limit.]\n\n"
// safeLinuxArgMax returns a conservative estimate of ARG_MAX on Linux.
// The actual limit is typically 2MB; we use 1.5MB to leave room for env vars.
func safeLinuxArgMax() int {
return 1536 * 1024
}
type FitPromptResult struct {
OK bool
Args []string
Truncated bool
OriginalLength int
FinalPromptLength int
Error string
}
func estimateCmdlineLength(resolved env.AgentCommand) int {
argv := append([]string{resolved.Command}, resolved.Args...)
if resolved.WindowsVerbatimArguments {
n := 0
for _, a := range argv {
n += len(a)
}
if len(argv) > 1 {
n += len(argv) - 1
}
return n + 512
}
dstLen := 0
for _, a := range argv {
dstLen += len(a)
}
dstLen = dstLen*2 + len(argv)*2
if len(argv) > 1 {
dstLen += len(argv) - 1
}
return dstLen + 512
}
func FitPromptToWinCmdline(agentBin string, fixedArgs []string, prompt string, maxCmdline int, cwd string) FitPromptResult {
if runtime.GOOS != "windows" {
return fitPromptLinux(fixedArgs, prompt)
}
e := env.OsEnvToMap()
measured := func(p string) int {
args := make([]string, len(fixedArgs)+1)
copy(args, fixedArgs)
args[len(fixedArgs)] = p
resolved := env.ResolveAgentCommand(agentBin, args, e, cwd)
return estimateCmdlineLength(resolved)
}
if measured("") > maxCmdline {
return FitPromptResult{
OK: false,
Error: "Windows command line exceeds the configured limit even without a prompt; shorten workspace path, model id, or CURSOR_BRIDGE_WIN_CMDLINE_MAX.",
}
}
if measured(prompt) <= maxCmdline {
args := make([]string, len(fixedArgs)+1)
copy(args, fixedArgs)
args[len(fixedArgs)] = prompt
return FitPromptResult{
OK: true,
Args: args,
Truncated: false,
OriginalLength: len(prompt),
FinalPromptLength: len(prompt),
}
}
prefix := WinPromptOmissionPrefix
if measured(prefix) > maxCmdline {
return FitPromptResult{
OK: false,
Error: "Windows command line too long to fit even the truncation notice; shorten workspace path or flags.",
}
}
lo, hi, best := 0, len(prompt), 0
for lo <= hi {
mid := (lo + hi) / 2
var tail string
if mid > 0 {
tail = prompt[len(prompt)-mid:]
}
candidate := prefix + tail
if measured(candidate) <= maxCmdline {
best = mid
lo = mid + 1
} else {
hi = mid - 1
}
}
var finalPrompt string
if best == 0 {
finalPrompt = prefix
} else {
finalPrompt = prefix + prompt[len(prompt)-best:]
}
args := make([]string, len(fixedArgs)+1)
copy(args, fixedArgs)
args[len(fixedArgs)] = finalPrompt
return FitPromptResult{
OK: true,
Args: args,
Truncated: true,
OriginalLength: len(prompt),
FinalPromptLength: len(finalPrompt),
}
}
// fitPromptLinux handles Linux ARG_MAX truncation.
func fitPromptLinux(fixedArgs []string, prompt string) FitPromptResult {
argMax := safeLinuxArgMax()
// Estimate total cmdline size: sum of all fixed args + prompt + null terminators
fixedLen := 0
for _, a := range fixedArgs {
fixedLen += len(a) + 1
}
totalLen := fixedLen + len(prompt) + 1
if totalLen <= argMax {
args := make([]string, len(fixedArgs)+1)
copy(args, fixedArgs)
args[len(fixedArgs)] = prompt
return FitPromptResult{
OK: true,
Args: args,
Truncated: false,
OriginalLength: len(prompt),
FinalPromptLength: len(prompt),
}
}
// Need to truncate: keep the tail of the prompt (most recent messages)
prefix := LinuxPromptOmissionPrefix
available := argMax - fixedLen - len(prefix) - 1
if available < 0 {
available = 0
}
var finalPrompt string
if available <= 0 {
finalPrompt = prefix
} else if available >= len(prompt) {
finalPrompt = prefix + prompt
} else {
finalPrompt = prefix + prompt[len(prompt)-available:]
}
args := make([]string, len(fixedArgs)+1)
copy(args, fixedArgs)
args[len(fixedArgs)] = finalPrompt
return FitPromptResult{
OK: true,
Args: args,
Truncated: true,
OriginalLength: len(prompt),
FinalPromptLength: len(finalPrompt),
}
}
func WarnPromptTruncated(originalLength, finalLength int) {
_ = originalLength
_ = finalLength
}

View File

@ -0,0 +1,37 @@
package winlimit
import (
"runtime"
"strings"
"testing"
)
func TestNonWindowsPassThrough(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping non-Windows test on Windows")
}
fixedArgs := []string{"--print", "--model", "gpt-4"}
prompt := "Hello world"
result := FitPromptToWinCmdline("agent", fixedArgs, prompt, 30000, "/tmp")
if !result.OK {
t.Fatalf("expected OK=true on non-Windows, got error: %s", result.Error)
}
if result.Truncated {
t.Error("expected no truncation on non-Windows")
}
if result.OriginalLength != len(prompt) {
t.Errorf("expected original length %d, got %d", len(prompt), result.OriginalLength)
}
// Last arg should be the prompt
if len(result.Args) == 0 || result.Args[len(result.Args)-1] != prompt {
t.Errorf("expected last arg to be prompt, got %v", result.Args)
}
}
func TestOmissionPrefix(t *testing.T) {
if !strings.Contains(WinPromptOmissionPrefix, "Earlier messages omitted") {
t.Errorf("omission prefix should mention earlier messages, got: %q", WinPromptOmissionPrefix)
}
}

View File

@ -0,0 +1,30 @@
package workspace
import (
"cursor-api-proxy/internal/config"
"os"
"path/filepath"
"strings"
)
type WorkspaceResult struct {
WorkspaceDir string
TempDir string
}
func ResolveWorkspace(cfg config.BridgeConfig, workspaceHeader string) WorkspaceResult {
if cfg.ChatOnlyWorkspace {
tempDir, err := os.MkdirTemp("", "cursor-proxy-")
if err != nil {
tempDir = filepath.Join(os.TempDir(), "cursor-proxy-fallback")
_ = os.MkdirAll(tempDir, 0700)
}
return WorkspaceResult{WorkspaceDir: tempDir, TempDir: tempDir}
}
headerWs := strings.TrimSpace(workspaceHeader)
if headerWs != "" {
return WorkspaceResult{WorkspaceDir: headerWs}
}
return WorkspaceResult{WorkspaceDir: cfg.Workspace}
}

View File

@ -0,0 +1,27 @@
package cursor
import (
"context"
"cursor-api-proxy/pkg/domain/entity"
"cursor-api-proxy/internal/config"
)
type Provider struct {
cfg config.BridgeConfig
}
func NewProvider(cfg config.BridgeConfig) *Provider {
return &Provider{cfg: cfg}
}
func (p *Provider) Name() string {
return "cursor"
}
func (p *Provider) Close() error {
return nil
}
func (p *Provider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) error {
return nil
}

View File

@ -0,0 +1,125 @@
package geminiweb
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
type Browser struct {
browser *rod.Browser
visible bool
}
func NewBrowser(visible bool) (*Browser, error) {
l := launcher.New()
if visible {
l = l.Headless(false)
} else {
l = l.Headless(true)
}
url, err := l.Launch()
if err != nil {
return nil, fmt.Errorf("failed to launch browser: %w", err)
}
b := rod.New().ControlURL(url)
if err := b.Connect(); err != nil {
return nil, fmt.Errorf("failed to connect browser: %w", err)
}
return &Browser{browser: b, visible: visible}, nil
}
func (b *Browser) Close() error {
if b.browser != nil {
return b.browser.Close()
}
return nil
}
func (b *Browser) NewPage() (*rod.Page, error) {
return b.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
}
type Cookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
Path string `json:"path"`
Expires float64 `json:"expires"`
HTTPOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
}
func LoadCookiesFromFile(cookieFile string) ([]Cookie, error) {
data, err := os.ReadFile(cookieFile)
if err != nil {
return nil, fmt.Errorf("failed to read cookies: %w", err)
}
var cookies []Cookie
if err := json.Unmarshal(data, &cookies); err != nil {
return nil, fmt.Errorf("failed to parse cookies: %w", err)
}
return cookies, nil
}
func SaveCookiesToFile(cookies []Cookie, cookieFile string) error {
data, err := json.MarshalIndent(cookies, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal cookies: %w", err)
}
dir := filepath.Dir(cookieFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create cookie dir: %w", err)
}
if err := os.WriteFile(cookieFile, data, 0644); err != nil {
return fmt.Errorf("failed to write cookies: %w", err)
}
return nil
}
func SetCookiesOnPage(page *rod.Page, cookies []Cookie) error {
var protoCookies []*proto.NetworkCookieParam
for _, c := range cookies {
p := &proto.NetworkCookieParam{
Name: c.Name,
Value: c.Value,
Domain: c.Domain,
Path: c.Path,
HTTPOnly: c.HTTPOnly,
Secure: c.Secure,
}
if c.Expires > 0 {
exp := proto.TimeSinceEpoch(c.Expires)
p.Expires = exp
}
protoCookies = append(protoCookies, p)
}
return page.SetCookies(protoCookies)
}
func WaitForElement(page *rod.Page, selector string, timeout time.Duration) (*rod.Element, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return page.Context(ctx).Element(selector)
}
func WaitForElements(page *rod.Page, selector string, timeout time.Duration) (rod.Elements, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return page.Context(ctx).Elements(selector)
}

View File

@ -0,0 +1,173 @@
package geminiweb
import (
"fmt"
"os"
"path/filepath"
"sync"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
// BrowserManager 管理瀏覽器實例的生命週期
type BrowserManager struct {
mu sync.Mutex
browser *rod.Browser
userDataDir string
page *rod.Page
visible bool
isRunning bool
currentModel string
}
var (
globalManager *BrowserManager
globalMu sync.Mutex
)
// GetBrowserManager 獲取全域瀏覽器管理器(單例)
func GetBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) {
globalMu.Lock()
defer globalMu.Unlock()
if globalManager != nil {
return globalManager, nil
}
manager, err := NewBrowserManager(userDataDir, visible)
if err != nil {
return nil, err
}
globalManager = manager
return globalManager, nil
}
// NewBrowserManager 建立新的瀏覽器管理器
func NewBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) {
cleanLockFiles(userDataDir)
if err := os.MkdirAll(userDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create user data dir: %w", err)
}
return &BrowserManager{
userDataDir: userDataDir,
visible: visible,
}, nil
}
// cleanLockFiles 清理 Chrome 的殘留鎖檔案
func cleanLockFiles(userDataDir string) {
lockFiles := []string{
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"Default/SingletonLock",
"Default/SingletonCookie",
"Default/SingletonSocket",
}
for _, file := range lockFiles {
path := filepath.Join(userDataDir, file)
os.Remove(path)
}
}
// Launch 啟動瀏覽器(如果尚未啟動)
func (m *BrowserManager) Launch() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.isRunning && m.browser != nil {
return nil
}
l := launcher.New()
if m.visible {
l = l.Headless(false)
} else {
l = l.Headless(true)
}
l = l.UserDataDir(m.userDataDir)
url, err := l.Launch()
if err != nil {
return fmt.Errorf("failed to launch browser: %w", err)
}
b := rod.New().ControlURL(url)
if err := b.Connect(); err != nil {
return fmt.Errorf("failed to connect browser: %w", err)
}
m.browser = b
page, err := b.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
_ = b.Close()
return fmt.Errorf("failed to create page: %w", err)
}
m.page = page
m.isRunning = true
return nil
}
// GetPage 獲取頁面
func (m *BrowserManager) GetPage() (*rod.Page, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.isRunning || m.browser == nil {
return nil, fmt.Errorf("browser not running")
}
return m.page, nil
}
// Close 關閉瀏覽器
func (m *BrowserManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.isRunning {
return nil
}
var err error
if m.browser != nil {
err = m.browser.Close()
m.browser = nil
}
m.page = nil
m.isRunning = false
return err
}
// IsRunning 檢查瀏覽器是否正在運行
func (m *BrowserManager) IsRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.isRunning
}
// SetCurrentModel 設定當前模型
func (m *BrowserManager) SetCurrentModel(model string) {
m.mu.Lock()
defer m.mu.Unlock()
m.currentModel = model
}
// GetCurrentModel 獲取當前模型
func (m *BrowserManager) GetCurrentModel() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.currentModel
}

View File

@ -0,0 +1,250 @@
package geminiweb
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-rod/rod"
)
const geminiURL = "https://gemini.google.com/app"
// 輸入框選擇器(依優先順序)
var inputSelectors = []string{
".ProseMirror",
"rich-textarea",
"div[role='textbox'][contenteditable='true']",
"div[contenteditable='true']",
"textarea",
}
// NavigateToGemini 導航到 Gemini
func NavigateToGemini(page *rod.Page) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := page.Context(ctx).Navigate(geminiURL); err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
return page.Context(ctx).WaitLoad()
}
// IsLoggedIn 檢查是否已登入
func IsLoggedIn(page *rod.Page) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, sel := range inputSelectors {
if _, err := page.Context(ctx).Element(sel); err == nil {
return true
}
}
return false
}
// SelectModel 選擇模型(可選)
func SelectModel(page *rod.Page, model string) error {
fmt.Printf("[GeminiWeb] Model selection skipped (using current model)\n")
return nil
}
// TypeInput 在輸入框中輸入文字
func TypeInput(page *rod.Page, text string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fmt.Println("[GeminiWeb] Looking for input field...")
// 1. 嘗試所有選擇器
var inputEl *rod.Element
var err error
for _, sel := range inputSelectors {
fmt.Printf(" Trying: %s\n", sel)
inputEl, err = page.Context(ctx).Element(sel)
if err == nil {
fmt.Printf(" ✓ Found with: %s\n", sel)
break
}
}
if err != nil {
// 2. Fallback: 嘗試等待頁面載入完成後重試
fmt.Println("[GeminiWeb] Waiting for page to fully load...")
time.Sleep(3 * time.Second)
for _, sel := range inputSelectors {
fmt.Printf(" Retrying: %s\n", sel)
inputEl, err = page.Context(ctx).Element(sel)
if err == nil {
fmt.Printf(" ✓ Found with: %s\n", sel)
break
}
}
}
if err != nil {
// 3. Debug: 印出頁面標題和 URL
info, _ := page.Info()
fmt.Printf("[GeminiWeb] DEBUG: URL=%s Title=%s\n", info.URL, info.Title)
// 4. Fallback: 嘗試更通用的選擇器
fmt.Println("[GeminiWeb] Trying generic selectors...")
genericSelectors := []string{
"div[contenteditable]",
"[contenteditable]",
"textarea",
"input[type='text']",
}
for _, sel := range genericSelectors {
fmt.Printf(" Trying generic: %s\n", sel)
inputEl, err = page.Context(ctx).Element(sel)
if err == nil {
fmt.Printf(" ✓ Found with: %s\n", sel)
break
}
}
}
if err != nil {
info, _ := page.Info()
return fmt.Errorf("input field not found after trying all selectors (URL=%s)", info.URL)
}
// 2. Focus 輸入框
fmt.Printf("[GeminiWeb] Focusing input field...\n")
if err := inputEl.Focus(); err != nil {
return fmt.Errorf("failed to focus input: %w", err)
}
time.Sleep(500 * time.Millisecond)
// 3. 使用 Input 方法
fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text))
if err := inputEl.Input(text); err != nil {
return fmt.Errorf("failed to input text: %w", err)
}
time.Sleep(200 * time.Millisecond)
fmt.Println("[GeminiWeb] Input complete")
return nil
}
// ClickSend 發送訊息
func ClickSend(page *rod.Page) error {
// 方法 1: 按 Enter
if err := page.Keyboard.Press('\r'); err != nil {
return fmt.Errorf("failed to press Enter: %w", err)
}
time.Sleep(200 * time.Millisecond)
return nil
}
// WaitForReady 等待頁面空閒
func WaitForReady(page *rod.Page) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fmt.Println("[GeminiWeb] Checking if page is ready...")
for {
select {
case <-ctx.Done():
fmt.Println("[GeminiWeb] Page ready check timeout, proceeding anyway")
return nil
default:
time.Sleep(500 * time.Millisecond)
// 檢查是否有停止按鈕
hasStopBtn := false
stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']")
for _, btn := range stopBtns {
visible, _ := btn.Visible()
if visible {
hasStopBtn = true
break
}
}
if !hasStopBtn {
fmt.Println("[GeminiWeb] Page is ready")
return nil
}
}
}
}
// ExtractResponse 提取回應文字
func ExtractResponse(page *rod.Page) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var lastText string
lastUpdate := time.Now()
for {
select {
case <-ctx.Done():
if lastText != "" {
return lastText, nil
}
return "", fmt.Errorf("response timeout")
default:
time.Sleep(500 * time.Millisecond)
// 尋找回應文字
for _, sel := range responseSelectors {
elements, err := page.Elements(sel)
if err != nil || len(elements) == 0 {
continue
}
// 取得最後一個元素的文字
lastEl := elements[len(elements)-1]
text, err := lastEl.Text()
if err != nil {
continue
}
text = strings.TrimSpace(text)
if text != "" && text != lastText && len(text) > len(lastText) {
lastText = text
lastUpdate = time.Now()
fmt.Printf("[GeminiWeb] Response length: %d\n", len(text))
}
}
// 檢查是否已完成2 秒內沒有新內容)
if time.Since(lastUpdate) > 2*time.Second && lastText != "" {
// 最後檢查一次是否還有停止按鈕
hasStopBtn := false
stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']")
for _, btn := range stopBtns {
visible, _ := btn.Visible()
if visible {
hasStopBtn = true
break
}
}
if !hasStopBtn {
return lastText, nil
}
}
}
}
}
// 默認的回應選擇器
var responseSelectors = []string{
".model-response-text",
".message-content",
".markdown",
".prose",
"model-response",
}

View File

@ -0,0 +1,662 @@
package geminiweb
import (
"context"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/pkg/domain/entity"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
// PlaywrightProvider 使用 Playwright 的 Gemini Provider
type PlaywrightProvider struct {
cfg config.BridgeConfig
pw *playwright.Playwright
browser playwright.Browser
context playwright.BrowserContext
page playwright.Page
mu sync.Mutex
userDataDir string
}
var (
playwrightInstance *playwright.Playwright
playwrightOnce sync.Once
playwrightErr error
)
// NewPlaywrightProvider 建立新的 Playwright Provider
func NewPlaywrightProvider(cfg config.BridgeConfig) (*PlaywrightProvider, error) {
// 確保 Playwright 已初始化(單例)
playwrightOnce.Do(func() {
playwrightInstance, playwrightErr = playwright.Run()
if playwrightErr != nil {
playwrightErr = fmt.Errorf("failed to run playwright: %w", playwrightErr)
}
})
if playwrightErr != nil {
return nil, playwrightErr
}
// 清理 Chrome 鎖檔案
userDataDir := filepath.Join(cfg.GeminiAccountDir, "default-session")
cleanLockFiles(userDataDir)
// 確保目錄存在
if err := os.MkdirAll(userDataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create user data dir: %w", err)
}
return &PlaywrightProvider{
cfg: cfg,
pw: playwrightInstance,
userDataDir: userDataDir,
}, nil
}
// getName 返回 Provider 名稱
func (p *PlaywrightProvider) Name() string {
return "gemini-web"
}
// launchIfNeeded 如果需要則啟動瀏覽器
func (p *PlaywrightProvider) launchIfNeeded() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.context != nil && p.page != nil {
return nil
}
fmt.Println("[GeminiWeb] Launching Chromium...")
// 使用 LaunchPersistentContext自動保存 session
context, err := p.pw.Chromium.LaunchPersistentContext(p.userDataDir,
playwright.BrowserTypeLaunchPersistentContextOptions{
Headless: playwright.Bool(!p.cfg.GeminiBrowserVisible),
Args: []string{
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-extensions",
"--disable-plugins",
"--disable-sync",
},
})
if err != nil {
return fmt.Errorf("failed to launch persistent context: %w", err)
}
p.context = context
// 取得或建立頁面
pages := context.Pages()
if len(pages) > 0 {
p.page = pages[0]
} else {
page, err := context.NewPage()
if err != nil {
_ = context.Close()
return fmt.Errorf("failed to create page: %w", err)
}
p.page = page
}
fmt.Println("[GeminiWeb] Browser launched")
return nil
}
// Generate 生成回應
func (p *PlaywrightProvider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) (err error) {
// 確保在返回錯誤時保存診斷
defer func() {
if err != nil {
fmt.Println("[GeminiWeb] Error occurred, saving diagnostics...")
_ = p.saveDiagnostics()
}
}()
fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model)
// 1. 確保瀏覽器已啟動
if err := p.launchIfNeeded(); err != nil {
return fmt.Errorf("failed to launch browser: %w", err)
}
// 2. 導航到 Gemini如果需要
currentURL := p.page.URL()
if !strings.Contains(currentURL, "gemini.google.com") {
fmt.Println("[GeminiWeb] Navigating to Gemini...")
if _, err := p.page.Goto("https://gemini.google.com/app", playwright.PageGotoOptions{
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
Timeout: playwright.Float(60000),
}); err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
// 額外等待 JavaScript 載入
fmt.Println("[GeminiWeb] Waiting for page to initialize...")
time.Sleep(3 * time.Second)
}
// 3. 調試模式:等待用戶確認
if p.cfg.GeminiBrowserVisible {
fmt.Println("\n" + strings.Repeat("=", 70))
fmt.Println("🔍 調試模式:瀏覽器已開啟")
fmt.Println("請檢查瀏覽器畫面,然後按 ENTER 繼續...")
fmt.Println("如果有問題,請查看: /tmp/gemini-debug.*")
fmt.Println(strings.Repeat("=", 70))
var input string
fmt.Scanln(&input)
}
// 4. 等待頁面完全載入project-golem 策略)
fmt.Println("[GeminiWeb] Waiting for page to be ready...")
if err := p.waitForPageReady(); err != nil {
fmt.Printf("[GeminiWeb] Warning: %v\n", err)
// 額外調試:輸出頁面 HTML 結構
if p.cfg.GeminiBrowserVisible {
html, _ := p.page.Content()
debugPath := "/tmp/gemini-debug.html"
if err := os.WriteFile(debugPath, []byte(html), 0644); err == nil {
fmt.Printf("[GeminiWeb] HTML saved to: %s\n", debugPath)
}
}
}
// 4. 檢查登入狀態
fmt.Println("[GeminiWeb] Checking login status...")
loggedIn := p.isLoggedIn()
if !loggedIn {
fmt.Println("[GeminiWeb] Not logged in, continuing anyway")
if p.cfg.GeminiBrowserVisible {
fmt.Println("\n========================================")
fmt.Println("Browser is open. You can:")
fmt.Println("1. Log in to Gemini now")
fmt.Println("2. Continue without login")
fmt.Println("========================================")
}
} else {
fmt.Println("[GeminiWeb] ✓ Logged in")
}
// 5. 選擇模型(如果支援)
if err := p.selectModel(model); err != nil {
fmt.Printf("[GeminiWeb] Warning: model selection failed: %v\n", err)
}
// 6. 建構提示詞
prompt := buildPromptFromMessagesPlaywright(messages)
fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt))
// 7. 輸入文字(使用 Playwright 的 Auto-wait
if err := p.typeInput(prompt); err != nil {
return fmt.Errorf("failed to type: %w", err)
}
// 7. 發送訊息
fmt.Println("[GeminiWeb] Sending message...")
if err := p.sendMessage(); err != nil {
return fmt.Errorf("failed to send: %w", err)
}
// 8. 提取回應
fmt.Println("[GeminiWeb] Waiting for response...")
response, err := p.extractResponse()
if err != nil {
return fmt.Errorf("failed to extract response: %w", err)
}
// 9. 回調
cb(entity.StreamChunk{Type: entity.ChunkText, Text: response})
cb(entity.StreamChunk{Type: entity.ChunkDone, Done: true})
fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response))
return nil
}
// Close 關閉 Provider
func (p *PlaywrightProvider) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.context != nil {
if err := p.context.Close(); err != nil {
return err
}
p.context = nil
p.page = nil
}
return nil
}
// saveDiagnostics 保存診斷信息
func (p *PlaywrightProvider) saveDiagnostics() error {
if p.page == nil {
return fmt.Errorf("no page available")
}
// 截圖
screenshotPath := "/tmp/gemini-debug.png"
if _, err := p.page.Screenshot(playwright.PageScreenshotOptions{
Path: playwright.String(screenshotPath),
}); err == nil {
fmt.Printf("[GeminiWeb] Screenshot saved: %s\n", screenshotPath)
}
// HTML
htmlPath := "/tmp/gemini-debug.html"
if html, err := p.page.Content(); err == nil {
if err := os.WriteFile(htmlPath, []byte(html), 0644); err == nil {
fmt.Printf("[GeminiWeb] HTML saved: %s\n", htmlPath)
}
}
// 輸出頁面信息
url := p.page.URL()
title, _ := p.page.Title()
fmt.Printf("[GeminiWeb] Diagnostics: URL=%s, Title=%s\n", url, title)
return nil
}
// waitForPageReady 等待頁面完全就緒project-golem 策略)
func (p *PlaywrightProvider) waitForPageReady() error {
fmt.Println("[GeminiWeb] Checking for ready state...")
// 1. 等待停止按鈕消失(如果存在)
_, _ = p.page.WaitForSelector("button[aria-label*='Stop'], button[aria-label*='停止']", playwright.PageWaitForSelectorOptions{
State: playwright.WaitForSelectorStateDetached,
Timeout: playwright.Float(5000),
})
// 2. 嘗試多種等待策略
inputSelectors := []string{
".ql-editor.ql-blank",
".ql-editor",
"div[contenteditable='true'][role='textbox']",
"div[contenteditable='true']",
".ProseMirror",
"rich-textarea",
"textarea",
}
// 策略 A: 等待任一輸入框出現
for i, sel := range inputSelectors {
fmt.Printf(" [%d/%d] Waiting for: %s\n", i+1, len(inputSelectors), sel)
locator := p.page.Locator(sel)
if err := locator.WaitFor(playwright.LocatorWaitForOptions{
Timeout: playwright.Float(5000),
State: playwright.WaitForSelectorStateVisible,
}); err == nil {
fmt.Printf(" ✓ Input field found: %s\n", sel)
return nil
}
}
// 策略 B: 等待頁面完全載入
fmt.Println("[GeminiWeb] Waiting for page load...")
time.Sleep(3 * time.Second)
// 策略 C: 使用 JavaScript 檢查
fmt.Println("[GeminiWeb] Checking with JavaScript...")
result, err := p.page.Evaluate(`
() => {
// 檢查所有可能的輸入元素
const selectors = [
'.ql-editor.ql-blank',
'.ql-editor',
'div[contenteditable="true"][role="textbox"]',
'div[contenteditable="true"]',
'.ProseMirror',
'rich-textarea',
'textarea'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
return {
found: true,
selector: sel,
tagName: el.tagName,
className: el.className,
visible: el.offsetParent !== null
};
}
}
return { found: false };
}
`)
if err == nil {
if m, ok := result.(map[string]interface{}); ok {
if found, _ := m["found"].(bool); found {
sel, _ := m["selector"].(string)
fmt.Printf(" ✓ JavaScript found: %s\n", sel)
return nil
}
}
}
// 策略 D: 調試模式 - 輸出頁面結構
if p.cfg.GeminiBrowserVisible {
fmt.Println("[GeminiWeb].dump: Page structure analysis")
_, _ = p.page.Evaluate(`
() => {
const allElements = document.querySelectorAll('*');
const inputLike = [];
for (const el of allElements) {
if (el.contentEditable === 'true' ||
el.role === 'textbox' ||
el.tagName === 'TEXTAREA' ||
el.tagName === 'INPUT') {
inputLike.push({
tag: el.tagName,
class: el.className,
id: el.id,
role: el.role,
contentEditable: el.contentEditable
});
}
}
console.log('Input-like elements:', inputLike);
}
`)
}
return fmt.Errorf("no input field found after all strategies")
}
// isLoggedIn 檢查是否已登入
func (p *PlaywrightProvider) isLoggedIn() bool {
// 嘗試找輸入框(登入狀態的主要特徵)
selectors := []string{
".ProseMirror",
"rich-textarea",
"div[role='textbox']",
"div[contenteditable='true']",
"textarea",
}
for _, sel := range selectors {
locator := p.page.Locator(sel)
if count, _ := locator.Count(); count > 0 {
return true
}
}
return false
}
// typeInput 輸入文字(使用 Playwright 的 Auto-wait
func (p *PlaywrightProvider) typeInput(text string) error {
fmt.Println("[GeminiWeb] Looking for input field...")
selectors := []string{
".ql-editor.ql-blank",
".ql-editor",
"div[contenteditable='true'][role='textbox']",
"div[contenteditable='true']",
".ProseMirror",
"rich-textarea",
"textarea",
}
var inputLocator playwright.Locator
var found bool
for _, sel := range selectors {
fmt.Printf(" Trying: %s\n", sel)
locator := p.page.Locator(sel)
if err := locator.WaitFor(playwright.LocatorWaitForOptions{
Timeout: playwright.Float(3000),
}); err == nil {
inputLocator = locator
found = true
fmt.Printf(" ✓ Found with: %s\n", sel)
break
}
}
if !found {
// 錯誤會被 Generate 的 defer 捕獲並保存診斷
url := p.page.URL()
title, _ := p.page.Title()
return fmt.Errorf("input field not found (URL=%s, Title=%s). Diagnostics will be saved to /tmp/", url, title)
}
// Focus 並填充Playwright 自動等待)
fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text))
if err := inputLocator.Fill(text); err != nil {
return fmt.Errorf("failed to fill: %w", err)
}
fmt.Println("[GeminiWeb] Input complete")
return nil
}
// sendMessage 發送訊息
func (p *PlaywrightProvider) sendMessage() error {
// 方法 1: 按 Enter最可靠
if err := p.page.Keyboard().Press("Enter"); err != nil {
return fmt.Errorf("failed to press Enter: %w", err)
}
time.Sleep(200 * time.Millisecond)
// 方法 2: 嘗試點擊發送按鈕(補強)
_, _ = p.page.Evaluate(`
() => {
const keywords = ['發送', 'Send', '傳送'];
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
for (const btn of buttons) {
const text = (btn.innerText || btn.textContent || '').trim();
const label = (btn.getAttribute('aria-label') || '').trim();
// 跳過停止按鈕
if (['停止', 'Stop', '中斷'].includes(text) || label.toLowerCase().includes('stop')) {
continue;
}
if (keywords.some(kw => text.includes(kw) || label.includes(kw))) {
btn.click();
return true;
}
}
return false;
}
`)
return nil
}
// extractResponse 提取回應
func (p *PlaywrightProvider) extractResponse() (string, error) {
var lastText string
var stableCount int
lastUpdate := time.Now()
timeout := 120 * time.Second
startTime := time.Now()
for time.Since(startTime) < timeout {
time.Sleep(500 * time.Millisecond)
// 使用 JavaScript 提取回應文字(更精確)
result, err := p.page.Evaluate(`
() => {
// 尋找所有可能的回應容器
const selectors = [
'model-response',
'.model-response',
'message-content',
'.message-content'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
// 嘗試找markdown內容
const markdown = el.querySelector('.markdown, .prose, [class*="markdown"]');
if (markdown && markdown.innerText.trim()) {
let text = markdown.innerText.trim();
// 移除常見的標籤前綴
text = text.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[:]\s*\n*/i, '').trim();
return { text: text, source: sel + ' .markdown' };
}
// 嘗試找純文字內容(排除標籤)
let textContent = el.innerText.trim();
if (textContent) {
// 移除常見的標籤前綴
textContent = textContent.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[:]\s*\n*/i, '').trim();
return { text: textContent, source: sel };
}
}
}
return { text: '', source: 'none' };
}
`)
if err == nil {
if m, ok := result.(map[string]interface{}); ok {
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text != "" && len(text) > len(lastText) {
lastText = text
lastUpdate = time.Now()
stableCount = 0
fmt.Printf("[GeminiWeb] Response: %d chars\n", len(text))
}
}
}
// 檢查是否完成(需要連續 3 次穩定)
if time.Since(lastUpdate) > 500*time.Millisecond && lastText != "" {
stableCount++
if stableCount >= 3 {
// 最終檢查:停止按鈕是否還存在
stopBtn := p.page.Locator("button[aria-label*='Stop'], button[aria-label*='停止'], button[data-test-id='stop-button']")
count, _ := stopBtn.Count()
if count == 0 {
fmt.Println("[GeminiWeb] ✓ Response complete")
return lastText, nil
}
}
}
}
if lastText != "" {
fmt.Println("[GeminiWeb] ✓ Response complete (timeout)")
return lastText, nil
}
return "", fmt.Errorf("response timeout")
}
// selectModel 選擇 Gemini 模型
// Gemini Web 只有三種模型fast, thinking, pro
func (p *PlaywrightProvider) selectModel(model string) error {
// 映射模型名稱到 Gemini Web 的模型選擇器
modelMap := map[string]string{
"fast": "Fast",
"thinking": "Thinking",
"pro": "Pro",
"gemini-fast": "Fast",
"gemini-thinking": "Thinking",
"gemini-pro": "Pro",
"gemini-2.0-fast": "Fast",
"gemini-2.0-flash": "Fast", // 相容舊名稱
"gemini-2.5-pro": "Pro",
"gemini-2.5-pro-thinking": "Thinking",
}
// 從完整模型名稱中提取類型
modelType := ""
modelLower := strings.ToLower(model)
for key, value := range modelMap {
if strings.Contains(modelLower, strings.ToLower(key)) || modelLower == strings.ToLower(key) {
modelType = value
break
}
}
if modelType == "" {
// 默認使用 Fast
fmt.Printf("[GeminiWeb] Unknown model '%s', defaulting to Fast\n", model)
return nil
}
fmt.Printf("[GeminiWeb] Selecting model: %s\n", modelType)
// 點擊模型選擇器
modelSelector := p.page.Locator("button[aria-label*='Model'], button[aria-label*='模型'], [data-test-id='model-selector']")
if count, _ := modelSelector.Count(); count > 0 {
if err := modelSelector.First().Click(); err != nil {
fmt.Printf("[GeminiWeb] Warning: could not click model selector: %v\n", err)
} else {
time.Sleep(500 * time.Millisecond)
// 選擇對應的模型選項
optionSelector := p.page.Locator(fmt.Sprintf("button:has-text('%s'), [role='menuitem']:has-text('%s')", modelType, modelType))
if count, _ := optionSelector.Count(); count > 0 {
if err := optionSelector.First().Click(); err != nil {
fmt.Printf("[GeminiWeb] Warning: could not select model: %v\n", err)
} else {
fmt.Printf("[GeminiWeb] ✓ Model selected: %s\n", modelType)
time.Sleep(500 * time.Millisecond)
}
}
}
}
return nil
}
// buildPromptFromMessagesPlaywright 從訊息列表建構提示詞
func buildPromptFromMessagesPlaywright(messages []entity.Message) string {
var prompt string
for _, m := range messages {
content := messageContentToStringPlaywright(m.Content)
switch m.Role {
case "system":
prompt += "System: " + content + "\n\n"
case "user":
prompt += content + "\n\n"
case "assistant":
prompt += "Assistant: " + content + "\n\n"
}
}
return prompt
}
// messageContentToStringPlaywright converts Message.Content to string
func messageContentToStringPlaywright(content interface{}) string {
switch v := content.(type) {
case string:
return v
case []interface{}:
var result string
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
if text, ok := m["text"].(string); ok {
result += text
}
}
}
return result
default:
return ""
}
}

View File

@ -0,0 +1,169 @@
package geminiweb
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type GeminiSession struct {
Name string `json:"name"`
CookieFile string `json:"cookie_file"`
LastUsed int64 `json:"last_used"`
ActiveCount int `json:"active_count"`
RateLimitEnd int64 `json:"rate_limit_end"`
}
type SessionPool struct {
mu sync.Mutex
sessions []*GeminiSession
dir string
maxCount int
}
func NewSessionPool(dir string, maxSessions int) (*SessionPool, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create session dir: %w", err)
}
sessions, err := loadSessions(dir)
if err != nil {
return nil, fmt.Errorf("failed to load sessions: %w", err)
}
return &SessionPool{
sessions: sessions,
dir: dir,
maxCount: maxSessions,
}, nil
}
func loadSessions(dir string) ([]*GeminiSession, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var sessions []*GeminiSession
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
metaPath := filepath.Join(dir, name, "session.json")
data, err := os.ReadFile(metaPath)
if err != nil {
continue
}
var s GeminiSession
if err := json.Unmarshal(data, &s); err != nil {
continue
}
sessions = append(sessions, &s)
}
return sessions, nil
}
func (p *SessionPool) Count() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.sessions)
}
func (p *SessionPool) GetAvailable() *GeminiSession {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now().UnixMilli()
var available []*GeminiSession
for _, s := range p.sessions {
if s.RateLimitEnd < now {
available = append(available, s)
}
}
if len(available) == 0 {
return nil
}
var best *GeminiSession
for _, s := range available {
if best == nil || s.ActiveCount < best.ActiveCount {
best = s
} else if s.ActiveCount == best.ActiveCount && s.LastUsed < best.LastUsed {
best = s
}
}
return best
}
func (p *SessionPool) StartSession(s *GeminiSession) {
p.mu.Lock()
defer p.mu.Unlock()
s.ActiveCount++
s.LastUsed = time.Now().UnixMilli()
p.saveSession(s)
}
func (p *SessionPool) EndSession(s *GeminiSession) {
p.mu.Lock()
defer p.mu.Unlock()
if s.ActiveCount > 0 {
s.ActiveCount--
}
p.saveSession(s)
}
func (p *SessionPool) RateLimitSession(s *GeminiSession, durationMs int64) {
p.mu.Lock()
defer p.mu.Unlock()
s.RateLimitEnd = time.Now().UnixMilli() + durationMs
p.saveSession(s)
}
func (p *SessionPool) saveSession(s *GeminiSession) {
metaPath := filepath.Join(p.dir, s.Name, "session.json")
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return
}
_ = os.WriteFile(metaPath, data, 0644)
}
func (p *SessionPool) CreateSession(name string) (*GeminiSession, error) {
p.mu.Lock()
defer p.mu.Unlock()
sessionDir := filepath.Join(p.dir, name)
if err := os.MkdirAll(sessionDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create session dir: %w", err)
}
s := &GeminiSession{
Name: name,
CookieFile: filepath.Join(sessionDir, "cookies.json"),
LastUsed: time.Now().UnixMilli(),
}
p.sessions = append(p.sessions, s)
p.saveSession(s)
return s, nil
}
func (p *SessionPool) GetSessionNames() []string {
p.mu.Lock()
defer p.mu.Unlock()
names := make([]string, len(p.sessions))
for i, s := range p.sessions {
names[i] = s.Name
}
return names
}

View File

@ -0,0 +1,218 @@
package geminiweb
import (
"context"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/pkg/domain/entity"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Provider 使用持久化瀏覽器管理器
type Provider struct {
cfg config.BridgeConfig
managerOnce sync.Once
manager *BrowserManager
managerErr error
}
// NewProvider 建立新的 Provider
func NewProvider(cfg config.BridgeConfig) *Provider {
return &Provider{cfg: cfg}
}
// getName 返回 Provider 名稱
func (p *Provider) Name() string {
return "gemini-web"
}
// Close 關閉瀏覽器
func (p *Provider) Close() error {
if p.manager != nil {
return p.manager.Close()
}
return nil
}
// getManager 獲取或初始化瀏覽器管理器(單例)
func (p *Provider) getManager() (*BrowserManager, error) {
p.managerOnce.Do(func() {
sessionDir := p.getSessionDir()
p.manager, p.managerErr = GetBrowserManager(sessionDir, p.cfg.GeminiBrowserVisible)
})
return p.manager, p.managerErr
}
// getSessionDir 獲取 session 目錄
func (p *Provider) getSessionDir() string {
// 使用單一 session 目錄(簡化設計)
return filepath.Join(p.cfg.GeminiAccountDir, "default-session")
}
// Generate 生成回應
func (p *Provider) Generate(ctx context.Context, model string, messages []entity.Message, tools []entity.Tool, cb func(entity.StreamChunk)) error {
fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model)
// 1. 獲取瀏覽器管理器
manager, err := p.getManager()
if err != nil {
return fmt.Errorf("failed to get browser manager: %w", err)
}
// 2. 啟動瀏覽器(如果尚未啟動)
if !manager.IsRunning() {
fmt.Printf("[GeminiWeb] Launching browser...\n")
if err := manager.Launch(); err != nil {
return fmt.Errorf("failed to launch browser: %w", err)
}
}
// 3. 獲取頁面
page, err := manager.GetPage()
if err != nil {
return fmt.Errorf("failed to get page: %w", err)
}
// 4. 檢查當前 URL如果不是 Gemini 則導航
currentURL, _ := page.Info()
if !strings.Contains(currentURL.URL, "gemini.google.com") {
fmt.Printf("[GeminiWeb] Navigating to Gemini...\n")
if err := NavigateToGemini(page); err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
time.Sleep(2 * time.Second)
}
// 5. 檢查登入狀態
fmt.Printf("[GeminiWeb] Checking login status...\n")
if !IsLoggedIn(page) {
fmt.Printf("[GeminiWeb] Not logged in, continuing anyway\n")
if p.cfg.GeminiBrowserVisible {
fmt.Println("\n========================================")
fmt.Println("Browser is open. You can:")
fmt.Println("1. Log in to Gemini now")
fmt.Println("2. Continue without login")
fmt.Println("========================================")
}
} else {
fmt.Printf("[GeminiWeb] Logged in\n")
}
// 6. 等待頁面就緒
if err := WaitForReady(page); err != nil {
fmt.Printf("[GeminiWeb] Warning: %v\n", err)
}
// 7. 建構提示詞
prompt := buildPromptFromMessages(messages)
fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt))
// 8. 輸入文字
if err := TypeInput(page, prompt); err != nil {
return fmt.Errorf("failed to type input: %w", err)
}
// 9. 發送
fmt.Printf("[GeminiWeb] Sending message...\n")
if err := ClickSend(page); err != nil {
return fmt.Errorf("failed to send: %w", err)
}
// 10. 提取回應
fmt.Printf("[GeminiWeb] Waiting for response...\n")
response, err := ExtractResponse(page)
if err != nil {
return fmt.Errorf("failed to extract response: %w", err)
}
// 11. 串流回調
cb(entity.StreamChunk{Type: entity.ChunkText, Text: response})
cb(entity.StreamChunk{Type: entity.ChunkDone, Done: true})
fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response))
return nil
}
// buildPromptFromMessages 從訊息列表建構提示詞
func buildPromptFromMessages(messages []entity.Message) string {
var prompt string
for _, m := range messages {
content := messageContentToString(m.Content)
switch m.Role {
case "system":
prompt += "System: " + content + "\n\n"
case "user":
prompt += content + "\n\n"
case "assistant":
prompt += "Assistant: " + content + "\n\n"
}
}
return prompt
}
// messageContentToString converts Message.Content to string
func messageContentToString(content interface{}) string {
switch v := content.(type) {
case string:
return v
case []interface{}:
// Handle array content (multimodal)
var result string
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
if text, ok := m["text"].(string); ok {
result += text
}
}
}
return result
default:
return ""
}
}
// RunLogin 執行登入流程(供 gemini-login 命令使用)
func RunLogin(cfg config.BridgeConfig, sessionName string) error {
if sessionName == "" {
sessionName = "default-session"
}
sessionDir := filepath.Join(cfg.GeminiAccountDir, sessionName)
if err := os.MkdirAll(sessionDir, 0755); err != nil {
return fmt.Errorf("failed to create session dir: %w", err)
}
fmt.Printf("Starting browser for login. Session: %s\n", sessionName)
fmt.Printf("Session directory: %s\n", sessionDir)
fmt.Println("Please log in to your Gemini account in the browser window.")
fmt.Println("Press Ctrl+C when you have completed the login...")
manager, err := NewBrowserManager(sessionDir, true) // visible=true
if err != nil {
return fmt.Errorf("failed to create browser manager: %w", err)
}
if err := manager.Launch(); err != nil {
return fmt.Errorf("failed to launch browser: %w", err)
}
defer manager.Close()
page, err := manager.GetPage()
if err != nil {
return fmt.Errorf("failed to get page: %w", err)
}
if err := NavigateToGemini(page); err != nil {
return fmt.Errorf("failed to navigate: %w", err)
}
// 等待用戶手動登入...
// 使用 Ctrl+C 退出,瀏覽器資料會自動保存在 userDataDir
return nil
}

273
pkg/repository/account.go Normal file
View File

@ -0,0 +1,273 @@
package repository
import (
"sync"
"time"
"cursor-api-proxy/pkg/domain/entity"
)
type accountStatus struct {
configDir string
activeRequests int
lastUsed int64
rateLimitUntil int64
totalRequests int
totalSuccess int
totalErrors int
totalRateLimits int
totalLatencyMs int64
}
type AccountPool struct {
mu sync.Mutex
accounts []*accountStatus
}
func NewAccountPool(configDirs []string) *AccountPool {
accounts := make([]*accountStatus, 0, len(configDirs))
for _, dir := range configDirs {
accounts = append(accounts, &accountStatus{configDir: dir})
}
return &AccountPool{accounts: accounts}
}
func (p *AccountPool) GetNextConfigDir() string {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.accounts) == 0 {
return ""
}
now := time.Now().UnixMilli()
available := make([]*accountStatus, 0, len(p.accounts))
for _, a := range p.accounts {
if a.rateLimitUntil < now {
available = append(available, a)
}
}
target := available
if len(target) == 0 {
target = make([]*accountStatus, len(p.accounts))
copy(target, p.accounts)
// sort by earliest recovery
for i := 1; i < len(target); i++ {
for j := i; j > 0 && target[j].rateLimitUntil < target[j-1].rateLimitUntil; j-- {
target[j], target[j-1] = target[j-1], target[j]
}
}
}
// pick least busy then least recently used
best := target[0]
for _, a := range target[1:] {
if a.activeRequests < best.activeRequests {
best = a
} else if a.activeRequests == best.activeRequests && a.lastUsed < best.lastUsed {
best = a
}
}
best.lastUsed = now
return best.configDir
}
func (p *AccountPool) find(configDir string) *accountStatus {
for _, a := range p.accounts {
if a.configDir == configDir {
return a
}
}
return nil
}
func (p *AccountPool) ReportRequestStart(configDir string) {
if configDir == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if a := p.find(configDir); a != nil {
a.activeRequests++
a.totalRequests++
}
}
func (p *AccountPool) ReportRequestEnd(configDir string) {
if configDir == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if a := p.find(configDir); a != nil && a.activeRequests > 0 {
a.activeRequests--
}
}
func (p *AccountPool) ReportRequestSuccess(configDir string, latencyMs int64) {
if configDir == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if a := p.find(configDir); a != nil {
a.totalSuccess++
a.totalLatencyMs += latencyMs
}
}
func (p *AccountPool) ReportRequestError(configDir string, latencyMs int64) {
if configDir == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if a := p.find(configDir); a != nil {
a.totalErrors++
a.totalLatencyMs += latencyMs
}
}
func (p *AccountPool) ReportRateLimit(configDir string, penaltyMs int64) {
if configDir == "" {
return
}
if penaltyMs <= 0 {
penaltyMs = 60000
}
p.mu.Lock()
defer p.mu.Unlock()
if a := p.find(configDir); a != nil {
a.rateLimitUntil = time.Now().UnixMilli() + penaltyMs
a.totalRateLimits++
}
}
func (p *AccountPool) GetStats() []entity.AccountStat {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now().UnixMilli()
stats := make([]entity.AccountStat, len(p.accounts))
for i, a := range p.accounts {
stats[i] = entity.AccountStat{
ConfigDir: a.configDir,
ActiveRequests: a.activeRequests,
TotalRequests: a.totalRequests,
TotalSuccess: a.totalSuccess,
TotalErrors: a.totalErrors,
TotalRateLimits: a.totalRateLimits,
TotalLatencyMs: a.totalLatencyMs,
IsRateLimited: a.rateLimitUntil > now,
RateLimitUntil: a.rateLimitUntil,
}
}
return stats
}
func (p *AccountPool) Count() int {
return len(p.accounts)
}
// ─── PoolHandle interface ──────────────────────────────────────────────────
// PoolHandle 讓 handler 可以注入獨立的 pool 實例,避免多 port 模式共用全域 pool。
type PoolHandle interface {
GetNextConfigDir() string
ReportRequestStart(configDir string)
ReportRequestEnd(configDir string)
ReportRequestSuccess(configDir string, latencyMs int64)
ReportRequestError(configDir string, latencyMs int64)
ReportRateLimit(configDir string, penaltyMs int64)
GetStats() []entity.AccountStat
}
// GlobalPoolHandle 包裝全域函式以實作 PoolHandle 介面(單 port 模式使用)
type GlobalPoolHandle struct{}
func (GlobalPoolHandle) GetNextConfigDir() string { return GetNextAccountConfigDir() }
func (GlobalPoolHandle) ReportRequestStart(d string) { ReportRequestStart(d) }
func (GlobalPoolHandle) ReportRequestEnd(d string) { ReportRequestEnd(d) }
func (GlobalPoolHandle) ReportRequestSuccess(d string, l int64) { ReportRequestSuccess(d, l) }
func (GlobalPoolHandle) ReportRequestError(d string, l int64) { ReportRequestError(d, l) }
func (GlobalPoolHandle) ReportRateLimit(d string, p int64) { ReportRateLimit(d, p) }
func (GlobalPoolHandle) GetStats() []entity.AccountStat { return GetAccountStats() }
// ─── Global pool ───────────────────────────────────────────────────────────
var (
globalPool *AccountPool
globalMu sync.Mutex
)
func InitAccountPool(configDirs []string) {
globalMu.Lock()
defer globalMu.Unlock()
globalPool = NewAccountPool(configDirs)
}
func GetNextAccountConfigDir() string {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p == nil {
return ""
}
return p.GetNextConfigDir()
}
func ReportRequestStart(configDir string) {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p != nil {
p.ReportRequestStart(configDir)
}
}
func ReportRequestEnd(configDir string) {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p != nil {
p.ReportRequestEnd(configDir)
}
}
func ReportRequestSuccess(configDir string, latencyMs int64) {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p != nil {
p.ReportRequestSuccess(configDir, latencyMs)
}
}
func ReportRequestError(configDir string, latencyMs int64) {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p != nil {
p.ReportRequestError(configDir, latencyMs)
}
}
func ReportRateLimit(configDir string, penaltyMs int64) {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p != nil {
p.ReportRateLimit(configDir, penaltyMs)
}
}
func GetAccountStats() []entity.AccountStat {
globalMu.Lock()
p := globalPool
globalMu.Unlock()
if p == nil {
return nil
}
return p.GetStats()
}

View File

@ -0,0 +1,152 @@
package repository
import (
"testing"
"time"
)
func TestEmptyPool(t *testing.T) {
p := NewAccountPool(nil)
if got := p.GetNextConfigDir(); got != "" {
t.Fatalf("expected empty string for empty pool, got %q", got)
}
if p.Count() != 0 {
t.Fatalf("expected count 0, got %d", p.Count())
}
}
func TestSingleDir(t *testing.T) {
p := NewAccountPool([]string{"/dir1"})
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("expected /dir1, got %q", got)
}
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("expected /dir1 again, got %q", got)
}
}
func TestRoundRobin(t *testing.T) {
p := NewAccountPool([]string{"/a", "/b", "/c"})
got := []string{
p.GetNextConfigDir(),
p.GetNextConfigDir(),
p.GetNextConfigDir(),
p.GetNextConfigDir(),
}
want := []string{"/a", "/b", "/c", "/a"}
for i, w := range want {
if got[i] != w {
t.Fatalf("call %d: expected %q, got %q", i, w, got[i])
}
}
}
func TestLeastBusy(t *testing.T) {
p := NewAccountPool([]string{"/dir1", "/dir2", "/dir3"})
p.ReportRequestStart("/dir1")
p.ReportRequestStart("/dir2")
if got := p.GetNextConfigDir(); got != "/dir3" {
t.Fatalf("expected /dir3 (least busy), got %q", got)
}
p.ReportRequestStart("/dir3")
p.ReportRequestEnd("/dir1")
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("expected /dir1 after end, got %q", got)
}
}
func TestSkipsRateLimited(t *testing.T) {
p := NewAccountPool([]string{"/dir1", "/dir2"})
p.ReportRateLimit("/dir1", 60000)
if got := p.GetNextConfigDir(); got != "/dir2" {
t.Fatalf("expected /dir2, got %q", got)
}
if got := p.GetNextConfigDir(); got != "/dir2" {
t.Fatalf("expected /dir2 again, got %q", got)
}
}
func TestFallbackToSoonestRecovery(t *testing.T) {
p := NewAccountPool([]string{"/dir1", "/dir2"})
p.ReportRateLimit("/dir1", 60000)
p.ReportRateLimit("/dir2", 30000)
// dir2 recovers sooner — should be selected
if got := p.GetNextConfigDir(); got != "/dir2" {
t.Fatalf("expected /dir2 (sooner recovery), got %q", got)
}
}
func TestActiveRequestsDoesNotGoNegative(t *testing.T) {
p := NewAccountPool([]string{"/dir1"})
p.ReportRequestEnd("/dir1")
p.ReportRequestEnd("/dir1")
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("pool should still work, got %q", got)
}
}
func TestIgnoreUnknownConfigDir(t *testing.T) {
p := NewAccountPool([]string{"/dir1"})
p.ReportRequestStart("/nonexistent")
p.ReportRequestEnd("/nonexistent")
p.ReportRateLimit("/nonexistent", 60000)
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("expected /dir1, got %q", got)
}
}
func TestRateLimitExpires(t *testing.T) {
p := NewAccountPool([]string{"/dir1", "/dir2"})
p.ReportRateLimit("/dir1", 50)
if got := p.GetNextConfigDir(); got != "/dir2" {
t.Fatalf("immediately expected /dir2, got %q", got)
}
time.Sleep(100 * time.Millisecond)
if got := p.GetNextConfigDir(); got != "/dir1" {
t.Fatalf("after expiry expected /dir1, got %q", got)
}
}
func TestGlobalPool(t *testing.T) {
InitAccountPool([]string{"/g1", "/g2"})
if got := GetNextAccountConfigDir(); got != "/g1" {
t.Fatalf("expected /g1, got %q", got)
}
if got := GetNextAccountConfigDir(); got != "/g2" {
t.Fatalf("expected /g2, got %q", got)
}
if got := GetNextAccountConfigDir(); got != "/g1" {
t.Fatalf("expected /g1 again, got %q", got)
}
}
func TestGlobalPoolEmpty(t *testing.T) {
InitAccountPool(nil)
if got := GetNextAccountConfigDir(); got != "" {
t.Fatalf("expected empty string for empty global pool, got %q", got)
}
}
func TestGlobalPoolReinit(t *testing.T) {
InitAccountPool([]string{"/old1", "/old2"})
GetNextAccountConfigDir()
InitAccountPool([]string{"/new1"})
if got := GetNextAccountConfigDir(); got != "/new1" {
t.Fatalf("expected /new1 after reinit, got %q", got)
}
}
func TestGlobalPoolFunctionsNoopBeforeInit(t *testing.T) {
InitAccountPool(nil)
ReportRequestStart("/dir1")
ReportRequestEnd("/dir1")
ReportRateLimit("/dir1", 1000)
}

28
pkg/usecase/cmdargs.go Normal file
View File

@ -0,0 +1,28 @@
package usecase
import "cursor-api-proxy/internal/config"
func BuildAgentFixedArgs(cfg config.BridgeConfig, workspaceDir, model string, stream bool) []string {
args := []string{"--print"}
if cfg.ApproveMcps {
args = append(args, "--approve-mcps")
}
if cfg.Force {
args = append(args, "--force")
}
if cfg.ChatOnlyWorkspace {
args = append(args, "--trust")
}
args = append(args, "--workspace", workspaceDir)
args = append(args, "--model", model)
if stream {
args = append(args, "--stream-partial-output", "--output-format", "stream-json")
} else {
args = append(args, "--output-format", "text")
}
return args
}
func BuildAgentCmdArgs(cfg config.BridgeConfig, workspaceDir, model, prompt string, stream bool) []string {
return append(BuildAgentFixedArgs(cfg, workspaceDir, model, stream), prompt)
}

85
pkg/usecase/maxmode.go Normal file
View File

@ -0,0 +1,85 @@
package usecase
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
)
func getCandidates(agentScriptPath, configDirOverride string) []string {
if configDirOverride != "" {
return []string{filepath.Join(configDirOverride, "cli-config.json")}
}
var result []string
if dir := os.Getenv("CURSOR_CONFIG_DIR"); dir != "" {
result = append(result, filepath.Join(dir, "cli-config.json"))
}
if agentScriptPath != "" {
agentDir := filepath.Dir(agentScriptPath)
result = append(result, filepath.Join(agentDir, "..", "data", "config", "cli-config.json"))
}
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
switch runtime.GOOS {
case "windows":
local := os.Getenv("LOCALAPPDATA")
if local == "" {
local = filepath.Join(home, "AppData", "Local")
}
result = append(result, filepath.Join(local, "cursor-agent", "cli-config.json"))
case "darwin":
result = append(result, filepath.Join(home, "Library", "Application Support", "cursor-agent", "cli-config.json"))
default:
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg == "" {
xdg = filepath.Join(home, ".config")
}
result = append(result, filepath.Join(xdg, "cursor-agent", "cli-config.json"))
}
return result
}
func RunMaxModePreflight(agentScriptPath, configDirOverride string) {
for _, candidate := range getCandidates(agentScriptPath, configDirOverride) {
data, err := os.ReadFile(candidate)
if err != nil {
continue
}
// Strip BOM if present
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
continue
}
if raw == nil || len(raw) <= 1 {
continue
}
raw["maxMode"] = true
if model, ok := raw["model"].(map[string]interface{}); ok {
model["maxMode"] = true
}
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
continue
}
if err := os.WriteFile(candidate, out, 0644); err != nil {
continue
}
return
}
}

72
pkg/usecase/runner.go Normal file
View File

@ -0,0 +1,72 @@
package usecase
import (
"context"
"cursor-api-proxy/internal/config"
"cursor-api-proxy/pkg/infrastructure/process"
"os"
"path/filepath"
)
func init() {
process.MaxModeFn = RunMaxModePreflight
}
func cacheTokenForAccount(configDir string) {
if configDir == "" {
return
}
token := ReadKeychainToken()
if token != "" {
WriteCachedToken(configDir, token)
}
}
func AccountsDir() string {
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
return filepath.Join(home, ".cursor-api-proxy", "accounts")
}
func RunAgentSync(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, tempDir, configDir string, ctx context.Context) (process.RunResult, error) {
opts := process.RunOptions{
Cwd: workspaceDir,
TimeoutMs: cfg.TimeoutMs,
MaxMode: cfg.MaxMode,
ConfigDir: configDir,
Ctx: ctx,
}
result, err := process.Run(cfg.AgentBin, cmdArgs, opts)
cacheTokenForAccount(configDir)
if tempDir != "" {
os.RemoveAll(tempDir)
}
return result, err
}
func RunAgentStreamWithContext(cfg config.BridgeConfig, workspaceDir string, cmdArgs []string, onLine func(string), tempDir, configDir string, ctx context.Context) (process.StreamResult, error) {
opts := process.RunStreamingOptions{
RunOptions: process.RunOptions{
Cwd: workspaceDir,
TimeoutMs: cfg.TimeoutMs,
MaxMode: cfg.MaxMode,
ConfigDir: configDir,
Ctx: ctx,
},
OnLine: onLine,
}
result, err := process.RunStreaming(cfg.AgentBin, cmdArgs, opts)
cacheTokenForAccount(configDir)
if tempDir != "" {
os.RemoveAll(tempDir)
}
return result, err
}

95
pkg/usecase/sanitizer.go Normal file
View File

@ -0,0 +1,95 @@
package usecase
import "regexp"
type rule struct {
pattern *regexp.Regexp
replacement string
}
var rules = []rule{
{regexp.MustCompile(`(?i)x-anthropic-billing-header:[^\n]*\n?`), ""},
{regexp.MustCompile(`(?i)\bcc_version=[^\s;,\n]+[;,]?\s*`), ""},
{regexp.MustCompile(`(?i)\bcc_entrypoint=[^\s;,\n]+[;,]?\s*`), ""},
{regexp.MustCompile(`(?i)\bcch=[a-f0-9]+[;,]?\s*`), ""},
{regexp.MustCompile(`\bClaude Code\b`), "Cursor"},
{regexp.MustCompile(`(?i)Anthropic['']s official CLI for Claude`), "Cursor AI assistant"},
{regexp.MustCompile(`\bAnthropic\b`), "Cursor"},
{regexp.MustCompile(`(?i)anthropic\.com`), "cursor.com"},
{regexp.MustCompile(`(?i)claude\.ai`), "cursor.sh"},
{regexp.MustCompile(`^[;,\s]+`), ""},
}
func SanitizeText(text string) string {
for _, r := range rules {
text = r.pattern.ReplaceAllString(text, r.replacement)
}
return text
}
func SanitizeMessages(messages []interface{}) []interface{} {
result := make([]interface{}, len(messages))
for i, raw := range messages {
if raw == nil {
result[i] = raw
continue
}
m, ok := raw.(map[string]interface{})
if !ok {
result[i] = raw
continue
}
newMsg := make(map[string]interface{}, len(m))
for k, v := range m {
newMsg[k] = v
}
switch c := m["content"].(type) {
case string:
newMsg["content"] = SanitizeText(c)
case []interface{}:
newParts := make([]interface{}, len(c))
for j, p := range c {
if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" {
if t, ok := pm["text"].(string); ok {
newPart := make(map[string]interface{}, len(pm))
for k, v := range pm {
newPart[k] = v
}
newPart["text"] = SanitizeText(t)
newParts[j] = newPart
continue
}
}
newParts[j] = p
}
newMsg["content"] = newParts
}
result[i] = newMsg
}
return result
}
func SanitizeSystem(system interface{}) interface{} {
switch v := system.(type) {
case string:
return SanitizeText(v)
case []interface{}:
result := make([]interface{}, len(v))
for i, p := range v {
if pm, ok := p.(map[string]interface{}); ok && pm["type"] == "text" {
if t, ok := pm["text"].(string); ok {
newPart := make(map[string]interface{}, len(pm))
for k, val := range pm {
newPart[k] = val
}
newPart["text"] = SanitizeText(t)
result[i] = newPart
continue
}
}
result[i] = p
}
return result
}
return system
}

Some files were not shown because too many files have changed in this diff Show More