457 lines
12 KiB
Markdown
457 lines
12 KiB
Markdown
# 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?
|