# Code Design: Cursor Adapter ## Overview 將架構文件轉為 Go 程式碼層級設計。基於架構文件 `docs/architecture/2026-04-14-cursor-adapter.md`。 語言:Go 1.22+。遵循 `language-go` skill 的設計規範。 ## Project Structure ``` cursor-adapter/ ├── go.mod ├── go.sum ├── main.go # cobra CLI 入口 + wiring ├── config.example.yaml ├── Makefile ├── README.md ├── internal/ │ ├── config/ │ │ └── config.go # Config struct + Load() │ ├── bridge/ │ │ ├── bridge.go # CLIBridge struct + methods │ │ └── bridge_test.go │ ├── converter/ │ │ ├── convert.go # Cursor JSON → OpenAI SSE 轉換 │ │ └── convert_test.go │ └── server/ │ ├── server.go # chi router + handler wiring │ ├── handler.go # route handler functions │ ├── handler_test.go │ ├── sse.go # SSE write helpers │ └── models.go # request/response structs (JSON) └── scripts/ └── test_cursor_cli.sh # 探索性測試 Cursor CLI 輸出 ``` ### Package Responsibilities | Package | Responsibility | Exports | |---------|---------------|---------| | main (root) | CLI 入口、wiring | main() | | internal/config | YAML config 載入 + 預設值 | Config, Load() | | internal/bridge | spawn/manage Cursor CLI subprocess | Bridge interface, CLIBridge | | internal/converter | stream-json → OpenAI SSE 轉換 | ToOpenAIChunk(), ToOpenAIResponse() | | internal/server | HTTP routes, handlers, SSE | New(), Server.Run() | ## Layer Architecture ``` main.go (wiring) ↓ 建立 config, bridge, server internal/server (HTTP layer) ↓ handler 呼叫 bridge internal/bridge (CLI layer) ↓ spawn subprocess, 讀 stdout internal/converter (轉換 layer) ↓ JSON 轉 SSE Cursor CLI (外部) ``` ## Interface Definitions ```go // internal/bridge/bridge.go // Bridge 定義與外部 CLI 工具的整合介面。 type Bridge interface { // Execute 執行 prompt,透過 channel 逐行回傳 Cursor CLI 的 stdout。 // context timeout 時會 kill subprocess。 Execute(ctx context.Context, prompt string, model string) (<-chan string, <-chan error) // ListModels 回傳可用的模型列表。 ListModels(ctx context.Context) ([]string, error) // CheckHealth 確認 Cursor CLI 是否可用。 CheckHealth(ctx context.Context) error } // CLIBridge 實作 Bridge,透過 os/exec spawn Cursor CLI。 type CLIBridge struct { cursorPath string // "agent" 或 config 指定的路徑 semaphore chan struct{} // 限制並發數 timeout time.Duration } func NewCLIBridge(cursorPath string, maxConcurrent int, timeout time.Duration) *CLIBridge func (b *CLIBridge) Execute(ctx context.Context, prompt string, model string) (<-chan string, <-chan error) func (b *CLIBridge) ListModels(ctx context.Context) ([]string, error) func (b *CLIBridge) CheckHealth(ctx context.Context) error ``` ```go // internal/converter/convert.go // CursorLine 代表 Cursor CLI stream-json 的一行。 type CursorLine struct { Type string `json:"type"` // "assistant", "result", "error", etc. Content string `json:"content"` // 文字內容(type=assistant 時) } // ToOpenAIChunk 將一行 Cursor JSON 轉換為 OpenAI SSE chunk struct。 // 實際 JSON schema 需等 P2 探索確認後定義。 func ToOpenAIChunk(line string, chatID string) (*OpenAIChunk, error) // ToOpenAIResponse 將多行 Cursor output 組合為完整 response。 func ToOpenAIResponse(lines []string, chatID string) (*OpenAIResponse, error) // SSE 格式化 func FormatSSE(data any) string // "data: {json}\n\n" func FormatDone() string // "data: [DONE]\n\n" ``` ```go // internal/config/config.go type Config struct { Port int `yaml:"port"` CursorCLIPath string `yaml:"cursor_cli_path"` DefaultModel string `yaml:"default_model"` Timeout int `yaml:"timeout"` // seconds MaxConcurrent int `yaml:"max_concurrent"` LogLevel string `yaml:"log_level"` AvailableModels []string `yaml:"available_models"` // optional } // Load 從 YAML 檔載入配置,套用預設值。 // path 為空時使用預設路徑 ~/.cursor-adapter/config.yaml。 func Load(path string) (*Config, error) // Defaults 回傳預設配置。 func Defaults() Config ``` ## Domain Models ```go // internal/server/models.go // Request type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type ChatCompletionRequest struct { Model string `json:"model"` Messages []ChatMessage `json:"messages"` Stream bool `json:"stream"` Temperature *float64 `json:"temperature,omitempty"` } // Response (non-streaming) type ChatCompletionResponse struct { ID string `json:"id"` Object string `json:"object"` // "chat.completion" Choices []Choice `json:"choices"` Usage Usage `json:"usage"` } type Choice struct { Index int `json:"index"` Message ChatMessage `json:"message"` FinishReason string `json:"finish_reason"` } type Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } // Streaming chunk type ChatCompletionChunk struct { ID string `json:"id"` Object string `json:"object"` // "chat.completion.chunk" Choices []ChunkChoice `json:"choices"` } type ChunkChoice struct { Index int `json:"index"` Delta Delta `json:"delta"` FinishReason string `json:"finish_reason,omitempty"` } type Delta struct { Role *string `json:"role,omitempty"` Content *string `json:"content,omitempty"` } // Models list type ModelList struct { Object string `json:"object"` // "list" Data []ModelInfo `json:"data"` } type ModelInfo struct { ID string `json:"id"` Object string `json:"object"` // "model" 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"` } ``` ## Database Implementation Design N/A。無資料庫。 ## Error Design ```go // internal/server/handler.go 中定義 sentinel errors + 錯誤回傳邏輯 var ( ErrInvalidRequest = errors.New("invalid_request") ErrModelNotFound = errors.New("model_not_found") ErrCLITimeout = errors.New("cli_timeout") ErrCLICrash = errors.New("cli_crash") ErrCLINotAvailable = errors.New("cli_not_available") ) // writeError 將 error 轉換為 JSON error response 並回傳。 func writeError(w http.ResponseWriter, err error) { var status int var errType string switch { case errors.Is(err, ErrInvalidRequest): status = http.StatusBadRequest errType = "invalid_request" case errors.Is(err, ErrModelNotFound): status = http.StatusNotFound errType = "model_not_found" case errors.Is(err, ErrCLITimeout): status = http.StatusGatewayTimeout errType = "timeout" default: status = http.StatusInternalServerError errType = "internal_error" } // 回傳 ErrorResponse JSON } ``` ### Error-to-HTTP Mapping | Sentinel Error | HTTP Status | Error Type | |---------------|-------------|------------| | ErrInvalidRequest | 400 | invalid_request | | ErrModelNotFound | 404 | model_not_found | | ErrCLITimeout | 504 | timeout | | ErrCLICrash | 500 | internal_error | | ErrCLINotAvailable | 500 | internal_error | ## Dependency Injection 手動 wiring 在 `main.go`,使用 constructor injection: ```go // main.go func run(cmd *cobra.Command, args []string) error { // 1. Load config cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } // 2. Create bridge br := bridge.NewCLIBridge( cfg.CursorCLIPath, cfg.MaxConcurrent, time.Duration(cfg.Timeout)*time.Second, ) // 3. Check CLI availability if err := br.CheckHealth(context.Background()); err != nil { return fmt.Errorf("cursor cli not available: %w", err) } // 4. Create and run server srv := server.New(cfg, br) return srv.Run() } ``` ## Configuration ```yaml # config.example.yaml port: 8976 cursor_cli_path: agent default_model: claude-sonnet-4-20250514 timeout: 300 max_concurrent: 5 log_level: INFO # optional: 手動指定可用模型 available_models: - claude-sonnet-4-20250514 - claude-opus-4-20250514 - gpt-5.2 - gemini-3.1-pro ``` Config 載入順序:defaults → YAML → CLI flags。 ## Testing Architecture ### Unit Tests(table-driven) ```go // internal/converter/convert_test.go func TestToOpenAIChunk(t *testing.T) { tests := []struct { name string input string expected ChatCompletionChunk }{ {"assistant line", `{"type":"assistant","content":"Hello"}`, ...}, {"result line", `{"type":"result","content":"..."}`, ...}, {"empty line", "", ...}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // ... }) } } // internal/config/config_test.go func TestLoad(t *testing.T) { ... } func TestDefaults(t *testing.T) { ... } // internal/server/handler_test.go(使用 httptest) func TestHealthEndpoint(t *testing.T) { ... } func TestChatCompletionInvalid(t *testing.T) { ... } ``` ### Mock Strategy ```go // internal/bridge/mock_test.go type MockBridge struct { OutputLines []string // 預設回傳的 stdout lines Err error } func (m *MockBridge) Execute(ctx context.Context, prompt, model string) (<-chan string, <-chan error) { outCh := make(chan string) errCh := make(chan error, 1) go func() { defer close(outCh) defer close(errCh) for _, line := range m.OutputLines { outCh <- line } if m.Err != nil { errCh <- m.Err } }() return outCh, errCh } ``` ### Integration Tests ```go // internal/bridge/bridge_test.go(需要實際 Cursor CLI 環境) func TestExecuteSimple(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } // 實際 spawn agent } ``` ## Build & Deployment ### Makefile ```makefile .PHONY: build run test lint fmt build: go build -o bin/cursor-adapter . run: build ./bin/cursor-adapter --port 8976 test: go test ./... -v -short test-integration: go test ./... -v lint: golangci-lint run fmt: gofmt -w . goimports -w . cross: GOOS=darwin GOARCH=arm64 go build -o bin/cursor-adapter-darwin-arm64 . GOOS=darwin GOARCH=amd64 go build -o bin/cursor-adapter-darwin-amd64 . GOOS=linux GOARCH=amd64 go build -o bin/cursor-adapter-linux-amd64 . ``` ### go.mod ``` module github.com/daniel/cursor-adapter go 1.22 require ( github.com/go-chi/chi/v5 v5.x.x github.com/spf13/cobra v1.x.x gopkg.in/yaml.v3 v3.x.x ) ``` ## Architecture Traceability | Architecture Element | Code Design Element | |---------------------|-------------------| | HTTP Server (chi) | internal/server/server.go + handler.go | | CLI Bridge | internal/bridge/bridge.go — CLIBridge | | Stream Converter | internal/converter/convert.go | | Config Module | internal/config/config.go | | Error Handler | internal/server/handler.go — writeError() | | Request/Response | internal/server/models.go | | SSE | internal/server/sse.go | ## Code Design Review N/A(首次設計)。 ## Open Questions 1. Cursor CLI stream-json 的確切 JSON schema?(需 scripts/test_cursor_cli.sh 確認) 2. 是否需要 `-ldflags` 減小 binary? 3. 是否需要 Goreleaser 做 release?