From b50f9af1a28bd0d2decec034ad3572400b2ddb2f Mon Sep 17 00:00:00 2001 From: Chinh Tran Date: Sat, 28 Mar 2026 10:15:17 +0700 Subject: [PATCH 1/4] feat(providers): add CursorCLIProvider for Cursor agent integration Implement CursorCLIProvider mirroring ClaudeCLIProvider design: - Shell out to Cursor agent binary with CURSOR_API_KEY auth injection - Support chat, MCP bridge, and session management - Add CursorCLIConfig struct and provider registration - Fix missing API key guards in config and DB layers - Align code style and error handling with ClaudeCLIProvider - Update provider documentation --- cmd/gateway_consumer_handlers.go | 1 + cmd/gateway_providers.go | 49 +++++ docs/02-providers.md | 91 ++++++++- internal/config/config_channels.go | 11 ++ internal/config/config_load.go | 5 + internal/providers/cursor_cli.go | 82 +++++++++ internal/providers/cursor_cli_auth.go | 12 ++ internal/providers/cursor_cli_chat.go | 224 +++++++++++++++++++++++ internal/providers/cursor_cli_mcp.go | 51 ++++++ internal/providers/cursor_cli_parse.go | 22 +++ internal/providers/cursor_cli_session.go | 117 ++++++++++++ internal/store/provider_store.go | 2 + 12 files changed, 663 insertions(+), 4 deletions(-) create mode 100644 internal/providers/cursor_cli.go create mode 100644 internal/providers/cursor_cli_auth.go create mode 100644 internal/providers/cursor_cli_chat.go create mode 100644 internal/providers/cursor_cli_mcp.go create mode 100644 internal/providers/cursor_cli_parse.go create mode 100644 internal/providers/cursor_cli_session.go diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 9b7a25060..00d67e663 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -563,6 +563,7 @@ func handleResetCommand( sessStore.Reset(sessionKey) sessStore.Save(sessionKey) providers.ResetCLISession("", sessionKey) + providers.ResetCursorCLISession("", sessionKey) slog.Info("inbound: /reset command", "session", sessionKey) return true diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b505c68a2..aae8dd1ec 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -160,6 +160,27 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { slog.Info("registered provider", "name", "claude-cli") } + // Cursor CLI provider — API key auth, configurable agent binary path + if cfg.Providers.CursorCLI.APIKey != "" { + cliPath := cfg.Providers.CursorCLI.CLIPath + if cliPath == "" { + cliPath = "agent" + } + var opts []providers.CursorCLIOption + opts = append(opts, providers.WithCursorCLIAPIKey(cfg.Providers.CursorCLI.APIKey)) + if cfg.Providers.CursorCLI.Model != "" { + opts = append(opts, providers.WithCursorCLIModel(cfg.Providers.CursorCLI.Model)) + } + if cfg.Providers.CursorCLI.BaseWorkDir != "" { + opts = append(opts, providers.WithCursorCLIWorkDir(cfg.Providers.CursorCLI.BaseWorkDir)) + } + gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) + mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token) + opts = append(opts, providers.WithCursorCLIMCPConfigData(mcpData)) + registry.Register(providers.NewCursorCLIProvider(cliPath, opts...)) + slog.Info("registered provider", "name", "cursor-cli") + } + // ACP provider (config-based) — orchestrates any ACP-compatible agent binary if cfg.Providers.ACP.Binary != "" { registerACPFromConfig(registry, cfg.Providers.ACP) @@ -269,6 +290,34 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Info("registered provider from DB", "name", p.Name) continue } + if p.ProviderType == store.ProviderCursorCLI { + cliPath := p.APIBase //reuse APIBase field for CLI path + if cliPath == "" { + cliPath = "agent" + } + if cliPath != "agent" && !filepath.IsAbs(cliPath) { + slog.Warn("security.cursor_cli: invalid path from DB, using default", "path", cliPath) + cliPath = "agent" + } + if p.APIKey == "" { + slog.Warn("cursor-cli: no API key in DB provider, skipping", "name", p.Name) + continue + } + if _, err := exec.LookPath(cliPath); err != nil { + slog.Warn("cursor-cli: binary not found, skipping", "path", cliPath, "error", err) + continue + } + var cursorOpts []providers.CursorCLIOption + cursorOpts = append(cursorOpts, providers.WithCursorCLIAPIKey(p.APIKey)) + if gatewayAddr != "" { + mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken) + mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore) + cursorOpts = append(cursorOpts, providers.WithCursorCLIMCPConfigData(mcpData)) + } + registry.Register(providers.NewCursorCLIProvider(cliPath, cursorOpts...)) + slog.Info("registered provider from DB", "name", p.Name) + continue + } // ACP provider — no API key needed (agents manage their own auth). if p.ProviderType == store.ProviderACP { registerACPFromDB(registry, p) diff --git a/docs/02-providers.md b/docs/02-providers.md index d7f4a318c..745484646 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -1,6 +1,6 @@ # 02 - LLM Providers -GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Six concrete implementations exist: Anthropic (native HTTP+SSE), OpenAI-compatible (covering 10+ API endpoints), Claude CLI (local binary), Codex (OAuth-based), ACP (subagent orchestration), and DashScope (Alibaba Qwen with thinking). +GoClaw abstracts LLM communication behind a single `Provider` interface, allowing the agent loop to work with any backend without knowing the wire format. Seven concrete implementations exist: Anthropic (native HTTP+SSE), OpenAI-compatible (covering 10+ API endpoints), Claude CLI (local binary), Cursor CLI (local binary), Codex (OAuth-based), ACP (subagent orchestration), and DashScope (Alibaba Qwen with thinking). --- @@ -15,6 +15,7 @@ flowchart TD PI --> ANTH["Anthropic Provider
native net/http + SSE"] PI --> OAI["OpenAI-Compatible Provider
generic HTTP client"] PI --> CLAUDE["Claude CLI Provider
stdio subprocess"] + PI --> CURSOR["Cursor CLI Provider
stdio subprocess"] PI --> CODEX["Codex Provider
OAuth-based Responses API"] PI --> ACP["ACP Provider
JSON-RPC 2.0 subagents"] PI --> DASH["DashScope Provider
OpenAI-compat wrapper"] @@ -27,6 +28,7 @@ flowchart TD OAI --> GEM["Gemini API"] OAI --> OTHER["Mistral / xAI / MiniMax
Cohere / Perplexity / Ollama"] CLAUDE --> CLI["claude CLI binary
stdio + MCP bridge"] + CURSOR --> CURSOR_CLI["agent (Cursor) binary
stdio + MCP bridge"] CODEX --> CODEX_API["ChatGPT Responses API
chatgpt.com/backend-api"] ACP --> AGENTS["Claude Code / Codex
Gemini CLI agents"] DASH --> QWEN["Alibaba DashScope
Qwen3 models"] @@ -36,6 +38,7 @@ Authentication and timeouts vary by provider type: - **Anthropic**: `x-api-key` header + `anthropic-version: 2023-06-01` - **OpenAI-compatible**: `Authorization: Bearer` token - **Claude CLI**: stdio subprocess (no auth; uses local CLI session) +- **Cursor CLI**: `CURSOR_API_KEY` env var injection per-call - **Codex**: OAuth access token (auto-refreshed via TokenSource) - **ACP**: JSON-RPC 2.0 over subprocess stdio - **DashScope**: `Authorization: Bearer` token (inherits from OpenAI-compatible) @@ -46,12 +49,13 @@ All HTTP-based providers (Anthropic, OpenAI-compatible, Codex) use 300-second ti ## 2. Supported Providers -### Six Core Provider Types +### Seven Core Provider Types | Provider | Type | Configuration | Default Model | |----------|------|----------|---------------| | **anthropic** | Native HTTP + SSE | API key required | `claude-sonnet-4-5-20250929` | | **claude_cli** | stdio subprocess + MCP | Binary path (default: `claude`) | `sonnet` | +| **cursor_cli** | stdio subprocess + MCP | API key env var (default binary: `agent`) | `cursor-fast` | | **codex** | OAuth Responses API | OAuth token source | `gpt-5.3-codex` | | **acp** | JSON-RPC 2.0 subagents | Binary + workspace dir | `claude` | | **dashscope** | OpenAI-compat wrapper | API key + custom models | `qwen3-max` | @@ -557,7 +561,80 @@ Claude CLI inherits thinking support from the underlying Claude model. Thinking --- -## 12. Codex Provider +## 12. Cursor CLI Provider + +The Cursor CLI provider enables GoClaw to delegate requests to a local `agent` (Cursor) CLI binary. Like Claude CLI, it manages session history, context files, and tool execution independently. Cursor provides fast inference with multimodal support and extended context. + +### Architecture Overview + +```mermaid +flowchart TD + AL["Agent Loop"] -->|Chat / ChatStream| CLI["CursorCLIProvider"] + CLI --> POOL["SessionPool"] + POOL -->|spawn/reuse| PROC["Subprocess
agent --print --output-format stream-json"] + PROC -->|manages| SESS["Session
(chat ID, history)"] + + SESS -->|AGENTS.md system prompt| TOOLS["CLI Tool Execution"] + SESS -->|.cursor/mcp.json MCP config| TOOLS + SESS -->|--resume chatId| TOOLS + + TOOLS -->|via MCP| MCP["MCP Servers
(if configured)"] +``` + +### Configuration + +CursorCLIProvider can be configured in `config.json`: + +```json5 +{ + "providers": { + "cursor_cli": { + "api_key": "your-cursor-api-key", // optional; prefer CURSOR_API_KEY env + "model": "cursor-fast", // default model (cursor-fast, cursor-standard, etc.) + "base_work_dir": "/tmp/cursor-workspaces" // workspace directory base + } + } +} +``` + +Or via database `llm_providers` table with `provider_type = "cursor_cli"`. + +Environment variables: +- `CURSOR_API_KEY` — Cursor User API Key (injected per-call; overrides config) +- `GOCLAW_CURSOR_CLI_MODEL` — Default model override +- `GOCLAW_CURSOR_CLI_WORK_DIR` — Base workspace directory override + +### Session Management + +Each conversation gets a persistent session tied to `session_key` option. Sessions survive across multiple requests and maintain: +- Chat ID (server-assigned; persisted to `.cursor_session_id`) +- Workspace directory (for file operations) +- MCP server connections +- System prompt file (`AGENTS.md`) + +### Tool Execution + +Cursor CLI executes tools natively (filesystem, web, terminal). GoClaw forwards tool results back and lets the CLI loop continue. This mirrors Claude CLI's execution model. + +### Headless Flags + +The provider invokes `agent` with these critical flags: +- `--print` — stream output to stdout +- `--output-format stream-json` — use structured event format +- `--force` — bypass confirmation prompts +- `--trust` — skip workspace security dialogs +- `--workspace ` — set working directory +- `--approve-mcps` — auto-approve MCP server connections (if configured) +- `--resume ` — resume existing conversation (if session ID found) + +### Streaming + +- **Chat**: Returns complete response after CLI execution +- **ChatStream**: Streams text chunks as they are produced by the CLI + +--- + +## 14. Codex Provider The Codex provider integrates with OpenAI's ChatGPT Responses API (OAuth-based), enabling access to gpt-5.3-codex model through the chatgpt.com backend. Unlike standard OpenAI endpoints, Codex uses OAuth token refresh and a custom response format with "phase" markers. @@ -629,7 +706,7 @@ Tracks prompt, completion, and total tokens. `CacheCreationTokens` and `CacheRea --- -## 14. File Reference +## 15. File Reference | File | Purpose | |------|---------| @@ -649,6 +726,12 @@ Tracks prompt, completion, and total tokens. `CacheCreationTokens` and `CacheRea | `internal/providers/claude_cli_deny_patterns.go` | Path validation and deny pattern enforcement | | `internal/providers/claude_cli_hooks.go` | Security hooks configuration for CLI tool execution | | `internal/providers/claude_cli_types.go` | Internal types for CLI provider (session, config, options) | +| `internal/providers/cursor_cli.go` | CursorCLIProvider: orchestrates local cursor agent binary via stdio | +| `internal/providers/cursor_cli_chat.go` | Chat/ChatStream implementation for Cursor CLI provider | +| `internal/providers/cursor_cli_session.go` | Session management: workspace, system prompt, session ID persistence | +| `internal/providers/cursor_cli_mcp.go` | MCP configuration for Cursor CLI provider | +| `internal/providers/cursor_cli_auth.go` | API key validation for Cursor CLI | +| `internal/providers/cursor_cli_parse.go` | Response parsing and chat ID extraction from stream-json | | `internal/providers/codex.go` | CodexProvider: OAuth-based ChatGPT Responses API | | `internal/providers/codex_build.go` | Codex request builder: message formatting, phase handling | | `internal/providers/codex_types.go` | Codex request/response types and OAuth token management | diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 206f48120..414d731a3 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -209,6 +209,7 @@ type ProvidersConfig struct { Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed) OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required) ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` + CursorCLI CursorCLIConfig `json:"cursor_cli"` ACP ACPConfig `json:"acp"` } @@ -226,6 +227,15 @@ type ClaudeCLIConfig struct { PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions") } +// CursorCLIConfig configures the Cursor CLI provider. +// Uses CURSOR_API_KEY for headless authentication via the `agent` binary. +type CursorCLIConfig struct { + APIKey string `json:"api_key,omitempty"` // Cursor User API Key (prefer CURSOR_API_KEY env) + CLIPath string `json:"cli_path,omitempty"` // path to agent binary (default: "agent") + Model string `json:"model,omitempty"` // default model (default: "cursor-fast") + BaseWorkDir string `json:"base_work_dir,omitempty"` // base dir for agent workspaces +} + // ACPConfig configures the ACP (Agent Client Protocol) provider. // Orchestrates any ACP-compatible coding agent (Claude Code, Codex CLI, Gemini CLI) as a subprocess. type ACPConfig struct { @@ -304,6 +314,7 @@ func (c *Config) HasAnyProvider() bool { p.Ollama.Host != "" || p.OllamaCloud.APIKey != "" || p.ClaudeCLI.CLIPath != "" || + p.CursorCLI.APIKey != "" || p.ACP.Binary != "" } diff --git a/internal/config/config_load.go b/internal/config/config_load.go index d03294a98..1274fbff0 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -158,6 +158,11 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_CLAUDE_CLI_MODEL", &c.Providers.ClaudeCLI.Model) envStr("GOCLAW_CLAUDE_CLI_WORK_DIR", &c.Providers.ClaudeCLI.BaseWorkDir) + // Cursor CLI provider (CURSOR_API_KEY loaded in registration) + envStr("GOCLAW_CURSOR_CLI_PATH", &c.Providers.CursorCLI.CLIPath) + envStr("GOCLAW_CURSOR_CLI_MODEL", &c.Providers.CursorCLI.Model) + envStr("GOCLAW_CURSOR_CLI_WORK_DIR", &c.Providers.CursorCLI.BaseWorkDir) + // Default provider/model: env is fallback only (applied when config has no value). // The onboard wizard sets these in .env for initial bootstrap; once the user // saves a provider/model via the Dashboard, the config-file value wins. diff --git a/internal/providers/cursor_cli.go b/internal/providers/cursor_cli.go new file mode 100644 index 000000000..717619bfb --- /dev/null +++ b/internal/providers/cursor_cli.go @@ -0,0 +1,82 @@ +package providers + +import "sync" + +// CursorCLIProvider implements Provider by shelling out to the Cursor `agent` binary. +// Auth via CURSOR_API_KEY env var injection per-call. +// Sessions tracked via workdir/.cursor_session_id (server-assigned chat IDs). +type CursorCLIProvider struct { + cliPath string // path to agent binary (default: "agent") + defaultModel string // default: "cursor-fast" + apiKey string // injected as CURSOR_API_KEY per-call + baseWorkDir string // base dir for per-session workspaces + mcpConfigData *MCPConfigData // per-session MCP config data + mu sync.Mutex // protects workdir creation + sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock +} + +// CursorCLIOption configures the provider. +type CursorCLIOption func(*CursorCLIProvider) + +// WithCursorCLIModel sets the default model. +func WithCursorCLIModel(model string) CursorCLIOption { + return func(p *CursorCLIProvider) { + if model != "" { + p.defaultModel = model + } + } +} + +// WithCursorCLIWorkDir sets the base work directory. +func WithCursorCLIWorkDir(dir string) CursorCLIOption { + return func(p *CursorCLIProvider) { + if dir != "" { + p.baseWorkDir = dir + } + } +} + +// WithCursorCLIAPIKey sets the Cursor API key injected per-call. +func WithCursorCLIAPIKey(key string) CursorCLIOption { + return func(p *CursorCLIProvider) { + p.apiKey = key + } +} + +// WithCursorCLIMCPConfigData sets the per-session MCP config data. +func WithCursorCLIMCPConfigData(data *MCPConfigData) CursorCLIOption { + return func(p *CursorCLIProvider) { + p.mcpConfigData = data + } +} + +// NewCursorCLIProvider creates a provider that invokes the Cursor agent CLI. +func NewCursorCLIProvider(cliPath string, opts ...CursorCLIOption) *CursorCLIProvider { + if cliPath == "" { + cliPath = "agent" + } + p := &CursorCLIProvider{ + cliPath: cliPath, + defaultModel: "cursor-fast", + baseWorkDir: defaultCursorCLIWorkDir(), + // sessionMu is zero-value ready (sync.Map) + } + for _, opt := range opts { + opt(p) + } + return p +} + +func (p *CursorCLIProvider) Name() string { return "cursor-cli" } +func (p *CursorCLIProvider) DefaultModel() string { return p.defaultModel } + +// Close is a no-op — Cursor workspaces persist for session continuity. +func (p *CursorCLIProvider) Close() error { return nil } + +// lockSession acquires a per-session mutex to prevent concurrent CLI calls on the same session. +func (p *CursorCLIProvider) lockSession(sessionKey string) func() { + actual, _ := p.sessionMu.LoadOrStore(sessionKey, &sync.Mutex{}) + m := actual.(*sync.Mutex) + m.Lock() + return m.Unlock +} diff --git a/internal/providers/cursor_cli_auth.go b/internal/providers/cursor_cli_auth.go new file mode 100644 index 000000000..68b6014df --- /dev/null +++ b/internal/providers/cursor_cli_auth.go @@ -0,0 +1,12 @@ +package providers + +// CursorAuthStatus holds auth state for the Cursor CLI provider. +type CursorAuthStatus struct { + Authenticated bool +} + +// CheckCursorAuthStatus verifies an API key is configured. +// No subprocess needed — API key presence is sufficient for headless operation. +func CheckCursorAuthStatus(apiKey string) *CursorAuthStatus { + return &CursorAuthStatus{Authenticated: apiKey != ""} +} diff --git a/internal/providers/cursor_cli_chat.go b/internal/providers/cursor_cli_chat.go new file mode 100644 index 000000000..35104f405 --- /dev/null +++ b/internal/providers/cursor_cli_chat.go @@ -0,0 +1,224 @@ +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Chat runs the Cursor CLI synchronously and returns the final response. +func (p *CursorCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + systemPrompt, userMsg, _ := extractFromMessages(req.Messages) + sessionKey := extractStringOpt(req.Options, OptSessionKey) + model := req.Model + if model == "" { + model = p.defaultModel + } + + unlock := p.lockSession(sessionKey) + defer unlock() + + workDir := p.ensureWorkDir(sessionKey) + if systemPrompt != "" { + p.writeAgentsMD(workDir, systemPrompt) + } + + bc := bridgeContextFromOpts(req.Options) + hasMCP := p.resolveCursorMCPConfig(ctx, workDir, sessionKey, bc) + chatID := readCursorSessionID(workDir) + args := p.buildArgs(model, workDir, hasMCP, chatID, "json") + slog.Debug("cursor-cli exec", "cmd", fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")), "workdir", workDir) + args = append(args, userMsg) + + cmd := exec.CommandContext(ctx, p.cliPath, args...) + cmd.Dir = workDir + cmd.Env = append(filterCursorEnv(os.Environ()), "CURSOR_API_KEY="+p.apiKey) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("cursor-cli: %w (stderr: %s)", err, stderr.String()) + } + + resp, err := parseJSONResponse(output) + if err != nil { + return nil, err + } + + // Persist chat ID from JSON response for session continuity + if newChatID := parseCursorChatIDFromJSON(output); newChatID != "" { + writeCursorSessionID(workDir, newChatID) + } + + return resp, nil +} + +// ChatStream runs the Cursor CLI with stream-json output, calling onChunk for each text delta. +func (p *CursorCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + systemPrompt, userMsg, _ := extractFromMessages(req.Messages) + sessionKey := extractStringOpt(req.Options, OptSessionKey) + model := req.Model + if model == "" { + model = p.defaultModel + } + + slog.Debug("cursor-cli: acquiring session lock", "session_key", sessionKey) + unlock := p.lockSession(sessionKey) + slog.Debug("cursor-cli: session lock acquired", "session_key", sessionKey) + defer func() { + unlock() + slog.Debug("cursor-cli: session lock released", "session_key", sessionKey) + }() + + workDir := p.ensureWorkDir(sessionKey) + if systemPrompt != "" { + p.writeAgentsMD(workDir, systemPrompt) + } + + bc := bridgeContextFromOpts(req.Options) + hasMCP := p.resolveCursorMCPConfig(ctx, workDir, sessionKey, bc) + chatID := readCursorSessionID(workDir) + args := p.buildArgs(model, workDir, hasMCP, chatID, "stream-json") + fullCmd := fmt.Sprintf("%s %s", p.cliPath, strings.Join(args, " ")) + slog.Debug("cursor-cli stream exec", "cmd", fullCmd, "workdir", workDir) + args = append(args, userMsg) + + cmd := exec.CommandContext(ctx, p.cliPath, args...) + cmd.Dir = workDir + cmd.Env = append(filterCursorEnv(os.Environ()), "CURSOR_API_KEY="+p.apiKey) + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("cursor-cli stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("cursor-cli start: %w", err) + } + + // Debug log file: only enabled when GOCLAW_DEBUG=1 + var debugFile *os.File + if os.Getenv("GOCLAW_DEBUG") == "1" { + debugLogPath := filepath.Join(workDir, "cursor-cli-debug.log") + debugFile, _ = os.OpenFile(debugLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if debugFile != nil { + fmt.Fprintf(debugFile, "=== CMD: %s\n=== WORKDIR: %s\n=== TIME: %s\n\n", fullCmd, workDir, time.Now().Format(time.RFC3339)) + defer debugFile.Close() + } + } + + // Parse stream-json line-by-line + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, StdioScanBufInit), StdioScanBufMax) + + var finalResp ChatResponse + var contentBuf strings.Builder + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + // Write raw line to debug log + if debugFile != nil { + fmt.Fprintf(debugFile, "%s\n", line) + } + + var ev cursorStreamEvent + if err := json.Unmarshal(line, &ev); err != nil { + slog.Debug("cursor-cli: skip malformed stream line", "error", err) + continue + } + + switch ev.Type { + case "assistant": + if ev.Message == nil { + continue + } + text, thinking := extractStreamContent(ev.Message) + if text != "" { + contentBuf.WriteString(text) + onChunk(StreamChunk{Content: text}) + } + if thinking != "" { + onChunk(StreamChunk{Thinking: thinking}) + } + + case "result": + if ev.Result != "" { + finalResp.Content = ev.Result + } else { + finalResp.Content = contentBuf.String() + } + finalResp.FinishReason = "stop" + if ev.Subtype == "error" { + finalResp.FinishReason = "error" + } + if ev.Usage != nil { + finalResp.Usage = &Usage{ + PromptTokens: ev.Usage.InputTokens, + CompletionTokens: ev.Usage.OutputTokens, + TotalTokens: ev.Usage.InputTokens + ev.Usage.OutputTokens, + } + } + // Persist server-assigned chat ID for session continuity + if newChatID := extractCursorChatID(&ev); newChatID != "" { + writeCursorSessionID(workDir, newChatID) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("cursor-cli: stream read error: %w", err) + } + + if err := cmd.Wait(); err != nil { + if debugFile != nil { + fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n=== EXIT ERROR: %v\n", stderrBuf.String(), err) + } + // If we got partial content, return it with the error + if finalResp.Content != "" { + return &finalResp, nil + } + return nil, fmt.Errorf("cursor-cli: %w (stderr: %s)", err, stderrBuf.String()) + } + if debugFile != nil && stderrBuf.Len() > 0 { + fmt.Fprintf(debugFile, "\n=== STDERR:\n%s\n", stderrBuf.String()) + } + + // Fallback if no "result" event was received + if finalResp.Content == "" { + finalResp.Content = contentBuf.String() + finalResp.FinishReason = "stop" + } + + onChunk(StreamChunk{Done: true}) + return &finalResp, nil +} + +// parseCursorChatIDFromJSON extracts session_id from a non-streaming JSON response. +func parseCursorChatIDFromJSON(data []byte) string { + for _, line := range bytes.Split(bytes.TrimSpace(data), []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var ev cursorStreamEvent + if err := json.Unmarshal(line, &ev); err == nil && ev.Type == "result" && ev.SessionID != "" { + return ev.SessionID + } + } + return "" +} diff --git a/internal/providers/cursor_cli_mcp.go b/internal/providers/cursor_cli_mcp.go new file mode 100644 index 000000000..8c929a91d --- /dev/null +++ b/internal/providers/cursor_cli_mcp.go @@ -0,0 +1,51 @@ +package providers + +import ( + "context" + "log/slog" + "os" + "path/filepath" +) + +// resolveCursorMCPConfig writes the MCP config to /.cursor/mcp.json. +// Cursor reads project-level MCP config from this path (not via --mcp-config flag). +// Reuses WriteMCPConfig to generate the JSON, then copies to the Cursor-specific path. +// Returns true if MCP config was written, false if no config data or write failed. +func (p *CursorCLIProvider) resolveCursorMCPConfig(ctx context.Context, workDir, sessionKey string, bc BridgeContext) bool { + if p.mcpConfigData == nil { + return false + } + + // Generate config via shared helper (writes to mcp-configs//mcp-config.json) + srcPath := p.mcpConfigData.WriteMCPConfig(ctx, sessionKey, bc) + if srcPath == "" { + return false + } + + data, err := os.ReadFile(srcPath) + if err != nil { + slog.Warn("cursor-cli: failed to read mcp config source", "path", srcPath, "error", err) + return false + } + + // Ensure /.cursor/ directory exists + cursorDir := filepath.Join(workDir, ".cursor") + if err := os.MkdirAll(cursorDir, 0755); err != nil { + slog.Warn("cursor-cli: failed to create .cursor dir", "dir", cursorDir, "error", err) + return false + } + + targetPath := filepath.Join(cursorDir, "mcp.json") + + // Skip write if content unchanged + if existing, err := os.ReadFile(targetPath); err == nil && string(existing) == string(data) { + return true + } + + if err := os.WriteFile(targetPath, data, 0600); err != nil { + slog.Warn("cursor-cli: failed to write .cursor/mcp.json", "path", targetPath, "error", err) + return false + } + + return true +} diff --git a/internal/providers/cursor_cli_parse.go b/internal/providers/cursor_cli_parse.go new file mode 100644 index 000000000..f74f59470 --- /dev/null +++ b/internal/providers/cursor_cli_parse.go @@ -0,0 +1,22 @@ +package providers + +// cursorStreamEvent is a single line from Cursor's `--output-format stream-json`. +// Mirrors cliStreamEvent but adds SessionID for server-assigned chat ID tracking. +type cursorStreamEvent struct { + Type string `json:"type"` // "assistant", "result", "system", "tool_call" + Subtype string `json:"subtype,omitempty"` // "success", "error" + Message *cliStreamMsg `json:"message,omitempty"` // for type="assistant" (reuse shared type) + Result string `json:"result,omitempty"` // for type="result" + SessionID string `json:"session_id,omitempty"` // server-assigned chat ID on result event + Model string `json:"model,omitempty"` + Usage *cliUsage `json:"usage,omitempty"` // reuse shared type +} + +// extractCursorChatID returns the server-assigned chat ID from a result event. +// Returns "" if no chat ID is present — stateless fallback applies. +func extractCursorChatID(ev *cursorStreamEvent) string { + if ev.Type != "result" { + return "" + } + return ev.SessionID +} diff --git a/internal/providers/cursor_cli_session.go b/internal/providers/cursor_cli_session.go new file mode 100644 index 000000000..d1c89e630 --- /dev/null +++ b/internal/providers/cursor_cli_session.go @@ -0,0 +1,117 @@ +package providers + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// defaultCursorCLIWorkDir returns the base workspace directory for Cursor CLI sessions. +func defaultCursorCLIWorkDir() string { + return filepath.Join(config.ResolvedDataDirFromEnv(), "cursor-workspaces") +} + +// ensureWorkDir creates and returns a stable work directory for the given session key. +func (p *CursorCLIProvider) ensureWorkDir(sessionKey string) string { + safe := sanitizePathSegment(sessionKey) + dir := filepath.Join(p.baseWorkDir, safe) + + p.mu.Lock() + defer p.mu.Unlock() + + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Warn("cursor-cli: failed to create workdir", "dir", dir, "error", err) + return os.TempDir() + } + return dir +} + +// writeAgentsMD writes the system prompt to AGENTS.md in the work directory. +// Cursor agent reads this file automatically on every run. +// Skips write if content is unchanged to avoid unnecessary disk I/O. +func (p *CursorCLIProvider) writeAgentsMD(workDir, systemPrompt string) { + path := filepath.Join(workDir, "AGENTS.md") + if existing, err := os.ReadFile(path); err == nil && string(existing) == systemPrompt { + return + } + if err := os.WriteFile(path, []byte(systemPrompt), 0600); err != nil { + slog.Warn("cursor-cli: failed to write AGENTS.md", "path", path, "error", err) + } +} + +// buildArgs constructs CLI arguments for a Cursor agent invocation. +func (p *CursorCLIProvider) buildArgs(model, workDir string, hasMCP bool, chatID, outputFormat string) []string { + args := []string{ + "--print", + "--output-format", outputFormat, + "--model", model, + "--force", // bypass confirmation prompts (--yolo is alias) + "--trust", // skip workspace prompts in headless mode + "--workspace", workDir, + } + if hasMCP { + args = append(args, "--approve-mcps") + } + if chatID != "" { + args = append(args, "--resume", chatID) + } + return args +} + +// readCursorSessionID returns the persisted chat ID from workdir/.cursor_session_id. +// Returns "" if the file does not exist or cannot be read. +func readCursorSessionID(workDir string) string { + data, err := os.ReadFile(filepath.Join(workDir, ".cursor_session_id")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// writeCursorSessionID persists the server-assigned chat ID to workdir/.cursor_session_id. +func writeCursorSessionID(workDir, chatID string) { + if chatID == "" { + return + } + path := filepath.Join(workDir, ".cursor_session_id") + if err := os.WriteFile(path, []byte(chatID), 0600); err != nil { + slog.Warn("cursor-cli: failed to write session ID", "path", path, "error", err) + } +} + +// filterCursorEnv removes CURSOR* env vars to prevent nested session conflicts +// before injecting a fresh CURSOR_API_KEY. +func filterCursorEnv(environ []string) []string { + var filtered []string + for _, e := range environ { + key := e + if before, _, ok := strings.Cut(e, "="); ok { + key = before + } + if strings.HasPrefix(key, "CURSOR") { + continue + } + filtered = append(filtered, e) + } + return filtered +} + +// ResetCursorCLISession deletes session state for a given session key. +// Called on /reset to ensure the agent starts fresh. +func ResetCursorCLISession(baseWorkDir, sessionKey string) { + if baseWorkDir == "" { + baseWorkDir = defaultCursorCLIWorkDir() + } + safe := sanitizePathSegment(sessionKey) + workDir := filepath.Join(baseWorkDir, safe) + + for _, rel := range []string{".cursor_session_id", "AGENTS.md", ".cursor/mcp.json"} { + path := filepath.Join(workDir, rel) + if err := os.Remove(path); err == nil { + slog.Info("cursor-cli: deleted on /reset", "path", path) + } + } +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index e597d017a..dca39ecd9 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -31,6 +31,7 @@ const ( ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess + ProviderCursorCLI = "cursor_cli" // Cursor CLI via `agent` binary with API key auth ) // ValidProviderTypes lists all accepted provider_type values. @@ -57,6 +58,7 @@ var ValidProviderTypes = map[string]bool{ ProviderOllama: true, ProviderOllamaCloud: true, ProviderACP: true, + ProviderCursorCLI: true, } // LLMProviderData represents an LLM provider configuration. From 5292ad62abaf4a1928ee2e60772fffc5cd490cc0 Mon Sep 17 00:00:00 2001 From: Chinh Tran Date: Sat, 4 Apr 2026 11:25:54 +0700 Subject: [PATCH 2/4] feat(providers): wire Cursor CLI through gateway, config, HTTP API, and tests Add Cursor CLI provider implementation, session/auth/chat/MCP plumbing, gateway registration, provider store and verification, channel config, and unit tests including session tests. Made-with: Cursor --- cmd/gateway_providers.go | 19 ++- internal/config/config_channels.go | 104 +++++++-------- internal/config/config_load.go | 3 +- internal/http/provider_models.go | 43 ++++++ internal/http/provider_verify.go | 45 +++++++ internal/http/providers.go | 46 ++++++- internal/providers/cursor_cli.go | 32 +++-- internal/providers/cursor_cli_auth.go | 125 +++++++++++++++++- internal/providers/cursor_cli_auth_test.go | 43 ++++++ internal/providers/cursor_cli_chat.go | 4 +- internal/providers/cursor_cli_session.go | 37 +++++- internal/providers/cursor_cli_session_test.go | 56 ++++++++ internal/store/provider_store.go | 2 +- 13 files changed, 464 insertions(+), 95 deletions(-) create mode 100644 internal/providers/cursor_cli_auth_test.go create mode 100644 internal/providers/cursor_cli_session_test.go diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index aae8dd1ec..3c5a33bc5 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -160,20 +160,19 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { slog.Info("registered provider", "name", "claude-cli") } - // Cursor CLI provider — API key auth, configurable agent binary path - if cfg.Providers.CursorCLI.APIKey != "" { + // Cursor CLI — browser auth on the server (`agent login`). + if cfg.Providers.CursorCLI.CLIPath != "" { cliPath := cfg.Providers.CursorCLI.CLIPath - if cliPath == "" { - cliPath = "agent" - } var opts []providers.CursorCLIOption - opts = append(opts, providers.WithCursorCLIAPIKey(cfg.Providers.CursorCLI.APIKey)) if cfg.Providers.CursorCLI.Model != "" { opts = append(opts, providers.WithCursorCLIModel(cfg.Providers.CursorCLI.Model)) } if cfg.Providers.CursorCLI.BaseWorkDir != "" { opts = append(opts, providers.WithCursorCLIWorkDir(cfg.Providers.CursorCLI.BaseWorkDir)) } + if cfg.Providers.CursorCLI.PermMode != "" { + opts = append(opts, providers.WithCursorCLIPermMode(cfg.Providers.CursorCLI.PermMode)) + } gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token) opts = append(opts, providers.WithCursorCLIMCPConfigData(mcpData)) @@ -299,16 +298,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Warn("security.cursor_cli: invalid path from DB, using default", "path", cliPath) cliPath = "agent" } - if p.APIKey == "" { - slog.Warn("cursor-cli: no API key in DB provider, skipping", "name", p.Name) - continue - } if _, err := exec.LookPath(cliPath); err != nil { slog.Warn("cursor-cli: binary not found, skipping", "path", cliPath, "error", err) continue } var cursorOpts []providers.CursorCLIOption - cursorOpts = append(cursorOpts, providers.WithCursorCLIAPIKey(p.APIKey)) + if pm := providers.PermModeFromCursorCLISettings(p.Settings); pm != "" { + cursorOpts = append(cursorOpts, providers.WithCursorCLIPermMode(pm)) + } if gatewayAddr != "" { mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken) mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore) diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 414d731a3..6a78ce5fb 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -24,24 +24,24 @@ type ChannelsConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled"` - Token string `json:"token"` - Proxy string `json:"proxy,omitempty"` - APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081") - AllowFrom FlexibleStringSlice `json:"allow_from"` - DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" - GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" - RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) - HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) - DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively - GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively - DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit - ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true) - ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions - MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB) - LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true) - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) - ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken) + Enabled bool `json:"enabled"` + Token string `json:"token"` + Proxy string `json:"proxy,omitempty"` + APIServer string `json:"api_server,omitempty"` // custom Telegram Bot API server URL (e.g. "http://localhost:8081") + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true) + HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled) + DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively + GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively + DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit + ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true) + ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions + MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB) + LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + ForceIPv4 bool `json:"force_ipv4,omitempty"` // force IPv4 for all Telegram API requests (use when IPv6 routing is broken) // Optional STT (Speech-to-Text) pipeline for voice/audio inbound messages. // When stt_proxy_url is set, audio/voice messages are transcribed before being forwarded to the agent. @@ -191,19 +191,19 @@ type FeishuConfig struct { // ProvidersConfig maps provider name to its config. type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Gemini ProviderConfig `json:"gemini"` - DeepSeek ProviderConfig `json:"deepseek"` - Mistral ProviderConfig `json:"mistral"` - XAI ProviderConfig `json:"xai"` - MiniMax ProviderConfig `json:"minimax"` - Cohere ProviderConfig `json:"cohere"` - Perplexity ProviderConfig `json:"perplexity"` - DashScope ProviderConfig `json:"dashscope"` - Bailian ProviderConfig `json:"bailian"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI ProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Gemini ProviderConfig `json:"gemini"` + DeepSeek ProviderConfig `json:"deepseek"` + Mistral ProviderConfig `json:"mistral"` + XAI ProviderConfig `json:"xai"` + MiniMax ProviderConfig `json:"minimax"` + Cohere ProviderConfig `json:"cohere"` + Perplexity ProviderConfig `json:"perplexity"` + DashScope ProviderConfig `json:"dashscope"` + Bailian ProviderConfig `json:"bailian"` Zai ProviderConfig `json:"zai"` ZaiCoding ProviderConfig `json:"zai_coding"` Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed) @@ -227,13 +227,13 @@ type ClaudeCLIConfig struct { PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions") } -// CursorCLIConfig configures the Cursor CLI provider. -// Uses CURSOR_API_KEY for headless authentication via the `agent` binary. +// CursorCLIConfig configures the Cursor CLI provider (browser auth via `agent login` on the server). type CursorCLIConfig struct { - APIKey string `json:"api_key,omitempty"` // Cursor User API Key (prefer CURSOR_API_KEY env) CLIPath string `json:"cli_path,omitempty"` // path to agent binary (default: "agent") - Model string `json:"model,omitempty"` // default model (default: "cursor-fast") + Model string `json:"model,omitempty"` // default model (default: "composer-2") BaseWorkDir string `json:"base_work_dir,omitempty"` // base dir for agent workspaces + // "force" (default) — --force + --trust; "default" — --trust only; "sandbox" — force + trust + --sandbox enabled. + PermMode string `json:"perm_mode,omitempty" yaml:"perm_mode"` } // ACPConfig configures the ACP (Agent Client Protocol) provider. @@ -314,7 +314,7 @@ func (c *Config) HasAnyProvider() bool { p.Ollama.Host != "" || p.OllamaCloud.APIKey != "" || p.ClaudeCLI.CLIPath != "" || - p.CursorCLI.APIKey != "" || + p.CursorCLI.CLIPath != "" || p.ACP.Binary != "" } @@ -340,16 +340,16 @@ type QuotaConfig struct { // GatewayConfig controls the gateway server. type GatewayConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth - OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner" - AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all) - MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000) - RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) - InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" - InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled) - Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas + Host string `json:"host"` + Port int `json:"port"` + Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth + OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner" + AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all) + MaxMessageChars int `json:"max_message_chars,omitempty"` // max user message characters (default 32000) + RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled) + InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off" + InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled) + Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false) ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true) TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min) @@ -412,12 +412,12 @@ type BrowserToolConfig struct { // ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider). type ToolPolicySpec struct { - Profile string `json:"profile,omitempty"` - Allow []string `json:"allow,omitempty"` - Deny []string `json:"deny,omitempty"` - AlsoAllow []string `json:"alsoAllow,omitempty"` - ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` - ToolCallPrefix string `json:"toolCallPrefix,omitempty"` // prefix to strip from model's tool call names before registry lookup + Profile string `json:"profile,omitempty"` + Allow []string `json:"allow,omitempty"` + Deny []string `json:"deny,omitempty"` + AlsoAllow []string `json:"alsoAllow,omitempty"` + ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` + ToolCallPrefix string `json:"toolCallPrefix,omitempty"` // prefix to strip from model's tool call names before registry lookup } type WebToolsConfig struct { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 1274fbff0..1c023e19a 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -158,10 +158,11 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_CLAUDE_CLI_MODEL", &c.Providers.ClaudeCLI.Model) envStr("GOCLAW_CLAUDE_CLI_WORK_DIR", &c.Providers.ClaudeCLI.BaseWorkDir) - // Cursor CLI provider (CURSOR_API_KEY loaded in registration) + // Cursor CLI provider envStr("GOCLAW_CURSOR_CLI_PATH", &c.Providers.CursorCLI.CLIPath) envStr("GOCLAW_CURSOR_CLI_MODEL", &c.Providers.CursorCLI.Model) envStr("GOCLAW_CURSOR_CLI_WORK_DIR", &c.Providers.CursorCLI.BaseWorkDir) + envStr("GOCLAW_CURSOR_CLI_PERM_MODE", &c.Providers.CursorCLI.PermMode) // Default provider/model: env is fallback only (applied when config has no value). // The onboard wizard sets these in .env for initial bootstrap; once the user diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index ed2fa4fce..390c1c94e 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -51,6 +51,12 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht return } + // Cursor CLI — curated model list for the dashboard (no remote /models API; see cursorCLIModels). + if p.ProviderType == store.ProviderCursorCLI { + writeJSON(w, http.StatusOK, map[string]any{"models": cursorCLIModels()}) + return + } + if p.APIKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")}) return @@ -249,6 +255,43 @@ func claudeCLIModels() []ModelInfo { } } +// cursorCLIModels returns model IDs shown in the dashboard for Cursor CLI (`agent --model`). +// Curated from Cursor’s model catalog; IDs are kebab-case slugs — adjust if `agent help` / release notes rename them. +func cursorCLIModels() []ModelInfo { + return []ModelInfo{ + // Cursor + {ID: "composer-2", Name: "Cursor Composer 2"}, + {ID: "composer-1.5", Name: "Cursor Composer 1.5"}, + {ID: "composer-1", Name: "Cursor Composer 1"}, + // Anthropic + {ID: "claude-sonnet-4-6", Name: "Claude 4.6 Sonnet"}, + {ID: "claude-sonnet-4-5", Name: "Claude 4.5 Sonnet"}, + {ID: "claude-opus-4-6", Name: "Claude 4.6 Opus"}, + {ID: "claude-opus-4-6-fast", Name: "Claude 4.6 Opus-fast"}, + {ID: "claude-opus-4-5", Name: "Claude 4.5 Opus"}, + {ID: "claude-haiku-4-5", Name: "Claude 4.5 Haiku"}, + // OpenAI + {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"}, + {ID: "gpt-5.4", Name: "GPT-5.4"}, + {ID: "gpt-5.4-mini", Name: "GPT-5.4-mini"}, + {ID: "gpt-5.4-nano", Name: "GPT-5.4-nano"}, + {ID: "gpt-5.2", Name: "GPT-5.2"}, + {ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"}, + {ID: "gpt-5", Name: "GPT-5"}, + {ID: "gpt-5-codex", Name: "GPT-5 Codex"}, + {ID: "gpt-5-fast", Name: "GPT-5-fast"}, + {ID: "gpt-5-mini", Name: "GPT-5-mini"}, + // Google + {ID: "gemini-3.1-pro", Name: "Gemini 3.1 Pro"}, + {ID: "gemini-3-pro", Name: "Gemini 3 Pro"}, + {ID: "gemini-3-flash", Name: "Gemini 3 Flash"}, + {ID: "gemini-2.5-flash", Name: "Gemini 2.5 Flash"}, + // Other + {ID: "grok-4-20", Name: "Grok 4-20"}, + {ID: "kimi-k2-5", Name: "Kimi K2-5"}, + } +} + // acpModels returns the model aliases for ACP-compatible coding agents. func acpModels() []ModelInfo { return []ModelInfo{ diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 161dea9a4..6f1ce972e 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -77,6 +77,18 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } + // Cursor CLI: validate model ID locally + if p.ProviderType == store.ProviderCursorCLI { + for _, m := range cursorCLIModels() { + if m.ID == req.Model { + writeJSON(w, http.StatusOK, map[string]any{"valid": true}) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "Unknown model for Cursor CLI"}) + return + } + if h.providerReg == nil { writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "no provider registry available"}) return @@ -151,6 +163,39 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h }) } +// handleCursorCLIAuthStatus checks whether the Cursor CLI is authenticated on the server. +// +// GET /v1/providers/cursor-cli/auth-status +func (h *ProvidersHandler) handleCursorCLIAuthStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + cliPath := "agent" + if existing, err := h.store.ListProviders(r.Context()); err == nil { + for _, p := range existing { + if p.ProviderType == store.ProviderCursorCLI && p.APIBase != "" { + cliPath = p.APIBase + break + } + } + } + + status, err := providers.CheckCursorAuthStatus(ctx, cliPath) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "logged_in": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "logged_in": status.LoggedIn, + "email": status.Email, + "subscription_type": status.SubscriptionType, + }) +} + // isNonChatModel returns true for models that cannot be verified via Chat API // (image/video generation models). func isNonChatModel(model string) bool { diff --git a/internal/http/providers.go b/internal/http/providers.go index dabfa56ce..1dc5fa91b 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "path/filepath" "sync" "github.com/google/uuid" @@ -22,10 +23,10 @@ type ProvidersHandler struct { secretStore store.ConfigSecretsStore token string providerReg *providers.Registry - gatewayAddr string // for injecting MCP bridge into Claude CLI providers - mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers + gatewayAddr string // for injecting MCP bridge into Claude CLI providers + mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers apiBaseFallback func(providerType string) string // optional: config/env fallback for api_base - cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates + cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates msgBus *bus.MessageBus } @@ -92,6 +93,8 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { // Claude CLI auth status (global — not per-provider) mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus)) + // Cursor CLI auth status (global — not per-provider) + mux.HandleFunc("GET /v1/providers/cursor-cli/auth-status", h.auth(h.handleCursorCLIAuthStatus)) } func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -132,6 +135,27 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) return } + if p.ProviderType == store.ProviderCursorCLI { + cliPath := p.APIBase + if cliPath == "" { + cliPath = "agent" + } + if cliPath != "agent" && !filepath.IsAbs(cliPath) { + slog.Warn("security.cursor_cli: invalid path from API, using default", "path", cliPath) + cliPath = "agent" + } + var cursorOpts []providers.CursorCLIOption + if pm := providers.PermModeFromCursorCLISettings(p.Settings); pm != "" { + cursorOpts = append(cursorOpts, providers.WithCursorCLIPermMode(pm)) + } + if h.gatewayAddr != "" { + mcpData := providers.BuildCLIMCPConfigData(nil, h.gatewayAddr, h.token) + mcpData.AgentMCPLookup = h.mcpLookup + cursorOpts = append(cursorOpts, providers.WithCursorCLIMCPConfigData(mcpData)) + } + h.providerReg.Register(providers.NewCursorCLIProvider(cliPath, cursorOpts...)) + return + } if p.APIKey == "" { return } @@ -216,6 +240,22 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R } } + // Only one Cursor CLI row per instance (registry name is fixed to cursor-cli). + if p.ProviderType == store.ProviderCursorCLI { + h.cliMu.Lock() + defer h.cliMu.Unlock() + + existing, _ := h.store.ListProviders(r.Context()) + for _, ep := range existing { + if ep.ProviderType == store.ProviderCursorCLI { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": i18n.T(locale, i18n.MsgAlreadyExists, "Cursor CLI provider", "only one is allowed per instance"), + }) + return + } + } + } + if err := h.store.CreateProvider(r.Context(), &p); err != nil { slog.Error("providers.create", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/internal/providers/cursor_cli.go b/internal/providers/cursor_cli.go index 717619bfb..e451eb45f 100644 --- a/internal/providers/cursor_cli.go +++ b/internal/providers/cursor_cli.go @@ -3,16 +3,16 @@ package providers import "sync" // CursorCLIProvider implements Provider by shelling out to the Cursor `agent` binary. -// Auth via CURSOR_API_KEY env var injection per-call. +// Authentication is via browser login (`agent login` on the server); credentials are not passed through GoClaw. // Sessions tracked via workdir/.cursor_session_id (server-assigned chat IDs). type CursorCLIProvider struct { - cliPath string // path to agent binary (default: "agent") - defaultModel string // default: "cursor-fast" - apiKey string // injected as CURSOR_API_KEY per-call - baseWorkDir string // base dir for per-session workspaces + cliPath string // path to agent binary (default: "agent") + defaultModel string // default: "composer-2" + baseWorkDir string // base dir for per-session workspaces + permMode string // see WithCursorCLIPermMode / buildArgs mcpConfigData *MCPConfigData // per-session MCP config data - mu sync.Mutex // protects workdir creation - sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock + mu sync.Mutex // protects workdir creation + sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock } // CursorCLIOption configures the provider. @@ -36,17 +36,20 @@ func WithCursorCLIWorkDir(dir string) CursorCLIOption { } } -// WithCursorCLIAPIKey sets the Cursor API key injected per-call. -func WithCursorCLIAPIKey(key string) CursorCLIOption { +// WithCursorCLIMCPConfigData sets the per-session MCP config data. +func WithCursorCLIMCPConfigData(data *MCPConfigData) CursorCLIOption { return func(p *CursorCLIProvider) { - p.apiKey = key + p.mcpConfigData = data } } -// WithCursorCLIMCPConfigData sets the per-session MCP config data. -func WithCursorCLIMCPConfigData(data *MCPConfigData) CursorCLIOption { +// WithCursorCLIPermMode sets how the Cursor `agent` subprocess handles permissions in --print mode. +// Values: "force" (default) — --force and --trust; "default" — --trust only (no --force); "sandbox" — force + trust + --sandbox enabled. +func WithCursorCLIPermMode(mode string) CursorCLIOption { return func(p *CursorCLIProvider) { - p.mcpConfigData = data + if mode != "" { + p.permMode = mode + } } } @@ -57,8 +60,9 @@ func NewCursorCLIProvider(cliPath string, opts ...CursorCLIOption) *CursorCLIPro } p := &CursorCLIProvider{ cliPath: cliPath, - defaultModel: "cursor-fast", + defaultModel: "composer-2", baseWorkDir: defaultCursorCLIWorkDir(), + permMode: "force", // sessionMu is zero-value ready (sync.Map) } for _, opt := range opts { diff --git a/internal/providers/cursor_cli_auth.go b/internal/providers/cursor_cli_auth.go index 68b6014df..b765614f9 100644 --- a/internal/providers/cursor_cli_auth.go +++ b/internal/providers/cursor_cli_auth.go @@ -1,12 +1,125 @@ package providers -// CursorAuthStatus holds auth state for the Cursor CLI provider. +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// CursorAuthStatus is the gateway's view of whether the Cursor `agent` CLI is signed in. type CursorAuthStatus struct { - Authenticated bool + LoggedIn bool + Email string + SubscriptionType string +} + +type cursorAuthJSON struct { + LoggedIn bool `json:"loggedIn"` + Authenticated bool `json:"authenticated"` + Email string `json:"email,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` +} + +var ( + ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + // about: "User Email user@host" + cursorAboutEmailRe = regexp.MustCompile(`(?m)^User Email\s+(\S+)\s*$`) + // status: "✓ Logged in as user@host" (after ANSI strip) + cursorStatusEmailRe = regexp.MustCompile(`Logged in as\s+(\S+)`) +) + +func stripANSISequences(s string) string { + return ansiEscapeRe.ReplaceAllString(s, "") +} + +// CheckCursorAuthStatus reports whether the Cursor `agent` CLI is authenticated on the host. +// +// We invoke `agent status` (no extra flags). Output may be JSON (starts with '{') or plain +// text (e.g. "✓ Logged in as …"); we strip ANSI escapes and parse either form. +// +// If status output cannot be interpreted, we fall back to `agent about`. +// +// Login is `agent login`; credentials stay on disk for the CLI. +func CheckCursorAuthStatus(ctx context.Context, cliPath string) (*CursorAuthStatus, error) { + if cliPath == "" { + cliPath = "agent" + } + + resolved, err := exec.LookPath(cliPath) + if err != nil { + return nil, fmt.Errorf("agent CLI binary not found at %q: %w", cliPath, err) + } + + cmd := exec.CommandContext(ctx, resolved, "status") + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(stripANSISequences(string(out))) + + if st := parseStatusStdout(text); st != nil { + return st, nil + } + + out2, errAbout := exec.CommandContext(ctx, resolved, "about").CombinedOutput() + textAbout := strings.TrimSpace(stripANSISequences(string(out2))) + if st := parseAboutText(textAbout); st != nil { + return st, nil + } + + if err != nil { + return nil, fmt.Errorf("agent status failed: %w", err) + } + if errAbout != nil { + return nil, fmt.Errorf("agent about failed after unparsable status output: %w", errAbout) + } + return nil, fmt.Errorf("could not parse agent status or about output") +} + +// parseStatusStdout handles JSON or plain-text lines from `agent status`. +func parseStatusStdout(text string) *CursorAuthStatus { + if text == "" { + return nil + } + if strings.HasPrefix(text, "{") { + var j cursorAuthJSON + if err := json.Unmarshal([]byte(text), &j); err != nil { + return nil + } + loggedIn := j.LoggedIn || j.Authenticated + return &CursorAuthStatus{ + LoggedIn: loggedIn, + Email: j.Email, + SubscriptionType: j.SubscriptionType, + } + } + return parseStatusText(text) +} + +func parseAboutText(text string) *CursorAuthStatus { + m := cursorAboutEmailRe.FindStringSubmatch(text) + if len(m) < 2 { + return nil + } + email := strings.TrimSpace(m[1]) + if email == "" || email == "-" || strings.EqualFold(email, "none") { + return &CursorAuthStatus{LoggedIn: false} + } + return &CursorAuthStatus{LoggedIn: true, Email: email} } -// CheckCursorAuthStatus verifies an API key is configured. -// No subprocess needed — API key presence is sufficient for headless operation. -func CheckCursorAuthStatus(apiKey string) *CursorAuthStatus { - return &CursorAuthStatus{Authenticated: apiKey != ""} +func parseStatusText(text string) *CursorAuthStatus { + lower := strings.ToLower(text) + if strings.Contains(lower, "not logged in") || strings.Contains(lower, "sign in") { + return &CursorAuthStatus{LoggedIn: false} + } + m := cursorStatusEmailRe.FindStringSubmatch(text) + if len(m) < 2 { + return nil + } + email := strings.TrimSpace(m[1]) + if email == "" { + return &CursorAuthStatus{LoggedIn: false} + } + return &CursorAuthStatus{LoggedIn: true, Email: email} } diff --git a/internal/providers/cursor_cli_auth_test.go b/internal/providers/cursor_cli_auth_test.go new file mode 100644 index 000000000..ff2b73280 --- /dev/null +++ b/internal/providers/cursor_cli_auth_test.go @@ -0,0 +1,43 @@ +package providers + +import "testing" + +func TestStripANSISequences(t *testing.T) { + in := "\x1b[2K\x1b[GUser Email a@b.com" + got := stripANSISequences(in) + if got != "User Email a@b.com" { + t.Fatalf("got %q", got) + } +} + +func TestParseStatusStdoutJSON(t *testing.T) { + raw := `{"loggedIn":true,"email":"a@b.com"}` + st := parseStatusStdout(raw) + if st == nil || !st.LoggedIn || st.Email != "a@b.com" { + t.Fatalf("got %+v", st) + } +} + +func TestParseStatusStdoutText(t *testing.T) { + raw := "\n ✓ Logged in as tranchinh0718@gmail.com\n" + st := parseStatusStdout(stripANSISequences(raw)) + if st == nil || !st.LoggedIn || st.Email != "tranchinh0718@gmail.com" { + t.Fatalf("got %+v", st) + } +} + +func TestParseAboutText(t *testing.T) { + raw := "About Cursor CLI\n\nUser Email user@example.com\n" + st := parseAboutText(stripANSISequences(raw)) + if st == nil || !st.LoggedIn || st.Email != "user@example.com" { + t.Fatalf("got %+v", st) + } +} + +func TestParseStatusTextNotLoggedIn(t *testing.T) { + raw := "Not logged in. Run agent login.\n" + st := parseStatusText(stripANSISequences(raw)) + if st == nil || st.LoggedIn { + t.Fatalf("got %+v", st) + } +} diff --git a/internal/providers/cursor_cli_chat.go b/internal/providers/cursor_cli_chat.go index 35104f405..2d0ff6f17 100644 --- a/internal/providers/cursor_cli_chat.go +++ b/internal/providers/cursor_cli_chat.go @@ -40,7 +40,7 @@ func (p *CursorCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRes cmd := exec.CommandContext(ctx, p.cliPath, args...) cmd.Dir = workDir - cmd.Env = append(filterCursorEnv(os.Environ()), "CURSOR_API_KEY="+p.apiKey) + cmd.Env = filterCursorEnv(os.Environ()) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -94,7 +94,7 @@ func (p *CursorCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC cmd := exec.CommandContext(ctx, p.cliPath, args...) cmd.Dir = workDir - cmd.Env = append(filterCursorEnv(os.Environ()), "CURSOR_API_KEY="+p.apiKey) + cmd.Env = filterCursorEnv(os.Environ()) var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf diff --git a/internal/providers/cursor_cli_session.go b/internal/providers/cursor_cli_session.go index d1c89e630..4df7bef0d 100644 --- a/internal/providers/cursor_cli_session.go +++ b/internal/providers/cursor_cli_session.go @@ -1,6 +1,7 @@ package providers import ( + "encoding/json" "log/slog" "os" "path/filepath" @@ -44,14 +45,24 @@ func (p *CursorCLIProvider) writeAgentsMD(workDir, systemPrompt string) { // buildArgs constructs CLI arguments for a Cursor agent invocation. func (p *CursorCLIProvider) buildArgs(model, workDir string, hasMCP bool, chatID, outputFormat string) []string { + mode := strings.ToLower(strings.TrimSpace(p.permMode)) + if mode == "" { + mode = "force" + } args := []string{ "--print", "--output-format", outputFormat, "--model", model, - "--force", // bypass confirmation prompts (--yolo is alias) - "--trust", // skip workspace prompts in headless mode - "--workspace", workDir, } + // default / strict: omit --force (stricter tool policy; may be unsuitable for unattended --print) + if mode != "default" && mode != "strict" { + args = append(args, "--force") // bypass confirmation prompts (--yolo is alias) + } + args = append(args, "--trust") // skip workspace prompts in headless mode + if mode == "sandbox" { + args = append(args, "--sandbox", "enabled") + } + args = append(args, "--workspace", workDir) if hasMCP { args = append(args, "--approve-mcps") } @@ -61,6 +72,22 @@ func (p *CursorCLIProvider) buildArgs(model, workDir string, hasMCP bool, chatID return args } +type cursorCLISettingsJSON struct { + PermMode string `json:"perm_mode"` +} + +// PermModeFromCursorCLISettings returns perm_mode from llm_providers.settings JSON (empty if unset). +func PermModeFromCursorCLISettings(raw []byte) string { + if len(raw) == 0 { + return "" + } + var s cursorCLISettingsJSON + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return strings.TrimSpace(s.PermMode) +} + // readCursorSessionID returns the persisted chat ID from workdir/.cursor_session_id. // Returns "" if the file does not exist or cannot be read. func readCursorSessionID(workDir string) string { @@ -82,8 +109,8 @@ func writeCursorSessionID(workDir, chatID string) { } } -// filterCursorEnv removes CURSOR* env vars to prevent nested session conflicts -// before injecting a fresh CURSOR_API_KEY. +// filterCursorEnv removes inherited CURSOR* env vars so the `agent` subprocess uses on-disk auth from `agent login`, +// not a leaked key from the gateway process environment. func filterCursorEnv(environ []string) []string { var filtered []string for _, e := range environ { diff --git a/internal/providers/cursor_cli_session_test.go b/internal/providers/cursor_cli_session_test.go new file mode 100644 index 000000000..38731e470 --- /dev/null +++ b/internal/providers/cursor_cli_session_test.go @@ -0,0 +1,56 @@ +package providers + +import ( + "strings" + "testing" +) + +func TestCursorCLIProvider_buildArgs_permMode(t *testing.T) { + base := NewCursorCLIProvider("agent") + cases := []struct { + name string + permMode string + want []string + }{ + { + name: "default force", + want: []string{"--print", "--output-format", "json", "--model", "m", "--force", "--trust", "--workspace", "/w"}, + }, + { + name: "explicit force", + permMode: "force", + want: []string{"--print", "--output-format", "json", "--model", "m", "--force", "--trust", "--workspace", "/w"}, + }, + { + name: "default mode", + permMode: "default", + want: []string{"--print", "--output-format", "json", "--model", "m", "--trust", "--workspace", "/w"}, + }, + { + name: "sandbox", + permMode: "sandbox", + want: []string{"--print", "--output-format", "json", "--model", "m", "--force", "--trust", "--sandbox", "enabled", "--workspace", "/w"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := base + if tc.permMode != "" { + p = NewCursorCLIProvider("agent", WithCursorCLIPermMode(tc.permMode)) + } + got := p.buildArgs("m", "/w", false, "", "json") + if strings.Join(got, " ") != strings.Join(tc.want, " ") { + t.Fatalf("buildArgs: got %v want %v", got, tc.want) + } + }) + } +} + +func TestPermModeFromCursorCLISettings(t *testing.T) { + if got := PermModeFromCursorCLISettings([]byte(`{"perm_mode":"sandbox"}`)); got != "sandbox" { + t.Fatalf("got %q", got) + } + if got := PermModeFromCursorCLISettings(nil); got != "" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index dca39ecd9..ba64f8e6b 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -31,7 +31,7 @@ const ( ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess - ProviderCursorCLI = "cursor_cli" // Cursor CLI via `agent` binary with API key auth + ProviderCursorCLI = "cursor_cli" // Cursor CLI via local `agent` binary (browser auth) ) // ValidProviderTypes lists all accepted provider_type values. From 7f418f03870750d7a9acd37db1a0c963d19bf9e8 Mon Sep 17 00:00:00 2001 From: Chinh Tran Date: Sat, 4 Apr 2026 11:25:56 +0700 Subject: [PATCH 3/4] feat(ui): add Cursor CLI provider setup, forms, and i18n Provider constants, dashboard section, form dialogs, setup wizard step, and en/vi/zh strings. Made-with: Cursor --- ui/web/src/constants/providers.ts | 1 + ui/web/src/i18n/locales/en/providers.json | 16 +++ ui/web/src/i18n/locales/en/setup.json | 1 + ui/web/src/i18n/locales/vi/providers.json | 16 +++ ui/web/src/i18n/locales/vi/setup.json | 1 + ui/web/src/i18n/locales/zh/providers.json | 16 +++ ui/web/src/i18n/locales/zh/setup.json | 1 + .../providers/provider-cursor-cli-section.tsx | 109 ++++++++++++++++++ .../provider-advanced-dialog.tsx | 15 ++- .../provider-detail/provider-overview.tsx | 2 +- .../pages/providers/provider-form-dialog.tsx | 16 ++- ui/web/src/pages/providers/provider-utils.tsx | 3 +- ui/web/src/pages/setup/step-provider.tsx | 13 ++- 13 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 ui/web/src/pages/providers/provider-cursor-cli-section.tsx diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 29984e266..bd0f1291c 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -26,5 +26,6 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" }, { value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" }, { value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" }, + { value: "cursor_cli", label: "Cursor CLI (Local)", apiBase: "", placeholder: "" }, { value: "acp", label: "ACP Agent (Subprocess)", apiBase: "", placeholder: "claude" }, ]; diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index 3662129c0..55b414e50 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -133,6 +133,20 @@ "runOnServer": "Run on the server terminal:", "recheckButton": "Re-check" }, + "cursorCli": { + "description": "Cursor CLI uses your local", + "descriptionSuffix": "binary. Sign in with browser login on the server — GoClaw does not store a Cursor API key.", + "checkingAuth": "Checking authentication...", + "authenticatedAs": "Authenticated as", + "switchAccount": "Switch account?", + "switchAccountInstructions": "Run on the server terminal:", + "switchAccountRecheck": "Then click", + "switchAccountRecheckSuffix": "to re-check.", + "notAuthenticated": "Not authenticated", + "runOnServer": "Run on the server terminal:", + "recheckButton": "Re-check", + "authCheckFailed": "Failed to check auth status" + }, "detail": { "advanced": "Advanced", "identity": "Identity", @@ -143,6 +157,8 @@ "acpConfigDesc": "Agent subprocess settings", "cliConfig": "Claude CLI", "cliConfigDesc": "Local CLI authentication", + "cursorCliConfig": "Cursor CLI", + "cursorCliConfigDesc": "Local Cursor agent authentication", "oauthConfig": "OAuth", "oauthConfigDesc": "ChatGPT OAuth authentication", "nameReadonly": "Provider identifier, cannot be changed", diff --git a/ui/web/src/i18n/locales/en/setup.json b/ui/web/src/i18n/locales/en/setup.json index 2f69b6639..4190bcfaf 100644 --- a/ui/web/src/i18n/locales/en/setup.json +++ b/ui/web/src/i18n/locales/en/setup.json @@ -18,6 +18,7 @@ "provider": { "title": "Configure LLM Provider", "descriptionCli": "Connect using your local Claude CLI installation. No API key needed.", + "descriptionCursorCli": "Connect using the local Cursor CLI installation. No API key needed.", "descriptionOauth": "Sign in with your ChatGPT account to use your subscription through OAuth. No API key needed.", "description": "Connect to an AI provider to power your agents. You'll need an API key.", "providerType": "Provider Type", diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index 0fecc59ac..9f9a2d78f 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -133,6 +133,20 @@ "runOnServer": "Chạy trên terminal máy chủ:", "recheckButton": "Kiểm tra lại" }, + "cursorCli": { + "description": "Cursor CLI dùng nhị phân", + "descriptionSuffix": "cục bộ. Đăng nhập trình duyệt trên máy chủ — GoClaw không lưu API key Cursor.", + "checkingAuth": "Đang kiểm tra xác thực...", + "authenticatedAs": "Đã xác thực là", + "switchAccount": "Chuyển tài khoản?", + "switchAccountInstructions": "Chạy trên terminal máy chủ:", + "switchAccountRecheck": "Sau đó nhấp", + "switchAccountRecheckSuffix": "để kiểm tra lại.", + "notAuthenticated": "Chưa xác thực", + "runOnServer": "Chạy trên terminal máy chủ:", + "recheckButton": "Kiểm tra lại", + "authCheckFailed": "Không thể kiểm tra trạng thái xác thực" + }, "detail": { "advanced": "Nâng cao", "identity": "Danh tính", @@ -143,6 +157,8 @@ "acpConfigDesc": "Cài đặt tiến trình con agent", "cliConfig": "Claude CLI", "cliConfigDesc": "Xác thực CLI cục bộ", + "cursorCliConfig": "Cursor CLI", + "cursorCliConfigDesc": "Xác thực agent Cursor cục bộ", "oauthConfig": "OAuth", "oauthConfigDesc": "Xác thực OAuth ChatGPT", "nameReadonly": "Định danh provider, không thể thay đổi", diff --git a/ui/web/src/i18n/locales/vi/setup.json b/ui/web/src/i18n/locales/vi/setup.json index dd1fd1013..e22ebae04 100644 --- a/ui/web/src/i18n/locales/vi/setup.json +++ b/ui/web/src/i18n/locales/vi/setup.json @@ -18,6 +18,7 @@ "provider": { "title": "Cấu hình provider LLM", "descriptionCli": "Kết nối bằng Claude CLI cục bộ. Không cần API key.", + "descriptionCursorCli": "Kết nối bằng Cursor CLI cục bộ. Không cần API key.", "descriptionOauth": "Đăng nhập bằng tài khoản ChatGPT để dùng gói thuê bao qua OAuth. Không cần API key.", "description": "Kết nối với provider AI để cấp nguồn cho agent. Bạn cần có API key.", "providerType": "Loại provider", diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index c543fe0e7..8f423338a 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -133,6 +133,20 @@ "runOnServer": "在服务器终端运行:", "recheckButton": "重新检查" }, + "cursorCli": { + "description": "Cursor CLI 使用本地", + "descriptionSuffix": "二进制。在服务器上用浏览器登录 — GoClaw 不保存 Cursor API 密钥。", + "checkingAuth": "正在检查认证...", + "authenticatedAs": "已认证为", + "switchAccount": "切换账户?", + "switchAccountInstructions": "在服务器终端运行:", + "switchAccountRecheck": "然后点击", + "switchAccountRecheckSuffix": "重新检查。", + "notAuthenticated": "未认证", + "runOnServer": "在服务器终端运行:", + "recheckButton": "重新检查", + "authCheckFailed": "无法检查认证状态" + }, "detail": { "advanced": "高级设置", "identity": "身份", @@ -143,6 +157,8 @@ "acpConfigDesc": "Agent子进程设置", "cliConfig": "Claude CLI", "cliConfigDesc": "本地CLI认证", + "cursorCliConfig": "Cursor CLI", + "cursorCliConfigDesc": "本地 Cursor agent 认证", "oauthConfig": "OAuth", "oauthConfigDesc": "ChatGPT OAuth认证", "nameReadonly": "Provider标识符,无法更改", diff --git a/ui/web/src/i18n/locales/zh/setup.json b/ui/web/src/i18n/locales/zh/setup.json index cf52eff11..a4fc5c1f9 100644 --- a/ui/web/src/i18n/locales/zh/setup.json +++ b/ui/web/src/i18n/locales/zh/setup.json @@ -18,6 +18,7 @@ "provider": { "title": "配置 LLM Provider", "descriptionCli": "使用本地 Claude CLI 连接,无需 API 密钥。", + "descriptionCursorCli": "使用本机 Cursor CLI 连接,无需 API 密钥。", "descriptionOauth": "使用 ChatGPT 账号登录,通过 OAuth 使用您的订阅。无需 API 密钥。", "description": "连接 AI Provider为您的Agent提供支持,需要 API 密钥。", "providerType": "Provider类型", diff --git a/ui/web/src/pages/providers/provider-cursor-cli-section.tsx b/ui/web/src/pages/providers/provider-cursor-cli-section.tsx new file mode 100644 index 000000000..67f98e6fc --- /dev/null +++ b/ui/web/src/pages/providers/provider-cursor-cli-section.tsx @@ -0,0 +1,109 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2, AlertTriangle, RefreshCw } from "lucide-react"; +import { useHttp } from "@/hooks/use-ws"; + +interface CursorCLIAuthStatus { + logged_in: boolean; + email?: string; + subscription_type?: string; + error?: string; +} + +export function CursorCLISection({ open }: { open: boolean }) { + const { t } = useTranslation("providers"); + const http = useHttp(); + const [cliAuth, setCliAuth] = useState(null); + const [loading, setLoading] = useState(false); + + const checkAuth = useCallback(() => { + setLoading(true); + http + .get("/v1/providers/cursor-cli/auth-status") + .then(setCliAuth) + .catch(() => setCliAuth({ logged_in: false, error: t("cursorCli.authCheckFailed") })) + .finally(() => setLoading(false)); + }, [http, t]); + + useEffect(() => { + if (open) { + checkAuth(); + } else { + setCliAuth(null); + } + }, [open, checkAuth]); + + return ( +
+

+ {t("cursorCli.description")} agent{" "} + {t("cursorCli.descriptionSuffix")} +

+ {loading ? ( +
+ + {t("cursorCli.checkingAuth")} +
+ ) : cliAuth?.logged_in ? ( +
+
+
+ +

+ {t("cursorCli.authenticatedAs")} {cliAuth.email} + {cliAuth.subscription_type && ( + ({cliAuth.subscription_type}) + )} +

+
+ +
+
+ {t("cursorCli.switchAccount")} +
+

{t("cursorCli.switchAccountInstructions")}

+ agent logout && agent login +

+ {t("cursorCli.switchAccountRecheck")} {" "} + {t("cursorCli.switchAccountRecheckSuffix")} +

+
+
+
+ ) : cliAuth ? ( +
+
+
+ +

{t("cursorCli.notAuthenticated")}

+
+ +
+

{t("cursorCli.runOnServer")}

+ + agent login + + {cliAuth.error &&

{cliAuth.error}

} +
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx index d9697c8ea..8f8d7c378 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx @@ -20,6 +20,7 @@ import { import { ConfigGroupHeader } from "@/components/shared/config-group-header"; import { PROVIDER_TYPES } from "@/constants/providers"; import { CLISection } from "../provider-cli-section"; +import { CursorCLISection } from "../provider-cursor-cli-section"; import { OAuthSection } from "../provider-oauth-section"; import type { ProviderData, ProviderInput } from "@/types/provider"; @@ -52,8 +53,9 @@ export function ProviderAdvancedDialog({ const isACP = provider.provider_type === "acp"; const isCLI = provider.provider_type === "claude_cli"; + const isCursorCLI = provider.provider_type === "cursor_cli"; const isOAuth = provider.provider_type === "chatgpt_oauth"; - const isStandard = !isACP && !isCLI && !isOAuth; + const isStandard = !isACP && !isCLI && !isCursorCLI && !isOAuth; const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type); @@ -239,6 +241,17 @@ export function ProviderAdvancedDialog({ )} + {/* Cursor CLI */} + {isCursorCLI && ( + <> + + + + )} + {/* OAuth */} {isOAuth && ( <> diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index f9ffbcbe5..2bf83cff7 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -16,7 +16,7 @@ interface ProviderOverviewProps { onUpdate: (id: string, data: ProviderInput) => Promise; } -const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]); +const NO_API_KEY_TYPES = new Set(["claude_cli", "cursor_cli", "acp", "chatgpt_oauth"]); export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) { const { t } = useTranslation("providers"); diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 0e83ac47c..25a7662e2 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -25,6 +25,7 @@ import { slugify, isValidSlug } from "@/lib/slug"; import { PROVIDER_TYPES } from "@/constants/providers"; import { OAuthSection } from "./provider-oauth-section"; import { CLISection } from "./provider-cli-section"; +import { CursorCLISection } from "./provider-cursor-cli-section"; import { ACPSection } from "./provider-acp-section"; import { Loader2 } from "lucide-react"; @@ -55,9 +56,11 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi const [acpWorkDir, setAcpWorkDir] = useState(""); const hasClaudeCLI = existingProviders.some((p) => p.provider_type === "claude_cli"); + const hasCursorCLI = existingProviders.some((p) => p.provider_type === "cursor_cli"); const isOAuth = providerType === "chatgpt_oauth"; const isCLI = providerType === "claude_cli"; + const isCursorCLI = providerType === "cursor_cli"; const isACP = providerType === "acp"; useEffect(() => { @@ -127,6 +130,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi { @@ -185,6 +189,8 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi {isCLI && } + {isCursorCLI && } + {isACP && ( )} - {!isCLI && !isACP && ( + {!isCLI && !isCursorCLI && !isACP && ( <>
@@ -257,9 +263,10 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi ); } -function ProviderTypeSelect({ value, hasClaudeCLI, alreadyAddedLabel, providerTypeLabel, onChange }: { +function ProviderTypeSelect({ value, hasClaudeCLI, hasCursorCLI, alreadyAddedLabel, providerTypeLabel, onChange }: { value: string; hasClaudeCLI: boolean; + hasCursorCLI: boolean; alreadyAddedLabel: string; providerTypeLabel: string; onChange: (value: string) => void; @@ -276,12 +283,15 @@ function ProviderTypeSelect({ value, hasClaudeCLI, alreadyAddedLabel, providerTy {pt.label} {pt.value === "claude_cli" && hasClaudeCLI && ( {alreadyAddedLabel} )} + {pt.value === "cursor_cli" && hasCursorCLI && ( + {alreadyAddedLabel} + )} ))} diff --git a/ui/web/src/pages/providers/provider-utils.tsx b/ui/web/src/pages/providers/provider-utils.tsx index 9e9b8c0d4..c709ba3c0 100644 --- a/ui/web/src/pages/providers/provider-utils.tsx +++ b/ui/web/src/pages/providers/provider-utils.tsx @@ -9,6 +9,7 @@ const SPECIAL_VARIANTS: Record = { anthropic_native: "default", chatgpt_oauth: "default", claude_cli: "outline", + cursor_cli: "outline", acp: "outline", }; @@ -30,7 +31,7 @@ export function ProviderApiKeyBadge({ provider }: { provider: ProviderData }) { ); } - if (provider.provider_type === "claude_cli") { + if (provider.provider_type === "claude_cli" || provider.provider_type === "cursor_cli") { return ( {t("card.authenticated")} diff --git a/ui/web/src/pages/setup/step-provider.tsx b/ui/web/src/pages/setup/step-provider.tsx index 1d8696f9b..3a6447a07 100644 --- a/ui/web/src/pages/setup/step-provider.tsx +++ b/ui/web/src/pages/setup/step-provider.tsx @@ -17,6 +17,7 @@ import { import { PROVIDER_TYPES } from "@/constants/providers"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { CLISection } from "@/pages/providers/provider-cli-section"; +import { CursorCLISection } from "@/pages/providers/provider-cursor-cli-section"; import { OAuthSection } from "@/pages/providers/provider-oauth-section"; import { slugify } from "@/lib/slug"; import type { ProviderData, ProviderInput } from "@/types/provider"; @@ -44,8 +45,10 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps const isOAuth = providerType === "chatgpt_oauth"; const isCLI = providerType === "claude_cli"; + const isCursorCLI = providerType === "cursor_cli"; // Local Ollama uses no API key — the server accepts any non-empty Bearer value internally const isOllama = providerType === "ollama"; + const noApiKeyProvider = isCLI || isCursorCLI || isOllama; const handleTypeChange = (value: string) => { setProviderType(value); @@ -83,7 +86,7 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps const handleSubmit = async () => { if (isOAuth) return; - if (!isEditing && !isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } + if (!isEditing && !noApiKeyProvider && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } setLoading(true); setError(""); try { @@ -102,7 +105,7 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps name: name.trim(), provider_type: providerType, api_base: apiBase.trim() || undefined, - api_key: isCLI || isOllama || isOAuth ? undefined : apiKey.trim(), + api_key: noApiKeyProvider || isOAuth ? undefined : apiKey.trim(), enabled: true, }) as ProviderData; onComplete(provider); @@ -125,6 +128,8 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps ? t("provider.descriptionOauth") : isCLI ? t("provider.descriptionCli") + : isCursorCLI + ? t("provider.descriptionCursorCli") : t("provider.description")}

@@ -164,6 +169,8 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps /> ) : isCLI ? ( + ) : isCursorCLI ? ( + ) : ( <>
@@ -197,7 +204,7 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps {!isOAuth && (
-