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

12 KiB
Raw Blame History

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

  1. Cursor CLI stream-json 的確切 JSON schema需 scripts/test_cursor_cli.sh 確認)
  2. 是否需要 -ldflags 減小 binary
  3. 是否需要 Goreleaser 做 release