12 KiB
12 KiB
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
// 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
// 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"
// 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
// 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
// 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:
// 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
# 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)
// 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
// 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
// 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
.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
- Cursor CLI stream-json 的確切 JSON schema?(需 scripts/test_cursor_cli.sh 確認)
- 是否需要
-ldflags減小 binary? - 是否需要 Goreleaser 做 release?