diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 5caf19e92..84a1a9401 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -459,6 +459,7 @@ func handleResetCommand( deps.SessStore.Reset(ctx, sessionKey) deps.SessStore.Save(ctx, 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 c3e4418d1..277d5c66b 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -170,6 +170,26 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { slog.Info("registered provider", "name", "claude-cli") } + // Cursor CLI — browser auth on the server (`agent login`). + if cfg.Providers.CursorCLI.CLIPath != "" { + cliPath := cfg.Providers.CursorCLI.CLIPath + var opts []providers.CursorCLIOption + 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)) + 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) @@ -278,6 +298,32 @@ 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 _, err := exec.LookPath(cliPath); err != nil { + slog.Warn("cursor-cli: binary not found, skipping", "path", cliPath, "error", err) + continue + } + var cursorOpts []providers.CursorCLIOption + 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) + 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 937db8dcc..242a239b1 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,9 +15,11 @@ 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"] + PI --> CURSOR["Cursor CLI Provider
stdio subprocess"] ANTH --> ANTHROPIC["Claude API
api.anthropic.com/v1"] OAI --> OPENAI["OpenAI API"] @@ -27,18 +29,22 @@ 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"] + CURSOR --> CURSOR_CLI["agent (Cursor) binary
stdio + MCP bridge"] ``` 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) +- **Cursor CLI**: stdio subprocess (browser auth via `agent login` on the server; no API key in GoClaw) All HTTP-based providers (Anthropic, OpenAI-compatible, Codex) use 300-second timeout. @@ -46,16 +52,18 @@ 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` | | **openai** (+ 10+ variants) | OpenAI-compatible | API key + endpoint URL | Model-specific | +| **cursor_cli** | stdio subprocess + MCP | Binary path (default: `agent`), browser login | `composer-2` | ### OpenAI-Compatible Providers @@ -557,7 +565,98 @@ 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": { + "cli_path": "agent", // optional; default binary name or absolute path + "model": "composer-2", // default model + "base_work_dir": "/tmp/cursor-workspaces", // workspace directory base + "perm_mode": "force" // optional: "force" | "default" | "sandbox" (see Headless Flags) + } + } +} +``` + +Or via database `llm_providers` table with `provider_type = "cursor_cli"`. Store `perm_mode` in `settings` JSON (same values as `config.json`). + +### Authentication + +Prefer **browser authentication** on the machine where GoClaw runs (same pattern as Cursor docs): + +1. Run `agent login` on the server (`agent help login`; set `NO_OPEN_BROWSER` to skip opening a browser). +2. Check with `agent status` (output may be JSON or plain text). GoClaw exposes `GET /v1/providers/cursor-cli/auth-status`, which runs `agent status` and falls back to `agent about` if needed. + +GoClaw does not read or store Cursor API keys; subprocess env strips inherited `CURSOR*` variables so the CLI uses the on-disk session from `agent login`. + +Environment variables: + +- `GOCLAW_CURSOR_CLI_PATH` — CLI binary path override +- `GOCLAW_CURSOR_CLI_MODEL` — Default model override +- `GOCLAW_CURSOR_CLI_WORK_DIR` — Base workspace directory override +- `GOCLAW_CURSOR_CLI_PERM_MODE` — Permission mode override (`force`, `default`, `sandbox`) + +### 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 (omitted when `perm_mode` is `default` or `strict`) +- `--trust` — skip workspace security dialogs +- `--sandbox enabled` — added when `perm_mode` is `sandbox` +- `--workspace ` — set working directory +- `--approve-mcps` — auto-approve MCP server connections (if configured) +- `--resume ` — resume existing conversation (if session ID found) + +`perm_mode` (config `providers.cursor_cli.perm_mode`, env `GOCLAW_CURSOR_CLI_PERM_MODE`, or DB `settings.perm_mode`) controls `--force` / `--sandbox`: +- **`force`** (default) — `--force` + `--trust` for unattended headless runs +- **`default`** — `--trust` only (stricter; same as `strict`) +- **`sandbox`** — `--force` + `--trust` + `--sandbox enabled` + +### 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. @@ -700,7 +799,7 @@ Reasoning behavior: --- -## 14. File Reference +## 15. File Reference | File | Purpose | |------|---------| @@ -720,6 +819,12 @@ Reasoning behavior: | `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` | Browser auth status via `agent status` / `agent about` | +| `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 db310a851..aa894a6a5 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -24,25 +24,25 @@ 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) - MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned - 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) + MentionMode string `json:"mention_mode,omitempty"` // "strict" (default) = only respond when mentioned; "yield" = respond unless another bot is mentioned + 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. @@ -194,24 +194,25 @@ 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) OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required) ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` + CursorCLI CursorCLIConfig `json:"cursor_cli"` ACP ACPConfig `json:"acp"` Novita ProviderConfig `json:"novita"` // Novita AI (OpenAI-compatible endpoint) } @@ -230,6 +231,15 @@ type ClaudeCLIConfig struct { PermMode string `json:"perm_mode" yaml:"perm_mode"` // permission mode (default: "bypassPermissions") } +// CursorCLIConfig configures the Cursor CLI provider (browser auth via `agent login` on the server). +type CursorCLIConfig struct { + CLIPath string `json:"cli_path,omitempty"` // path to agent binary (default: "agent") + 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. // Orchestrates any ACP-compatible coding agent (Claude Code, Codex CLI, Gemini CLI) as a subprocess. type ACPConfig struct { @@ -310,6 +320,7 @@ func (c *Config) HasAnyProvider() bool { p.Ollama.Host != "" || p.OllamaCloud.APIKey != "" || p.ClaudeCLI.CLIPath != "" || + p.CursorCLI.CLIPath != "" || p.ACP.Binary != "" || p.Novita.APIKey != "" } @@ -336,16 +347,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) @@ -411,12 +422,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 38b3319e4..4764640c3 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -156,6 +156,12 @@ 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 + 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 // saves a provider/model via the Dashboard, the config-file value wins. diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 790aa0019..bfaf0b12d 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -15,8 +15,8 @@ import ( // ModelInfo is a normalized model entry returned by the list-models endpoint. type ModelInfo struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` Reasoning *providers.ReasoningCapability `json:"reasoning,omitempty"` } @@ -86,6 +86,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 { + respond(cursorCLIModels()) + return + } + if p.APIKey == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")}) return @@ -95,12 +101,13 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht defer cancel() var models []ModelInfo + var errModels error switch p.ProviderType { case "anthropic_native": - models, err = fetchAnthropicModels(ctx, p.APIKey, h.resolveAPIBase(p)) + models, errModels = fetchAnthropicModels(ctx, p.APIKey, h.resolveAPIBase(p)) case "gemini_native": - models, err = fetchGeminiModels(ctx, p.APIKey) + models, errModels = fetchGeminiModels(ctx, p.APIKey) case "bailian": models = bailianModels() case "dashscope": @@ -115,11 +122,11 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht if apiBase == "" { apiBase = "https://api.openai.com/v1" } - models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey) + models, errModels = fetchOpenAIModels(ctx, apiBase, p.APIKey) } - if err != nil { - slog.Warn("providers.models", "provider", p.Name, "error", err) + if errModels != nil { + slog.Warn("providers.models", "provider", p.Name, "error", errModels) // Return empty list instead of error — provider may not support /models respond([]ModelInfo{}) return diff --git a/internal/http/provider_models_catalog.go b/internal/http/provider_models_catalog.go index 75c3611e7..625717a4d 100644 --- a/internal/http/provider_models_catalog.go +++ b/internal/http/provider_models_catalog.go @@ -77,6 +77,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 5ffc03a07..5d2542726 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -79,6 +79,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 @@ -159,6 +171,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 31dda011e..6ec39ee17 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/url" + "path/filepath" "strings" "sync" @@ -132,6 +133,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 { @@ -183,6 +186,27 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host), "llama3.3")) 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, pkgGatewayToken) + mcpData.AgentMCPLookup = h.mcpLookup + cursorOpts = append(cursorOpts, providers.WithCursorCLIMCPConfigData(mcpData)) + } + h.providerReg.Register(providers.NewCursorCLIProvider(cliPath, cursorOpts...)) + return + } if p.APIKey == "" { return } @@ -342,6 +366,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 := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, uuid.Nil, &p); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return diff --git a/internal/providers/cursor_cli.go b/internal/providers/cursor_cli.go new file mode 100644 index 000000000..e451eb45f --- /dev/null +++ b/internal/providers/cursor_cli.go @@ -0,0 +1,86 @@ +package providers + +import "sync" + +// CursorCLIProvider implements Provider by shelling out to the Cursor `agent` binary. +// 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: "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 +} + +// 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 + } + } +} + +// WithCursorCLIMCPConfigData sets the per-session MCP config data. +func WithCursorCLIMCPConfigData(data *MCPConfigData) CursorCLIOption { + return func(p *CursorCLIProvider) { + p.mcpConfigData = data + } +} + +// 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) { + if mode != "" { + p.permMode = mode + } + } +} + +// 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: "composer-2", + baseWorkDir: defaultCursorCLIWorkDir(), + permMode: "force", + // 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..b765614f9 --- /dev/null +++ b/internal/providers/cursor_cli_auth.go @@ -0,0 +1,125 @@ +package providers + +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 { + 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} +} + +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 new file mode 100644 index 000000000..2d0ff6f17 --- /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 = filterCursorEnv(os.Environ()) + + 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 = filterCursorEnv(os.Environ()) + + 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..4df7bef0d --- /dev/null +++ b/internal/providers/cursor_cli_session.go @@ -0,0 +1,144 @@ +package providers + +import ( + "encoding/json" + "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 { + mode := strings.ToLower(strings.TrimSpace(p.permMode)) + if mode == "" { + mode = "force" + } + args := []string{ + "--print", + "--output-format", outputFormat, + "--model", model, + } + // 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") + } + if chatID != "" { + args = append(args, "--resume", 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 { + 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 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 { + 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/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 9dce54b3b..50db47baa 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 local `agent` binary (browser auth) ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint) // Novita AI defaults. @@ -62,6 +63,7 @@ var ValidProviderTypes = map[string]bool{ ProviderOllama: true, ProviderOllamaCloud: true, ProviderACP: true, + ProviderCursorCLI: true, ProviderNovita: true, } diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index b751dcdad..d08ee15ca 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -33,6 +33,7 @@ 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 9c24022ec..c3465e2d6 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -179,6 +179,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": { "title": "Provider Details", "advanced": "Advanced", @@ -190,6 +204,8 @@ "acpConfigDesc": "Agent subprocess settings", "cliConfig": "Claude CLI", "cliConfigDesc": "Local CLI authentication", + "cursorCliConfig": "Cursor CLI", + "cursorCliConfigDesc": "Local Cursor agent authentication", "oauthConfig": "ChatGPT Subscription (OAuth)", "oauthConfigDesc": "Manage sign-in for this named OpenAI/Codex account", "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 d6b4ac4e2..bfa63d4dd 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 to create one named OpenAI Codex OAuth account. Give it an alias now, then add more aliases later for pool members or round robin.", "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 a2bf56d2f..285faed8d 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -184,6 +184,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", @@ -194,6 +208,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": "ChatGPT Subscription (OAuth)", "oauthConfigDesc": "Quản lý đăng nhập cho tài khoản OpenAI/Codex có bí danh này", "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 2594dfb67..7d2c5778d 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 để tạo một tài khoản OpenAI Codex OAuth có bí danh riêng. Đặt bí danh ngay bây giờ, rồi thêm các bí danh khác sau này cho pool members hoặc round robin.", "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 d0d477822..3822f4e08 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -202,6 +202,20 @@ "runOnServer": "在服务器终端运行:", "recheckButton": "重新检查" }, + "cursorCli": { + "description": "Cursor CLI 使用本地", + "descriptionSuffix": "二进制。在服务器上用浏览器登录 — GoClaw 不保存 Cursor API 密钥。", + "checkingAuth": "正在检查认证...", + "authenticatedAs": "已认证为", + "switchAccount": "切换账户?", + "switchAccountInstructions": "在服务器终端运行:", + "switchAccountRecheck": "然后点击", + "switchAccountRecheckSuffix": "重新检查。", + "notAuthenticated": "未认证", + "runOnServer": "在服务器终端运行:", + "recheckButton": "重新检查", + "authCheckFailed": "无法检查认证状态" + }, "detail": { "advanced": "高级设置", "identity": "身份", @@ -212,6 +226,8 @@ "acpConfigDesc": "Agent子进程设置", "cliConfig": "Claude CLI", "cliConfigDesc": "本地CLI认证", + "cursorCliConfig": "Cursor CLI", + "cursorCliConfigDesc": "本地 Cursor agent 认证", "oauthConfig": "ChatGPT Subscription (OAuth)", "oauthConfigDesc": "管理这个具名 OpenAI/Codex 账户的登录状态", "nameReadonly": "Provider标识符,无法更改", diff --git a/ui/web/src/i18n/locales/zh/setup.json b/ui/web/src/i18n/locales/zh/setup.json index 3a6ae819e..e47542b60 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": "登录以创建一个具名的 OpenAI Codex OAuth 账户。现在先设置别名,之后再添加更多别名用于池成员或 round robin。", "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 ade377bc5..7dc209708 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); @@ -241,6 +243,17 @@ export function ProviderAdvancedDialog({ )} + {/* Cursor CLI */} + {isCursorCLI && ( + <> + + + + )} + {/* OAuth */} {isOAuth && ( <> diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts index 4e2217059..e83759c9c 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts +++ b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts @@ -3,7 +3,7 @@ import type { ChatGPTOAuthRoutingConfig } from "@/types/agent"; import { normalizeReasoningEffort, normalizeReasoningFallback } from "@/types/provider"; // Provider types that don't use API keys -export const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]); +export const NO_API_KEY_TYPES = new Set(["claude_cli", "cursor_cli", "acp", "chatgpt_oauth"]); // Provider types that don't support embedding export const NO_EMBEDDING_TYPES = new Set([ diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 86b275807..3112e4a5c 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -27,6 +27,7 @@ import { slugify } from "@/lib/slug"; import { DEFAULT_CODEX_OAUTH_ALIAS, PROVIDER_TYPES, suggestUniqueProviderAlias } 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"; import { providerCreateSchema, type ProviderCreateFormData } from "@/schemas/provider.schema"; @@ -66,8 +67,11 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi const name = watch("name"); 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"; // Reset form when dialog opens @@ -140,6 +144,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi } + {isCursorCLI && } + {isACP && ( )} - {!isCLI && !isACP && ( + {!isCLI && !isCursorCLI && !isACP && ( <>
@@ -287,9 +294,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; @@ -306,12 +314,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 a21001cc4..70d884ff2 100644 --- a/ui/web/src/pages/providers/provider-utils.tsx +++ b/ui/web/src/pages/providers/provider-utils.tsx @@ -12,6 +12,7 @@ const SPECIAL_VARIANTS: Record = { anthropic_native: "default", chatgpt_oauth: "default", claude_cli: "outline", + cursor_cli: "outline", acp: "outline", }; @@ -123,7 +124,7 @@ export function ProviderApiKeyBadge({ ); } - 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 0a0a0b012..281c8cd0e 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, suggestUniqueProviderAlias } 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"; @@ -45,8 +46,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); @@ -97,7 +100,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 { @@ -116,7 +119,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); @@ -139,6 +142,8 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps ? t("provider.descriptionOauth") : isCLI ? t("provider.descriptionCli") + : isCursorCLI + ? t("provider.descriptionCursorCli") : t("provider.description")}

@@ -195,6 +200,8 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps ) : isCLI ? ( + ) : isCursorCLI ? ( + ) : ( <>
@@ -228,7 +235,7 @@ export function StepProvider({ onComplete, existingProvider }: StepProviderProps {!isOAuth && (
-