opencode-cursor-agent/docs/code-design/2026-04-14-cursor-adapter.md

457 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 Teststable-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