初始化仓库
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("cursor", New)
|
||||
}
|
||||
|
||||
// Agent drives the Cursor Agent CLI (`agent`) using --print --output-format stream-json.
|
||||
//
|
||||
// Modes (maps to Cursor agent CLI flags):
|
||||
// - "default": default permissions (ask permission for tools)
|
||||
// - "force": --force (auto-approve tools unless explicitly denied)
|
||||
// - "plan": --mode plan (read-only analysis)
|
||||
// - "ask": --mode ask (Q&A style, read-only)
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
cmd string // CLI binary name, default "agent"
|
||||
providers []core.ProviderConfig
|
||||
activeIdx int
|
||||
sessionEnv []string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Agent, error) {
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
if workDir == "" {
|
||||
workDir = "."
|
||||
}
|
||||
model, _ := opts["model"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
mode = normalizeMode(mode)
|
||||
cmd, _ := opts["cmd"].(string)
|
||||
if cmd == "" {
|
||||
cmd = "agent"
|
||||
}
|
||||
if _, err := exec.LookPath(cmd); err != nil {
|
||||
return nil, fmt.Errorf("cursor: %q CLI not found in PATH, install with: npm i -g @anthropic-ai/cursor-agent (or from Cursor IDE settings)", cmd)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
cmd: cmd,
|
||||
activeIdx: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "force", "yolo", "auto":
|
||||
return "force"
|
||||
case "plan":
|
||||
return "plan"
|
||||
case "ask":
|
||||
return "ask"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "cursor" }
|
||||
func (a *Agent) CLIBinaryName() string { return "agent" }
|
||||
func (a *Agent) CLIDisplayName() string { return "Cursor Agent" }
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("cursor: work_dir changed", "work_dir", dir)
|
||||
}
|
||||
|
||||
func (a *Agent) GetWorkDir() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.workDir
|
||||
}
|
||||
|
||||
func (a *Agent) SetModel(model string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.model = model
|
||||
slog.Info("cursor: model changed", "model", model)
|
||||
}
|
||||
|
||||
func (a *Agent) GetModel() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
|
||||
}
|
||||
|
||||
func (a *Agent) configuredModels() []core.ModelOption {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return core.GetProviderModels(a.providers, a.activeIdx)
|
||||
}
|
||||
|
||||
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
|
||||
if models := a.configuredModels(); len(models) > 0 {
|
||||
return models
|
||||
}
|
||||
a.mu.RLock()
|
||||
cmd := a.cmd
|
||||
extraEnv := a.providerEnvLocked()
|
||||
extraEnv = append(extraEnv, a.sessionEnv...)
|
||||
a.mu.RUnlock()
|
||||
|
||||
if models := fetchModelsFromAgentCLI(ctx, cmd, extraEnv); len(models) > 0 {
|
||||
return models
|
||||
}
|
||||
return cursorFallbackModels()
|
||||
}
|
||||
|
||||
// fetchModelsFromAgentCLI runs `agent models` and parses the output.
|
||||
// Output format: "model-id - Display Name (current)" or "model-id - Display Name"
|
||||
func fetchModelsFromAgentCLI(ctx context.Context, cmd string, extraEnv []string) []core.ModelOption {
|
||||
c := exec.CommandContext(ctx, cmd, "models")
|
||||
c.Env = append(os.Environ(), extraEnv...)
|
||||
out, err := c.Output()
|
||||
if err != nil {
|
||||
slog.Debug("cursor: agent models failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var models []core.ModelOption
|
||||
seen := make(map[string]struct{})
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line == "Available models" || strings.HasPrefix(line, "Tip:") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, " - ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(line[:idx])
|
||||
desc := strings.TrimSpace(line[idx+3:])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Remove trailing markers like "(current)", "(default)"
|
||||
desc = strings.TrimSuffix(desc, " (current)")
|
||||
desc = strings.TrimSuffix(desc, " (default)")
|
||||
desc = strings.TrimSpace(desc)
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
models = append(models, core.ModelOption{Name: name, Desc: desc})
|
||||
}
|
||||
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
|
||||
return models
|
||||
}
|
||||
|
||||
func cursorFallbackModels() []core.ModelOption {
|
||||
return []core.ModelOption{
|
||||
{Name: "claude-sonnet-4-20250514", Desc: "Claude Sonnet 4"},
|
||||
{Name: "claude-opus-4-20250514", Desc: "Claude Opus 4"},
|
||||
{Name: "gpt-4o", Desc: "GPT-4o"},
|
||||
{Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"},
|
||||
{Name: "cursor-small", Desc: "Cursor Small (fast)"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetSessionEnv(env []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sessionEnv = env
|
||||
}
|
||||
|
||||
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.RLock()
|
||||
model := a.model
|
||||
mode := a.mode
|
||||
cmd := a.cmd
|
||||
workDir := a.workDir
|
||||
extraEnv := a.providerEnvLocked()
|
||||
extraEnv = append(extraEnv, a.sessionEnv...)
|
||||
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
||||
if m := a.providers[a.activeIdx].Model; m != "" {
|
||||
model = m
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
return newCursorSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv)
|
||||
}
|
||||
|
||||
// ListSessions reads sessions from ~/.cursor/chats/<workspace_hash>/.
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
workDir := a.GetWorkDir()
|
||||
return listCursorSessions(workDir)
|
||||
}
|
||||
|
||||
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cursor: cannot determine home dir: %w", err)
|
||||
}
|
||||
workDir := a.GetWorkDir()
|
||||
hash := workspaceHash(workDir)
|
||||
dir := filepath.Join(homeDir, ".cursor", "chats", hash, sessionID)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("session not found: %s", sessionID)
|
||||
}
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// ── SkillProvider implementation ──────────────────────────────
|
||||
|
||||
func (a *Agent) SkillDirs() []string {
|
||||
workDir := a.GetWorkDir()
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
dirs := []string{filepath.Join(absDir, ".claude", "skills")}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".claude", "skills"))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// ── ContextCompressor implementation ──────────────────────────
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "" }
|
||||
|
||||
// ── ModeSwitcher ────────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) SetMode(mode string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mode = normalizeMode(mode)
|
||||
slog.Info("cursor: mode changed", "mode", a.mode)
|
||||
}
|
||||
|
||||
func (a *Agent) GetMode() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.mode
|
||||
}
|
||||
|
||||
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
||||
return []core.PermissionModeInfo{
|
||||
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Trust workspace, ask before tool use", DescZh: "信任工作区,工具调用前询问"},
|
||||
{Key: "force", Name: "Force (YOLO)", NameZh: "强制执行", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
||||
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only analysis, no edits", DescZh: "只读分析,不做修改"},
|
||||
{Key: "ask", Name: "Ask", NameZh: "问答模式", Desc: "Q&A style, read-only", DescZh: "问答风格,只读"},
|
||||
}
|
||||
}
|
||||
|
||||
// ── ProviderSwitcher ────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.providers = providers
|
||||
}
|
||||
|
||||
func (a *Agent) SetActiveProvider(name string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if name == "" {
|
||||
a.activeIdx = -1
|
||||
slog.Info("cursor: provider cleared")
|
||||
return true
|
||||
}
|
||||
for i, p := range a.providers {
|
||||
if p.Name == name {
|
||||
a.activeIdx = i
|
||||
slog.Info("cursor: provider switched", "provider", name)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
return &p
|
||||
}
|
||||
|
||||
func (a *Agent) ListProviders() []core.ProviderConfig {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
result := make([]core.ProviderConfig, len(a.providers))
|
||||
copy(result, a.providers)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agent) providerEnvLocked() []string {
|
||||
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
||||
return nil
|
||||
}
|
||||
p := a.providers[a.activeIdx]
|
||||
var env []string
|
||||
if p.APIKey != "" {
|
||||
env = append(env, "CURSOR_API_KEY="+p.APIKey)
|
||||
}
|
||||
for k, v := range p.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// ── Session listing ─────────────────────────────────────────────
|
||||
|
||||
// workspaceHash returns the MD5 hash that Cursor uses to organize chats by workspace.
|
||||
func workspaceHash(workDir string) string {
|
||||
abs, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
abs = workDir
|
||||
}
|
||||
h := md5.Sum([]byte(abs))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func listCursorSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cursor: cannot determine home dir: %w", err)
|
||||
}
|
||||
|
||||
hash := workspaceHash(workDir)
|
||||
chatsDir := filepath.Join(homeDir, ".cursor", "chats", hash)
|
||||
|
||||
entries, err := os.ReadDir(chatsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cursor: read chats dir: %w", err)
|
||||
}
|
||||
|
||||
var sessions []core.AgentSessionInfo
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
sessionID := entry.Name()
|
||||
dbPath := filepath.Join(chatsDir, sessionID, "store.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
meta := readSessionMeta(dbPath)
|
||||
msgCount, firstUserMsg := countSessionMessages(dbPath, meta.RootBlobID)
|
||||
|
||||
summary := meta.Name
|
||||
if summary == "" || summary == "New Agent" {
|
||||
if firstUserMsg != "" {
|
||||
summary = firstUserMsg
|
||||
} else {
|
||||
summary = sessionID[:12] + "..."
|
||||
}
|
||||
}
|
||||
if utf8.RuneCountInString(summary) > 60 {
|
||||
summary = string([]rune(summary)[:60]) + "..."
|
||||
}
|
||||
|
||||
sessions = append(sessions, core.AgentSessionInfo{
|
||||
ID: sessionID,
|
||||
Summary: summary,
|
||||
MessageCount: msgCount,
|
||||
ModifiedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
||||
})
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// sessionMeta holds metadata extracted from a Cursor chat store.db.
|
||||
type sessionMeta struct {
|
||||
AgentID string
|
||||
Name string
|
||||
Mode string
|
||||
RootBlobID string
|
||||
}
|
||||
|
||||
// readSessionMeta reads the meta table from store.db without importing database/sql.
|
||||
// The meta value at key "0" is already a hex-encoded JSON string in the TEXT column,
|
||||
// so we read it directly (no extra hex() wrapping) and decode once.
|
||||
func readSessionMeta(dbPath string) sessionMeta {
|
||||
sqliteBin, err := exec.LookPath("sqlite3")
|
||||
if err != nil {
|
||||
return sessionMeta{}
|
||||
}
|
||||
|
||||
out, err := exec.Command(sqliteBin, dbPath,
|
||||
"SELECT value FROM meta WHERE key='0' LIMIT 1;",
|
||||
).Output()
|
||||
if err != nil {
|
||||
return sessionMeta{}
|
||||
}
|
||||
|
||||
hexStr := strings.TrimSpace(string(out))
|
||||
if hexStr == "" {
|
||||
return sessionMeta{}
|
||||
}
|
||||
|
||||
decoded, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
// Fallback: value might be raw JSON (not hex-encoded) in some versions
|
||||
decoded = []byte(hexStr)
|
||||
}
|
||||
|
||||
var m struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
RootBlobID string `json:"latestRootBlobId"`
|
||||
}
|
||||
if json.Unmarshal(decoded, &m) != nil {
|
||||
return sessionMeta{}
|
||||
}
|
||||
|
||||
return sessionMeta{AgentID: m.AgentID, Name: m.Name, Mode: m.Mode, RootBlobID: m.RootBlobID}
|
||||
}
|
||||
|
||||
// countSessionMessages reads the root blob from store.db and counts conversation
|
||||
// messages. It also returns the first user message text as a summary fallback.
|
||||
// The root blob uses a protobuf-like encoding where field 1 (tag 0x0a, length 0x20)
|
||||
// entries are 32-byte SHA-256 references to child message blobs.
|
||||
func countSessionMessages(dbPath, rootBlobID string) (int, string) {
|
||||
if rootBlobID == "" {
|
||||
return 0, ""
|
||||
}
|
||||
sqliteBin, err := exec.LookPath("sqlite3")
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Read root blob header (first ~8KB is enough for counting refs)
|
||||
out, err := exec.Command(sqliteBin, dbPath,
|
||||
fmt.Sprintf("SELECT hex(substr(data,1,8192)) FROM blobs WHERE id='%s' LIMIT 1;", rootBlobID),
|
||||
).Output()
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
rootHex := strings.TrimSpace(string(out))
|
||||
rootBytes, err := hex.DecodeString(rootHex)
|
||||
if err != nil || len(rootBytes) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Count field-1 entries (0x0a 0x20 + 32-byte hash)
|
||||
var childIDs []string
|
||||
i := 0
|
||||
for i+33 < len(rootBytes) && rootBytes[i] == 0x0a && rootBytes[i+1] == 0x20 {
|
||||
childIDs = append(childIDs, hex.EncodeToString(rootBytes[i+2:i+34]))
|
||||
i += 34
|
||||
}
|
||||
if len(childIDs) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Read the first few children to find the first real user message for summary,
|
||||
// and count roles to determine message count (excluding system).
|
||||
msgCount := 0
|
||||
var firstUserMsg string
|
||||
limit := len(childIDs)
|
||||
if limit > 80 {
|
||||
limit = 80
|
||||
}
|
||||
|
||||
// Build a single query to read multiple children
|
||||
var ids []string
|
||||
for _, cid := range childIDs[:limit] {
|
||||
ids = append(ids, "'"+cid+"'")
|
||||
}
|
||||
query := fmt.Sprintf(
|
||||
"SELECT id, data FROM blobs WHERE id IN (%s);",
|
||||
strings.Join(ids, ","),
|
||||
)
|
||||
blobOut, err := exec.Command(sqliteBin, "-separator", "|", dbPath, query).Output()
|
||||
if err != nil {
|
||||
// Fallback: estimate from child count minus 1 (system message)
|
||||
if len(childIDs) > 1 {
|
||||
return len(childIDs) - 1, ""
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
roleCount := make(map[string]int)
|
||||
blobMap := make(map[string][]byte)
|
||||
for _, line := range strings.Split(string(blobOut), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
blobMap[parts[0]] = []byte(parts[1])
|
||||
}
|
||||
|
||||
for _, cid := range childIDs[:limit] {
|
||||
raw, ok := blobMap[cid]
|
||||
if !ok || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
roleCount[msg.Role]++
|
||||
if msg.Role == "user" && firstUserMsg == "" {
|
||||
if s, ok := msg.Content.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
// Skip injected context (XML tags, conversation summaries, etc.)
|
||||
if len(s) > 0 && !strings.HasPrefix(s, "<") && !strings.HasPrefix(s, "[") && !strings.HasPrefix(s, "{") {
|
||||
if utf8.RuneCountInString(s) > 50 {
|
||||
s = string([]rune(s)[:50]) + "..."
|
||||
}
|
||||
firstUserMsg = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msgCount = roleCount["user"] + roleCount["assistant"]
|
||||
if limit < len(childIDs) {
|
||||
// Extrapolate for remaining children
|
||||
total := len(childIDs)
|
||||
msgCount = msgCount * total / limit
|
||||
}
|
||||
|
||||
return msgCount, firstUserMsg
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgent_StartSessionWorkDirRace exercises concurrent SetWorkDir + StartSession.
|
||||
// Without the fix, StartSession reads a.workDir without holding a.mu while
|
||||
// SetWorkDir writes it under the lock, which Go's -race detector flags as a
|
||||
// data race. With the fix, the field is captured inside the existing critical
|
||||
// section and no race is reported.
|
||||
//
|
||||
// newCursorSession only initialises the session struct; it does not spawn the
|
||||
// Cursor agent CLI until Send() is called, so this test runs without requiring
|
||||
// the binary on PATH.
|
||||
func TestAgent_StartSessionWorkDirRace(t *testing.T) {
|
||||
a := &Agent{cmd: "agent", workDir: "/initial"}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(2)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.SetWorkDir(fmt.Sprintf("/path-%d", i))
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sess, err := a.StartSession(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Errorf("StartSession: %v", err)
|
||||
return
|
||||
}
|
||||
_ = sess.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func shortTestContext(t *testing.T) (context.Context, context.CancelFunc) {
|
||||
t.Helper()
|
||||
timeout := 30 * time.Second
|
||||
if deadline, ok := t.Deadline(); ok {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
timeout = 100 * time.Millisecond
|
||||
} else if remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
|
||||
func requireWorkingAgentCLI(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("skipping agent CLI test in CI (no real cursor agent available)")
|
||||
}
|
||||
if os.Getenv("SKIP_REAL_AGENT_CLI") != "" {
|
||||
t.Skip("skipping real agent CLI test (SKIP_REAL_AGENT_CLI is set)")
|
||||
}
|
||||
if _, err := exec.LookPath("agent"); err != nil {
|
||||
t.Skip("agent CLI not in PATH")
|
||||
}
|
||||
ctx, cancel := shortTestContext(t)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "agent", "models").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("agent CLI is not runnable in this environment: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelsFromAgentCLI(t *testing.T) {
|
||||
ctx, cancel := shortTestContext(t)
|
||||
defer cancel()
|
||||
requireWorkingAgentCLI(t)
|
||||
|
||||
models := fetchModelsFromAgentCLI(ctx, "agent", nil)
|
||||
if len(models) == 0 {
|
||||
t.Fatal("expected models from agent models, got none")
|
||||
}
|
||||
|
||||
// Verify format: each model has non-empty Name
|
||||
for i, m := range models {
|
||||
if m.Name == "" {
|
||||
t.Errorf("models[%d].Name is empty", i)
|
||||
}
|
||||
}
|
||||
// 运行 go test -v 时可见
|
||||
t.Logf("fetched %d models:", len(models))
|
||||
for i, m := range models {
|
||||
t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelsFromAgentCLI_FailsGracefully(t *testing.T) {
|
||||
ctx, cancel := shortTestContext(t)
|
||||
defer cancel()
|
||||
models := fetchModelsFromAgentCLI(ctx, "nonexistent-agent-xyz", nil)
|
||||
if len(models) != 0 {
|
||||
t.Errorf("expected empty when command fails, got %d models", len(models))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableModels_Fallback(t *testing.T) {
|
||||
// When agent models fails, should fall back to hardcoded list
|
||||
ctx, cancel := shortTestContext(t)
|
||||
defer cancel()
|
||||
a := &Agent{cmd: "nonexistent-cmd-that-will-fail"}
|
||||
models := a.AvailableModels(ctx)
|
||||
fallback := cursorFallbackModels()
|
||||
if len(models) != len(fallback) {
|
||||
t.Fatalf("fallback models length = %d, want %d", len(models), len(fallback))
|
||||
}
|
||||
for i := range models {
|
||||
if models[i].Name != fallback[i].Name {
|
||||
t.Errorf("models[%d].Name = %q, want %q", i, models[i].Name, fallback[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableModels_FetchFromAgent(t *testing.T) {
|
||||
requireWorkingAgentCLI(t)
|
||||
ctx, cancel := shortTestContext(t)
|
||||
defer cancel()
|
||||
|
||||
a := &Agent{cmd: "agent"}
|
||||
models := a.AvailableModels(ctx)
|
||||
if len(models) == 0 {
|
||||
t.Fatal("expected models from agent models, got none")
|
||||
}
|
||||
|
||||
t.Logf("AvailableModels returned %d models:", len(models))
|
||||
for i, m := range models {
|
||||
t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc)
|
||||
}
|
||||
|
||||
// Should have real models like gpt-5.3-codex, opus-4.6-thinking, etc.
|
||||
hasCodex := false
|
||||
for _, m := range models {
|
||||
if m.Name == "gpt-5.3-codex" || m.Name == "opus-4.6-thinking" || m.Name == "auto" {
|
||||
hasCodex = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCodex {
|
||||
t.Logf("models: %v", models)
|
||||
t.Log("agent models returned models but none of the expected ones (gpt-5.3-codex, opus-4.6-thinking, auto) - may be OK if CLI output format changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// cursorSession manages multi-turn conversations with the Cursor Agent CLI.
|
||||
// Each Send() launches a new `agent --print` process with --resume for continuity.
|
||||
type cursorSession struct {
|
||||
cmd string // CLI binary name
|
||||
workDir string
|
||||
model string
|
||||
mode string
|
||||
extraEnv []string
|
||||
events chan core.Event
|
||||
chatID atomic.Value // stores string — Cursor chat/session ID
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
|
||||
thinkingBuf strings.Builder // accumulate thinking deltas
|
||||
}
|
||||
|
||||
func newCursorSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*cursorSession, error) {
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
cs := &cursorSession{
|
||||
cmd: cmd,
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
extraEnv: extraEnv,
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
cs.alive.Store(true)
|
||||
|
||||
if resumeID != "" && resumeID != core.ContinueSession {
|
||||
cs.chatID.Store(resumeID)
|
||||
}
|
||||
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
func (cs *cursorSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if len(images) > 0 {
|
||||
slog.Warn("cursorSession: images not yet supported in CLI mode, ignoring")
|
||||
}
|
||||
if len(files) > 0 {
|
||||
filePaths := core.SaveFilesToDisk(cs.workDir, files)
|
||||
prompt = core.AppendFileRefs(prompt, filePaths)
|
||||
}
|
||||
if !cs.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
|
||||
chatID := cs.CurrentSessionID()
|
||||
isResume := chatID != ""
|
||||
|
||||
args := []string{
|
||||
"--print",
|
||||
"--output-format", "stream-json",
|
||||
}
|
||||
|
||||
switch cs.mode {
|
||||
case "force":
|
||||
args = append(args, "--force")
|
||||
case "plan":
|
||||
args = append(args, "--mode", "plan")
|
||||
case "ask":
|
||||
args = append(args, "--mode", "ask")
|
||||
}
|
||||
|
||||
if isResume {
|
||||
args = append(args, "--resume", chatID)
|
||||
}
|
||||
if cs.model != "" {
|
||||
args = append(args, "--model", cs.model)
|
||||
}
|
||||
args = append(args, "--workspace", cs.workDir, "--", prompt)
|
||||
|
||||
slog.Debug("cursorSession: launching", "resume", isResume, "args", core.RedactArgs(args))
|
||||
|
||||
cmd := exec.CommandContext(cs.ctx, cs.cmd, args...)
|
||||
cmd.Dir = cs.workDir
|
||||
env := os.Environ()
|
||||
if len(cs.extraEnv) > 0 {
|
||||
env = core.MergeEnv(env, cs.extraEnv)
|
||||
}
|
||||
cmd.Env = env
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cursorSession: stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("cursorSession: start: %w", err)
|
||||
}
|
||||
|
||||
cs.wg.Add(1)
|
||||
go cs.readLoop(cmd, stdout, &stderrBuf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *cursorSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
|
||||
defer cs.wg.Done()
|
||||
defer func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
stderrMsg := strings.TrimSpace(stderrBuf.String())
|
||||
if stderrMsg != "" {
|
||||
slog.Error("cursorSession: process failed", "error", err, "stderr", stderrMsg)
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("cursorSession: raw", "line", truncateStr(line, 500))
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
slog.Debug("cursorSession: non-JSON line", "line", line)
|
||||
continue
|
||||
}
|
||||
|
||||
cs.handleEvent(raw)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
slog.Error("cursorSession: scanner error", "error", err)
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleEvent(raw map[string]any) {
|
||||
eventType, _ := raw["type"].(string)
|
||||
|
||||
switch eventType {
|
||||
case "system":
|
||||
cs.handleSystem(raw)
|
||||
|
||||
case "user":
|
||||
// User echo — nothing to do
|
||||
|
||||
case "thinking":
|
||||
cs.handleThinking(raw)
|
||||
|
||||
case "assistant":
|
||||
cs.handleAssistant(raw)
|
||||
|
||||
case "tool_call":
|
||||
cs.handleToolCall(raw)
|
||||
|
||||
case "interaction_query":
|
||||
cs.handleInteractionQuery(raw)
|
||||
|
||||
case "result":
|
||||
cs.handleResult(raw)
|
||||
|
||||
default:
|
||||
slog.Debug("cursorSession: unhandled event", "type", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleSystem(raw map[string]any) {
|
||||
if sid, ok := raw["session_id"].(string); ok && sid != "" {
|
||||
cs.chatID.Store(sid)
|
||||
slog.Debug("cursorSession: session init", "session_id", sid)
|
||||
|
||||
model, _ := raw["model"].(string)
|
||||
evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleThinking(raw map[string]any) {
|
||||
subtype, _ := raw["subtype"].(string)
|
||||
switch subtype {
|
||||
case "delta":
|
||||
if text, _ := raw["text"].(string); text != "" {
|
||||
cs.thinkingBuf.WriteString(text)
|
||||
}
|
||||
default:
|
||||
text := cs.thinkingBuf.String()
|
||||
cs.thinkingBuf.Reset()
|
||||
if text != "" {
|
||||
evt := core.Event{Type: core.EventThinking, Content: text}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleAssistant(raw map[string]any) {
|
||||
msg, ok := raw["message"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contentArr, ok := msg["content"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, contentItem := range contentArr {
|
||||
item, ok := contentItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
contentType, _ := item["type"].(string)
|
||||
if contentType == "text" {
|
||||
if text, ok := item["text"].(string); ok && text != "" {
|
||||
evt := core.Event{Type: core.EventText, Content: text}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleToolCall(raw map[string]any) {
|
||||
subtype, _ := raw["subtype"].(string)
|
||||
tc, _ := raw["tool_call"].(map[string]any)
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if subtype == "started" {
|
||||
name, input := extractToolInfo(tc)
|
||||
if name != "" {
|
||||
evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: input}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// "completed" tool_call events contain results; we log but don't emit to chat
|
||||
if subtype == "completed" {
|
||||
name, _ := extractToolInfo(tc)
|
||||
slog.Debug("cursorSession: tool completed", "tool", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleInteractionQuery(raw map[string]any) {
|
||||
subtype, _ := raw["subtype"].(string)
|
||||
if subtype != "request" {
|
||||
return
|
||||
}
|
||||
|
||||
queryType, _ := raw["query_type"].(string)
|
||||
query, _ := raw["query"].(map[string]any)
|
||||
if query == nil {
|
||||
return
|
||||
}
|
||||
|
||||
toolName, input := extractInteractionQueryInfo(queryType, query)
|
||||
if toolName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func extractInteractionQueryInfo(queryType string, query map[string]any) (string, string) {
|
||||
switch queryType {
|
||||
case "webFetchRequestQuery":
|
||||
if inner, ok := query["webFetchRequestQuery"].(map[string]any); ok {
|
||||
if args, ok := inner["args"].(map[string]any); ok {
|
||||
url, _ := args["url"].(string)
|
||||
return "WebFetch", url
|
||||
}
|
||||
}
|
||||
case "shellRequestQuery":
|
||||
if inner, ok := query["shellRequestQuery"].(map[string]any); ok {
|
||||
if args, ok := inner["args"].(map[string]any); ok {
|
||||
cmd, _ := args["command"].(string)
|
||||
return "Bash", cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(queryType, "RequestQuery")
|
||||
name = strings.TrimSuffix(name, "Query")
|
||||
if name == "" {
|
||||
name = queryType
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
|
||||
// extractToolInfo parses the nested tool_call structure from Cursor's stream-json.
|
||||
// Tool calls can be shellToolCall, readToolCall, editToolCall, etc.
|
||||
func extractToolInfo(tc map[string]any) (name string, input string) {
|
||||
toolTypes := []struct {
|
||||
key string
|
||||
toolName string
|
||||
}{
|
||||
{"shellToolCall", "Bash"},
|
||||
{"readToolCall", "Read"},
|
||||
{"editToolCall", "Edit"},
|
||||
{"writeToolCall", "Write"},
|
||||
{"listToolCall", "List"},
|
||||
{"searchToolCall", "Search"},
|
||||
{"grepToolCall", "Grep"},
|
||||
{"globToolCall", "Glob"},
|
||||
{"webFetchToolCall", "WebFetch"},
|
||||
}
|
||||
|
||||
for _, tt := range toolTypes {
|
||||
if call, ok := tc[tt.key].(map[string]any); ok {
|
||||
name = tt.toolName
|
||||
input = extractToolInput(name, call)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generic: try "description" field at top level
|
||||
if desc, ok := tc["description"].(string); ok && desc != "" {
|
||||
return "Tool", truncateStr(desc, 200)
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func extractToolInput(toolName string, call map[string]any) string {
|
||||
args, _ := call["args"].(map[string]any)
|
||||
if args == nil {
|
||||
if desc, ok := call["description"].(string); ok {
|
||||
return desc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
switch toolName {
|
||||
case "Bash":
|
||||
if cmd, ok := args["command"].(string); ok {
|
||||
return cmd
|
||||
}
|
||||
case "Read":
|
||||
if p, ok := args["path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "Edit", "Write":
|
||||
if p, ok := args["path"].(string); ok {
|
||||
return p
|
||||
}
|
||||
if p, ok := args["filePath"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "Grep":
|
||||
if p, ok := args["pattern"].(string); ok {
|
||||
return p
|
||||
}
|
||||
case "Glob":
|
||||
if p, ok := args["pattern"].(string); ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if desc, ok := call["description"].(string); ok && desc != "" {
|
||||
return desc
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(args)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (cs *cursorSession) handleResult(raw map[string]any) {
|
||||
var content string
|
||||
if result, ok := raw["result"].(string); ok {
|
||||
content = result
|
||||
}
|
||||
if sid, ok := raw["session_id"].(string); ok && sid != "" {
|
||||
cs.chatID.Store(sid)
|
||||
}
|
||||
evt := core.Event{Type: core.EventResult, Content: content, SessionID: cs.CurrentSessionID(), Done: true}
|
||||
select {
|
||||
case cs.events <- evt:
|
||||
case <-cs.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RespondPermission is a no-op — Cursor Agent permissions are handled via CLI default behavior or --force flag.
|
||||
func (cs *cursorSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *cursorSession) Events() <-chan core.Event {
|
||||
return cs.events
|
||||
}
|
||||
|
||||
func (cs *cursorSession) CurrentSessionID() string {
|
||||
v, _ := cs.chatID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (cs *cursorSession) Alive() bool {
|
||||
return cs.alive.Load()
|
||||
}
|
||||
|
||||
func (cs *cursorSession) Close() error {
|
||||
cs.alive.Store(false)
|
||||
cs.cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
cs.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
close(cs.events)
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("cursorSession: close timed out, abandoning wg.Wait")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:maxRunes]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user