From 2b6d878213325bd3f462d3bd67aa139ed6ade0e7 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Thu, 27 Aug 2026 21:35:20 -0500 Subject: [PATCH] feat(executor): add Cursor CLI as a full-parity task executor Cursor Agent (`agent` / `cursor-agent`) now sits alongside grok, claude, and the other backends: session resume via --resume, dangerous mode via --force (CURSOR_DANGEROUS_ARGS), per-task --model, worktree-local MCP, and the write-guard through .cursor/hooks.json. Do not pass Cursor's own --worktree flag; TaskYou already isolates each task. --approve-mcps is always set so the taskyou MCP server is not blocked on a TUI prompt. --- AGENTS.md | 4 +- README.md | 6 +- cmd/task/cli_test.go | 2 + cmd/task/completion.go | 1 + cmd/task/completion_test.go | 4 +- cmd/task/main.go | 38 +- desktop/src-tauri/src/env_check.rs | 21 +- desktop/src/components/SetupCheck.tsx | 2 +- docs/executor_interface.md | 6 + internal/db/models.go | 35 +- internal/db/models_test.go | 25 +- internal/db/tasks.go | 4 +- internal/executor/auth_check.go | 4 + internal/executor/auth_check_test.go | 10 + internal/executor/cursor_executor.go | 733 ++++++++++++++++++ internal/executor/cursor_executor_test.go | 362 +++++++++ internal/executor/dangerous_mode_test.go | 31 +- internal/executor/executor.go | 3 + internal/executor/worktree_guard.go | 15 +- internal/executor/worktree_guard_hooks.go | 56 ++ .../executor/worktree_guard_hooks_test.go | 16 + internal/executor/worktree_guard_test.go | 27 + internal/pipeline/generate.go | 2 +- internal/ui/app.go | 4 +- internal/ui/detail.go | 2 + internal/ui/form.go | 2 +- 26 files changed, 1370 insertions(+), 45 deletions(-) create mode 100644 internal/executor/cursor_executor.go create mode 100644 internal/executor/cursor_executor_test.go diff --git a/AGENTS.md b/AGENTS.md index dfd46fab..f2f16c2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Guide for AI agents working in this repository. This is **Task You** - a personal task management system with: - **SQLite storage** for tasks and projects - **SSH-accessible TUI** via Wish -- **Background executor** with support for multiple AI coding agents (Claude, Codex, Gemini, Grok, Pi, OpenClaw, OpenCode) +- **Background executor** with support for multiple AI coding agents (Claude, Codex, Gemini, Grok, Cursor, Pi, OpenClaw, OpenCode) - **Beautiful terminal UI** built with Charm libraries (Kanban board) - **Git worktree isolation** for parallel task execution - **Task lifecycle hooks** for real-time task state tracking @@ -304,6 +304,7 @@ TaskYou supports multiple AI coding agent backends: - **codex** - OpenAI Codex CLI with dangerous mode support - **gemini** - Google Gemini CLI with configurable dangerous mode flags - **grok** - Grok CLI with session resume and `--always-approve` dangerous mode +- **cursor** - Cursor Agent CLI with session resume and `--force` dangerous mode - **pi** - Pi coding agent with session continuity - **openclaw** - OpenClaw AI assistant - **opencode** - OpenCode AI assistant @@ -434,6 +435,7 @@ wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { | `WORKTREE_CWD` | Working directory for project detection | - | | `GEMINI_DANGEROUS_ARGS` | Overrides the Gemini CLI flags used when dangerous mode is enabled | `--dangerously-allow-run` | | `GROK_DANGEROUS_ARGS` | Overrides the Grok CLI flags used when dangerous mode is enabled | `--always-approve` | +| `CURSOR_DANGEROUS_ARGS` | Overrides the Cursor CLI flags used when dangerous mode is enabled | `--force` | Set `TASK_EXECUTOR` before launching `task -l`/`task daemon` to change the executor label shown in the UI (e.g., `TASK_EXECUTOR=codex`). For compatibility, `WORKFLOW_EXECUTOR`, `TASKYOU_EXECUTOR`, and `WORKTREE_EXECUTOR` are also recognized. diff --git a/README.md b/README.md index ee71d825..39d0f651 100644 --- a/README.md +++ b/README.md @@ -453,13 +453,14 @@ Task You supports multiple AI executors for processing tasks. You can choose the | Codex | `codex` | [OpenAI Codex CLI](https://github.com/openai/codex) - OpenAI's coding assistant | | Gemini | `gemini` | [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) - Google's Gemini-based coding assistant | | Grok | `grok` | [Grok CLI](https://x.ai/cli) - xAI's coding assistant with session resumption | +| Cursor | `agent` / `cursor-agent` | [Cursor CLI](https://cursor.com/docs/cli) - Cursor's coding agent with session resumption | | Pi | `pi` | [Pi Coding Agent](https://github.com/mariozechner/pi-coding-agent) - Multi-provider AI coding agent with session continuity | | OpenCode | `opencode` | [OpenCode](https://opencode.ai) - Open-source AI coding assistant with multi-LLM support | | OpenClaw | `openclaw` | [OpenClaw](https://openclaw.ai) - Open-source personal AI assistant with session resumption | All executors run in tmux windows with the same worktree isolation and environment variables. The main differences: -- **Claude Code**, **Grok**, **Pi**, and **OpenClaw** support session resumption - when you retry a task, they continue with full conversation history +- **Claude Code**, **Grok**, **Cursor**, **Pi**, and **OpenClaw** support session resumption - when you retry a task, they continue with full conversation history - **Codex** and **Gemini** start fresh on each execution but receive the full prompt with any feedback - **OpenCode** does not support session resumption @@ -480,6 +481,9 @@ npm install -g @openai/codex # Grok CLI curl -fsSL https://x.ai/cli/install.sh | bash +# Cursor Agent CLI +curl https://cursor.com/install -fsS | bash + # Pi Coding Agent npm install -g @mariozechner/pi-coding-agent diff --git a/cmd/task/cli_test.go b/cmd/task/cli_test.go index a6f4f396..1e4aa002 100644 --- a/cmd/task/cli_test.go +++ b/cmd/task/cli_test.go @@ -1917,6 +1917,8 @@ func TestCreateModelValidation(t *testing.T) { {db.ExecutorClaude, "claude-opus-5"}, {db.ExecutorClaude, "claude-opus-9"}, // released after this code {db.ExecutorGrok, "grok-4"}, + {db.ExecutorCursor, "gpt-5"}, + {db.ExecutorCursor, "composer-1"}, {db.ExecutorCodex, ""}, // modelless executor, no override } for _, tt := range accepted { diff --git a/cmd/task/completion.go b/cmd/task/completion.go index f9b69be1..09421bfa 100644 --- a/cmd/task/completion.go +++ b/cmd/task/completion.go @@ -142,6 +142,7 @@ func completeFlagExecutors(cmd *cobra.Command, args []string, toComplete string) "codex\tOpenAI Codex", "gemini\tGoogle Gemini", "grok\txAI Grok", + "cursor\tCursor Agent", "pi\tInflection Pi", "opencode\tOpenCode", "openclaw\tOpenClaw", diff --git a/cmd/task/completion_test.go b/cmd/task/completion_test.go index 66395961..30aeba5a 100644 --- a/cmd/task/completion_test.go +++ b/cmd/task/completion_test.go @@ -100,8 +100,8 @@ func TestCompleteFlagExecutors(t *testing.T) { if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("expected NoFileComp directive") } - if len(completions) != 7 { - t.Errorf("expected 7 executors, got %d", len(completions)) + if len(completions) != 8 { + t.Errorf("expected 8 executors, got %d", len(completions)) } } diff --git a/cmd/task/main.go b/cmd/task/main.go index b13bdde9..736482e5 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -429,7 +429,7 @@ Tasks will automatically reconnect to their agent sessions when viewed.`, worktreeGuardCmd := &cobra.Command{ Use: "worktree-guard", Short: "Evaluate the worktree write-guard for a pre-tool hook", - Hidden: true, // Internal use only - invoked by codex/gemini/grok/opencode hooks + Hidden: true, // Internal use only - invoked by codex/gemini/grok/cursor/opencode hooks Run: func(cmd *cobra.Command, args []string) { format, _ := cmd.Flags().GetString("format") // Never block the agent on our own failure: handle errors by allowing. @@ -438,7 +438,7 @@ Tasks will automatically reconnect to their agent sessions when viewed.`, } }, } - worktreeGuardCmd.Flags().String("format", "", "Output format: codex | gemini | grok | opencode") + worktreeGuardCmd.Flags().String("format", "", "Output format: codex | gemini | grok | cursor | opencode") rootCmd.AddCommand(worktreeGuardCmd) // MCP server subcommand - runs the workflow MCP server for a task (internal use) @@ -900,9 +900,9 @@ Examples: createCmd.Flags().String("body", "", "Task body/description (if no title, AI generates from body)") createCmd.Flags().StringP("type", "t", "", "Task type: code, writing, thinking (default: code)") createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)") - createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, grok, pi, opencode, openclaw (default: claude)") + createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, grok, cursor, pi, opencode, openclaw (default: claude)") createCmd.Flags().String("effort", "", "Per-task Claude effort override: low, medium, high, xhigh, max (default: Claude's global default)") - createCmd.Flags().String("model", "", "Per-task model override: opus, sonnet, haiku, fable, or a full model ID like claude-opus-5 (default: the agent's own default). Claude and grok only") + createCmd.Flags().String("model", "", "Per-task model override: opus, sonnet, haiku, fable, or a full model ID like claude-opus-5 (default: the agent's own default). Claude, grok, and cursor only") createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution") createCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (alias for --permission-mode dangerous)") createCmd.Flags().String("permission-mode", "", "Permission mode: default (prompt), accept-edits (auto-accept file edits), auto (Claude Code auto mode: auto-approve safe actions, block risky ones), dangerous (skip all). Defaults to the project's setting") @@ -1928,7 +1928,7 @@ Examples: updateCmd.Flags().String("body", "", "Update task body/description") updateCmd.Flags().StringP("type", "t", "", "Update task type: code, writing, thinking") updateCmd.Flags().StringP("project", "p", "", "Update project name") - updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, grok, pi, opencode, openclaw") + updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, grok, cursor, pi, opencode, openclaw") updateCmd.Flags().String("tags", "", "Update task tags (comma-separated)") updateCmd.Flags().Bool("pinned", false, "Pin or unpin the task") updateCmd.RegisterFlagCompletionFunc("project", completeFlagProjects) @@ -4967,6 +4967,8 @@ type worktreeGuardHookInput struct { ToolNameCamel string `json:"toolName"` ToolInputCamel json.RawMessage `json:"toolInput"` PermissionModeCamel string `json:"permissionMode"` + // Cursor beforeShellExecution puts the command at the top level. + Command string `json:"command"` } func (in *worktreeGuardHookInput) normalize() { @@ -4979,10 +4981,16 @@ func (in *worktreeGuardHookInput) normalize() { if in.PermissionMode == "" { in.PermissionMode = in.PermissionModeCamel } + if in.ToolName == "" && in.Command != "" { + in.ToolName = "Bash" + if raw, err := json.Marshal(map[string]string{"command": in.Command}); err == nil { + in.ToolInput = raw + } + } } // handleWorktreeGuardHook is the executor-agnostic transport for the worktree -// write-guard, shared by the codex/gemini/grok/opencode pre-tool hooks. It reads the +// write-guard, shared by the codex/gemini/grok/cursor/opencode pre-tool hooks. It reads the // worktree root from WORKTREE_PATH (set by every executor when launching the CLI), // evaluates the single shared policy (EvaluateWorktreeWriteGuard), and renders the // decision in the requested executor's wire format. It fails open on any error so a @@ -5016,7 +5024,7 @@ func handleWorktreeGuardHook(format string) error { } // renderWorktreeGuardDecision writes a guard decision in the wire format the given -// executor's hook expects. None of codex/gemini/grok/opencode support an interactive +// executor's hook expects. None of codex/gemini/grok/cursor/opencode support an interactive // "ask" in their pre-tool hook, so an "ask" is downgraded to a hard deny to fail // safe (the escape hatch is worktree.allow_external_writes in .taskyou.yml). When // the guard allows the call, nothing is emitted (and exit stays 0) so the CLI's own @@ -5049,6 +5057,15 @@ func renderWorktreeGuardDecision(format string, decision *executor.WorktreeGuard "decision": "deny", "reason": decision.Reason, }) + case "cursor": + // Cursor preToolUse / beforeShellExecution: JSON permission deny, plus + // exit 2 which Cursor documents as blocking the tool (Claude-compatible). + emitJSONLine(map[string]any{ + "permission": "deny", + "user_message": decision.Reason, + "agent_message": decision.Reason, + }) + os.Exit(2) case "opencode": // The OpenCode plugin reads the reason from stdout and treats exit code 1 as // a denial (which it surfaces by throwing, aborting the tool call). @@ -5678,12 +5695,13 @@ func getSessions() []agentSession { // getAgentMemoryByTaskID returns a map of task ID -> memory (MB) for all agent processes. // It identifies task IDs by examining each agent process's working directory. -// Supports all executors: claude, codex, gemini, grok, openclaw, opencode, pi. +// Supports all executors: claude, codex, gemini, grok, cursor, openclaw, opencode, pi. func getAgentMemoryByTaskID() map[int]int { result := make(map[int]int) - // Find processes for all supported executors - executorNames := []string{"claude", "codex", "gemini", "grok", "openclaw", "opencode", "pi"} + // Find processes for all supported executors. "cursor-agent" is the + // specific binary; skip the generic "agent" name (too many false positives). + executorNames := []string{"claude", "codex", "gemini", "grok", "cursor-agent", "openclaw", "opencode", "pi"} for _, executorName := range executorNames { pgrepOut, err := osexec.Command("pgrep", "-f", executorName).Output() diff --git a/desktop/src-tauri/src/env_check.rs b/desktop/src-tauri/src/env_check.rs index 09ad5290..9e106122 100644 --- a/desktop/src-tauri/src/env_check.rs +++ b/desktop/src-tauri/src/env_check.rs @@ -10,7 +10,17 @@ use serde::Serialize; use std::process::Command; /// Executor CLIs taskyou knows how to drive, in display order. -const EXECUTOR_CLIS: &[&str] = &["claude", "codex", "gemini", "grok", "opencode", "pi", "openclaw"]; +/// Cursor ships as `cursor-agent` (preferred) or `agent`; see check_environment. +const EXECUTOR_CLIS: &[&str] = &[ + "claude", + "codex", + "gemini", + "grok", + "cursor", + "opencode", + "pi", + "openclaw", +]; /// Replace this process's PATH with the login shell's PATH so child processes /// (ty → tmux → executors) resolve tools the way the user's terminal does. @@ -84,12 +94,19 @@ pub fn check_environment() -> EnvironmentReport { .iter() .map(|name| ToolCheck { name: (*name).to_string(), - path: which(name), + path: which_executor(name), }) .collect(), } } +fn which_executor(name: &str) -> Option { + if name == "cursor" { + return which("cursor-agent").or_else(|| which("agent")); + } + which(name) +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src/components/SetupCheck.tsx b/desktop/src/components/SetupCheck.tsx index 8a286d6b..3967f4c0 100644 --- a/desktop/src/components/SetupCheck.tsx +++ b/desktop/src/components/SetupCheck.tsx @@ -78,7 +78,7 @@ export function SetupCheck({ detail={ executorOk ? foundExecutors.map((e) => e.name).join(", ") - : "none found (claude, codex, gemini, grok, …)" + : "none found (claude, codex, gemini, grok, cursor, …)" } /> {!executorOk && npm install -g @anthropic-ai/claude-code} diff --git a/docs/executor_interface.md b/docs/executor_interface.md index b668b68a..1f0f2858 100644 --- a/docs/executor_interface.md +++ b/docs/executor_interface.md @@ -35,3 +35,9 @@ Review `internal/executor/gemini_executor.go` for a reference implementation tha The Grok executor launches the interactive TUI (`grok "prompt"`) inside tmux, the same pattern as Gemini. Dangerous mode maps to `--always-approve` (overridable via `GROK_DANGEROUS_ARGS`). Other permission modes pass through as `--permission-mode acceptEdits|auto`. Per-task `--effort` and `--model` match Claude. Session resume uses `--resume `; Grok stores sessions under `~/.grok/sessions///`. Do **not** pass Grok's own `--worktree` flag — Task You already isolates each task in a git worktree. Project-local PreToolUse hooks (the write-guard) require folder trust, so the executor sets `GROK_FOLDER_TRUST=0` when launching. The taskyou MCP stdio server is written to `/.grok/config.toml` (`mcp_servers.taskyou` → `ty mcp-server --task-id`), because Grok has no `--mcp-config` flag. Review `internal/executor/grok_executor.go` for the implementation. + +## Cursor CLI Notes + +The Cursor executor launches the interactive Agent CLI (`agent "prompt"`, or `cursor-agent` when that binary is on PATH) inside tmux. Dangerous mode maps to `--force` (overridable via `CURSOR_DANGEROUS_ARGS`; `--yolo` is an alias). Cursor has no Claude-style `--permission-mode`, so accept-edits/auto launch without extra flags. Per-task `--model` is supported (Cursor is multi-vendor, so any well-formed model ID is accepted). Session resume uses `--resume `; Cursor stores CLI chats under `~/.cursor/chats///`. `--approve-mcps` is always passed so the worktree-local taskyou MCP server is not blocked on a TUI prompt. Do **not** pass Cursor's own `--worktree` flag — Task You already isolates each task in a git worktree. The taskyou MCP stdio server is merged into `/.cursor/mcp.json` (`mcpServers.taskyou` → `ty mcp-server --task-id`), because Cursor has no `--mcp-config` flag. The write-guard is wired via `/.cursor/hooks.json` (`preToolUse` and `beforeShellExecution`). + +Review `internal/executor/cursor_executor.go` for the implementation. diff --git a/internal/db/models.go b/internal/db/models.go index 63e69aa6..655c9dce 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -26,13 +26,15 @@ const ( ModelOpusPlan = "opusplan" ) -// executorModelPrefix maps an executor to the ID prefix its models carry. Only -// executors listed here pass a --model flag to their CLI at all; for the rest -// (codex, gemini, pi, opencode, openclaw) a model override is dead config — -// the launch command never mentions it. -var executorModelPrefix = map[string]string{ - ExecutorClaude: "claude-", - ExecutorGrok: "grok-", +// executorModelPrefix maps an executor to the ID prefixes its models carry. +// Only executors listed here pass a --model flag to their CLI at all; for the +// rest (codex, gemini, pi, opencode, openclaw) a model override is dead config +// — the launch command never mentions it. Cursor is multi-vendor, so it has +// several prefixes rather than one. +var executorModelPrefix = map[string][]string{ + ExecutorClaude: {"claude-"}, + ExecutorGrok: {"grok-"}, + ExecutorCursor: {"gpt-", "composer-", "claude-", "grok-", "gemini-", "sonnet-", "opus-"}, } // executorModelAliases lists the short names an executor's CLI accepts in place @@ -44,6 +46,9 @@ var executorModelAliases = map[string][]string{ // The grok CLI takes full IDs only; these are examples for help text and // shell completion, not a closed set (any grok-* ID is accepted). ExecutorGrok: {"grok-4", "grok-4-fast", "grok-code-fast-1"}, + // Cursor is multi-vendor; these are examples for help text and shell + // completion. Any well-formed model ID is also accepted. + ExecutorCursor: {"auto", "composer-1", "gpt-5", "sonnet-4", "grok"}, } // modelIDShape matches the shape of a vendor model ID: dash-separated @@ -118,12 +123,18 @@ func ValidateModel(executor, model string) error { return nil } } - prefix := executorModelPrefix[executor] - if strings.HasPrefix(lower, prefix) && modelIDShape.MatchString(lower) { - return nil + prefixes := executorModelPrefix[executor] + for _, prefix := range prefixes { + if strings.HasPrefix(lower, prefix) && modelIDShape.MatchString(lower) { + return nil + } + } + prefixHint := strings.Join(prefixes, "* / ") + "*" + if len(prefixes) == 1 { + prefixHint = prefixes[0] + "*" } - return fmt.Errorf("unknown %s model %q — known models: %s (or any %s* model ID)", - executor, model, strings.Join(ModelsForExecutor(executor), ", "), prefix) + return fmt.Errorf("unknown %s model %q — known models: %s (or any %s model ID)", + executor, model, strings.Join(ModelsForExecutor(executor), ", "), prefixHint) } // ValidateTaskModel checks a task's model override against the executor that diff --git a/internal/db/models_test.go b/internal/db/models_test.go index 3b651988..3ff99005 100644 --- a/internal/db/models_test.go +++ b/internal/db/models_test.go @@ -52,6 +52,19 @@ func TestValidateModel(t *testing.T) { {"claude alias on grok", ExecutorGrok, ModelOpus, true}, {"claude id on grok", ExecutorGrok, "claude-opus-5", true}, + // Cursor is multi-vendor: aliases, other vendors' IDs, and any + // well-formed ID are accepted. Shell-unsafe names are still rejected. + {"cursor auto alias", ExecutorCursor, "auto", false}, + {"cursor composer alias", ExecutorCursor, "composer-1", false}, + {"cursor gpt-5", ExecutorCursor, "gpt-5", false}, + {"cursor claude id", ExecutorCursor, "claude-4-sonnet", false}, + {"cursor grok id", ExecutorCursor, "grok-4", false}, + {"cursor unreleased id", ExecutorCursor, "composer-9", false}, + {"cursor executor slug is not a model", ExecutorCursor, "cursor", true}, + {"cursor typo alias", ExecutorCursor, "opuss", true}, + {"cursor whitespace", ExecutorCursor, "gpt 5", true}, + {"cursor shell metacharacters", ExecutorCursor, "gpt-5; rm -rf /", true}, + // Executors with no --model flag: an override there is dead config. {"codex takes no model", ExecutorCodex, "gpt-5-codex", true}, {"gemini takes no model", ExecutorGemini, "gemini-2.5-pro", true}, @@ -89,7 +102,7 @@ func TestValidateModelErrorNamesAlternatives(t *testing.T) { t.Fatal("expected an error setting a model on a modelless executor") } // It must name the executors that DO take a model, or the user is stuck. - for _, want := range []string{ExecutorCodex, ExecutorClaude, ExecutorGrok} { + for _, want := range []string{ExecutorCodex, ExecutorClaude, ExecutorGrok, ExecutorCursor} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q should mention %q", err, want) } @@ -100,7 +113,7 @@ func TestValidateModelErrorNamesAlternatives(t *testing.T) { // Adding a --model flag to another executor must update executorModelPrefix, // or overrides for it stay silently ignored. func TestExecutorSupportsModel(t *testing.T) { - supported := map[string]bool{ExecutorClaude: true, ExecutorGrok: true} + supported := map[string]bool{ExecutorClaude: true, ExecutorGrok: true, ExecutorCursor: true} for _, e := range KnownExecutors() { if got := ExecutorSupportsModel(e); got != supported[e] { t.Errorf("ExecutorSupportsModel(%q) = %v, want %v", e, got, supported[e]) @@ -109,8 +122,8 @@ func TestExecutorSupportsModel(t *testing.T) { if !ExecutorSupportsModel("") { t.Error("empty executor should resolve to the default (claude), which supports models") } - if got := ModelCapableExecutors(); len(got) != 2 || got[0] != ExecutorClaude || got[1] != ExecutorGrok { - t.Errorf("ModelCapableExecutors() = %v, want [claude grok]", got) + if got := ModelCapableExecutors(); len(got) != 3 || got[0] != ExecutorClaude || got[1] != ExecutorGrok || got[2] != ExecutorCursor { + t.Errorf("ModelCapableExecutors() = %v, want [claude grok cursor]", got) } } @@ -174,7 +187,7 @@ func TestModelBackendIsCustom(t *testing.T) { // TestIsValidModel covers the looser executor-less form used by the new-task // form's remembered per-project default. func TestIsValidModel(t *testing.T) { - valid := []string{"", ModelOpus, "claude-opus-5", "grok-4"} + valid := []string{"", ModelOpus, "claude-opus-5", "grok-4", "gpt-5", "composer-1"} for _, m := range valid { if !IsValidModel(m) { t.Errorf("IsValidModel(%q) = false, want true", m) @@ -182,7 +195,7 @@ func TestIsValidModel(t *testing.T) { } // "claude" is the executor slug an early default baked into every row; it is // not a model and must not survive as a remembered default. - invalid := []string{"claude", "opuss", "gpt-5"} + invalid := []string{"claude", "opuss", "cursor"} for _, m := range invalid { if IsValidModel(m) { t.Errorf("IsValidModel(%q) = true, want false", m) diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 0663661b..7f88f01a 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -19,7 +19,7 @@ type Task struct { Status string Type string Project string - Executor string // Task executor: "claude" (default), "codex", "gemini", "grok" + Executor string // Task executor: "claude" (default), "codex", "gemini", "grok", "cursor" EffortLevel string // Per-task Claude effort override ("" = use global/Claude default; otherwise low/medium/high/xhigh/max) Model string // Per-task Claude model override ("" = use global/Claude default; otherwise an alias like opus/sonnet/haiku or a full model name) ClaudeConfigDir string // Per-task CLAUDE_CONFIG_DIR override ("" = use the project's/default config dir). Lets a single step route through a different Claude config (e.g. an ollama-backed one) without changing the project. @@ -209,6 +209,7 @@ const ( ExecutorCodex = "codex" // OpenAI Codex CLI ExecutorGemini = "gemini" // Google Gemini CLI ExecutorGrok = "grok" // Grok CLI (https://x.ai/cli) + ExecutorCursor = "cursor" // Cursor CLI (https://cursor.com/docs/cli) ExecutorOpenClaw = "openclaw" // OpenClaw AI assistant (https://openclaw.ai) ExecutorOpenCode = "opencode" // OpenCode AI assistant (https://opencode.ai) ExecutorPi = "pi" // Pi coding agent (https://github.com/mariozechner/pi-coding-agent) @@ -221,6 +222,7 @@ func KnownExecutors() []string { ExecutorCodex, ExecutorGemini, ExecutorGrok, + ExecutorCursor, ExecutorPi, ExecutorOpenCode, ExecutorOpenClaw, diff --git a/internal/executor/auth_check.go b/internal/executor/auth_check.go index 6b5ea12b..ea41cba4 100644 --- a/internal/executor/auth_check.go +++ b/internal/executor/auth_check.go @@ -32,6 +32,10 @@ var authRequiredPatterns = []authPattern{ {"please run grok login", "Grok session expired — run grok login to re-authenticate"}, {"run `grok login`", "Grok session expired — run grok login to re-authenticate"}, {"sign in to grok", "Grok is showing the login screen — re-authentication required"}, + {"please run agent login", "Cursor session expired — run agent login to re-authenticate"}, + {"run `agent login`", "Cursor session expired — run agent login to re-authenticate"}, + {"run cursor-agent login", "Cursor session expired — run agent login to re-authenticate"}, + {"sign in to cursor", "Cursor is showing the login screen — re-authentication required"}, } // DetectAuthPrompt scans captured pane content for signs that the executor's diff --git a/internal/executor/auth_check_test.go b/internal/executor/auth_check_test.go index 527912a2..eda0afe1 100644 --- a/internal/executor/auth_check_test.go +++ b/internal/executor/auth_check_test.go @@ -64,6 +64,16 @@ func TestDetectAuthPrompt(t *testing.T) { content: "Sign in to Grok to continue this session.", want: true, }, + { + name: "cursor agent login prompt", + content: "Your Cursor credentials expired.\nPlease run agent login to continue.", + want: true, + }, + { + name: "cursor sign-in screen", + content: "Sign in to Cursor to continue this session.", + want: true, + }, } for _, tt := range tests { diff --git a/internal/executor/cursor_executor.go b/internal/executor/cursor_executor.go new file mode 100644 index 00000000..881f9465 --- /dev/null +++ b/internal/executor/cursor_executor.go @@ -0,0 +1,733 @@ +package executor + +import ( + "context" + "crypto/md5" //nolint:gosec // G501: Cursor names CLI chat folders MD5(cwd) + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/log" + + "github.com/bborn/workflow/internal/db" +) + +// CursorExecutor implements TaskExecutor for the Cursor Agent CLI. +// See: https://cursor.com/docs/cli and https://cursor.com/install +// +// CLI reference (agent --help / cursor.com/docs/cli/reference/parameters): +// - agent "prompt" Interactive agent with an initial prompt +// - agent --force / --yolo Force-allow commands (dangerous mode) +// - agent --resume [chatId] Resume a chat session +// - agent --continue Continue the most recent session +// - agent --model Model to use +// - agent --approve-mcps Auto-approve project MCP servers +// +// TaskYou already creates an isolated git worktree per task, so we never pass +// Cursor's own --worktree flag (that would nest a second worktree). +type CursorExecutor struct { + executor *Executor + logger *log.Logger + suspendedTasks map[int64]time.Time +} + +// NewCursorExecutor creates a new Cursor executor. +func NewCursorExecutor(e *Executor) *CursorExecutor { + return &CursorExecutor{ + executor: e, + logger: e.logger, + suspendedTasks: make(map[int64]time.Time), + } +} + +// Name returns the executor name. +func (c *CursorExecutor) Name() string { + return db.ExecutorCursor +} + +// cursorLaunchBin is the Cursor Agent CLI binary. Official installs put +// `agent` on PATH (https://cursor.com/install); some builds also ship +// `cursor-agent`. Prefer the more specific name when both exist. +func cursorLaunchBin() string { + if _, err := exec.LookPath("cursor-agent"); err == nil { + return "cursor-agent" + } + return "agent" +} + +// IsAvailable checks if the Cursor Agent CLI is installed. +func (c *CursorExecutor) IsAvailable() bool { + if _, err := exec.LookPath("cursor-agent"); err == nil { + return true + } + _, err := exec.LookPath("agent") + return err == nil +} + +// Execute runs a task using the Cursor CLI. +func (c *CursorExecutor) Execute(ctx context.Context, task *db.Task, workDir, prompt string) ExecResult { + return c.runCursor(ctx, task, workDir, prompt, "", false) +} + +// Resume continues a previous Cursor session, appending feedback as the next prompt. +func (c *CursorExecutor) Resume(ctx context.Context, task *db.Task, workDir, prompt, feedback string) ExecResult { + return c.runCursor(ctx, task, workDir, prompt, feedback, true) +} + +func (c *CursorExecutor) runCursor(ctx context.Context, task *db.Task, workDir, prompt, feedback string, isResume bool) ExecResult { + paths := c.executor.claudePathsForProject(task.Project) + + if !c.IsAvailable() { + c.executor.logLine(task.ID, "error", "Cursor CLI is not installed - run: curl https://cursor.com/install -fsS | bash") + return ExecResult{Message: "Cursor CLI is not installed"} + } + + if _, err := exec.LookPath("tmux"); err != nil { + c.executor.logLine(task.ID, "error", "tmux is not installed - required for task execution") + return ExecResult{Message: "tmux is not installed"} + } + + daemonSession, err := ensureTmuxDaemon() + if err != nil { + c.logger.Error("could not create task-daemon session", "error", err) + c.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux daemon: %s", err.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create tmux daemon: %s", err.Error())} + } + + windowName := TmuxWindowName(task.ID) + windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName) + + KillAllWindowsByNameAllSessions(windowName) + + cleanupGuard, guardErr := c.executor.setupCursorWorktreeGuard(workDir, c.executor.getProjectDir(task.Project)) + if guardErr != nil { + c.logger.Warn("could not set up Cursor worktree guard", "error", guardErr) + } + defer func() { + if cleanupGuard != nil { + cleanupGuard() + } + }() + + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + c.logger.Error("could not create temp file", "error", err) + c.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create temp file: %s", err.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create temp file: %s", err.Error())} + } + fullPrompt := prompt + if isResume && feedback != "" { + fullPrompt = prompt + "\n\n## User Feedback\n\n" + feedback + } + if _, err := promptFile.WriteString(fullPrompt); err != nil { + promptFile.Close() + os.Remove(promptFile.Name()) + c.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to write prompt: %s", err.Error())) + return ExecResult{Message: fmt.Sprintf("failed to write prompt: %s", err.Error())} + } + promptFile.Close() + defer os.Remove(promptFile.Name()) + + sessionID := os.Getenv("WORKTREE_SESSION_ID") + if sessionID == "" { + sessionID = fmt.Sprintf("%d", os.Getpid()) + } + + resumeSessionID := "" + existingSessionID := task.ClaudeSessionID + if existingSessionID == "" && isResume { + existingSessionID = findCursorSessionID(workDir) + } + if existingSessionID != "" && isResume { + if cursorSessionExists(existingSessionID) { + resumeSessionID = existingSessionID + c.executor.logLine(task.ID, "system", fmt.Sprintf("Resuming Cursor session %s", existingSessionID)) + } else { + c.executor.logLine(task.ID, "system", fmt.Sprintf("Session %s no longer exists, starting fresh", existingSessionID)) + if err := c.executor.db.UpdateTaskClaudeSessionID(task.ID, ""); err != nil { + c.logger.Warn("failed to clear stale session ID", "task", task.ID, "error", err) + } + } + } + + if err := ensureCursorWorktreeMCPConfig(workDir, task.ID); err != nil { + c.logger.Warn("could not write cursor taskyou MCP config", "error", err) + } + + script := cursorLaunchScript(task, sessionID, resumeSessionID, nil, fmt.Sprintf(`"$(cat %q)"`, promptFile.Name())) + + actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, c.executor.getProjectDir(task.Project), task.ID) + if tmuxErr != nil { + c.logger.Error("tmux new-window failed", "error", tmuxErr, "session", daemonSession) + c.executor.logLine(task.ID, "error", fmt.Sprintf("Failed to create tmux window: %s", tmuxErr.Error())) + return ExecResult{Message: fmt.Sprintf("failed to create tmux window: %s", tmuxErr.Error())} + } + + if actualSession != daemonSession { + windowTarget = fmt.Sprintf("%s:%s", actualSession, windowName) + daemonSession = actualSession + } + + time.Sleep(200 * time.Millisecond) + + if err := c.executor.db.UpdateTaskDaemonSession(task.ID, daemonSession); err != nil { + c.logger.Warn("failed to save daemon session", "task", task.ID, "error", err) + } + if windowID := getWindowID(daemonSession, windowName); windowID != "" { + if err := c.executor.db.UpdateTaskWindowID(task.ID, windowID); err != nil { + c.logger.Warn("failed to save window ID", "task", task.ID, "error", err) + } + } + + c.executor.ensureShellPane(windowTarget, workDir, task.ID, task.Port, task.WorktreePath, paths.configDir) + c.executor.configureTmuxWindow(windowTarget) + + result := c.executor.pollTmuxSession(ctx, task.ID, windowTarget) + + if sid := findCursorSessionID(workDir); sid != "" { + if err := c.executor.db.UpdateTaskClaudeSessionID(task.ID, sid); err != nil { + c.logger.Warn("failed to save cursor session ID", "task", task.ID, "error", err) + } + } + + return ExecResult(result) +} + +func cursorProcessName(comm string) bool { + comm = strings.TrimSpace(comm) + return strings.Contains(comm, "cursor-agent") || comm == "agent" || strings.HasSuffix(comm, "/agent") +} + +// GetProcessID returns the PID of the Cursor process for a task. +func (c *CursorExecutor) GetProcessID(taskID int64) int { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + windowName := TmuxWindowName(taskID) + + out, err := exec.CommandContext(ctx, "tmux", "list-panes", "-a", "-F", "#{session_name}:#{window_name}:#{pane_index} #{pane_pid}").Output() + if err != nil { + return 0 + } + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + target := parts[0] + pidStr := parts[1] + if !strings.Contains(target, windowName) { + continue + } + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + cmdOut, _ := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() + if cursorProcessName(string(cmdOut)) { + return pid + } + for _, name := range []string{"cursor-agent", "agent"} { + childOut, err := exec.CommandContext(ctx, "pgrep", "-P", strconv.Itoa(pid), name).Output() + if err == nil && len(childOut) > 0 { + childPid, err := strconv.Atoi(strings.TrimSpace(string(childOut))) + if err == nil { + return childPid + } + } + } + } + return 0 +} + +// Kill terminates the Cursor process for a task. +func (c *CursorExecutor) Kill(taskID int64) bool { + pid := c.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + c.logger.Debug("Failed to find Cursor process", "pid", pid, "error", err) + return false + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + c.logger.Debug("Failed to terminate Cursor process", "pid", pid, "error", err) + return false + } + c.logger.Info("Terminated Cursor process", "task", taskID, "pid", pid) + delete(c.suspendedTasks, taskID) + return true +} + +// Suspend pauses the Cursor process for a task. +func (c *CursorExecutor) Suspend(taskID int64) bool { + pid := c.GetProcessID(taskID) + if pid == 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + c.logger.Debug("Failed to find process", "pid", pid, "error", err) + return false + } + if err := sendSIGTSTP(proc); err != nil { + c.logger.Debug("Failed to suspend process", "pid", pid, "error", err) + return false + } + c.suspendedTasks[taskID] = time.Now() + c.logger.Info("Suspended Cursor process", "task", taskID, "pid", pid) + c.executor.logLine(taskID, "system", "Cursor suspended (idle timeout)") + return true +} + +// IsSuspended reports whether the Cursor process is suspended for a task. +func (c *CursorExecutor) IsSuspended(taskID int64) bool { + _, suspended := c.suspendedTasks[taskID] + return suspended +} + +// ResumeProcess resumes a previously suspended Cursor process. +func (c *CursorExecutor) ResumeProcess(taskID int64) bool { + if !c.IsSuspended(taskID) { + return false + } + pid := c.GetProcessID(taskID) + if pid == 0 { + delete(c.suspendedTasks, taskID) + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + delete(c.suspendedTasks, taskID) + return false + } + if err := sendSIGCONT(proc); err != nil { + c.logger.Debug("Failed to resume process", "pid", pid, "error", err) + return false + } + delete(c.suspendedTasks, taskID) + c.logger.Info("Resumed Cursor process", "task", taskID, "pid", pid) + c.executor.logLine(taskID, "system", "Cursor resumed") + return true +} + +// BuildCommand returns the shell command to start an interactive Cursor session. +func (c *CursorExecutor) BuildCommand(task *db.Task, sessionID, prompt string) string { + if err := ensureCursorWorktreeMCPConfig(task.WorktreePath, task.ID); err != nil { + c.logger.Error("BuildCommand: failed to write cursor MCP config", "error", err) + } + + worktreeSessionID := os.Getenv("WORKTREE_SESSION_ID") + if worktreeSessionID == "" { + worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + if prompt != "" { + promptFile, err := os.CreateTemp("", "task-prompt-*.txt") + if err != nil { + c.logger.Error("BuildCommand: failed to create temp file", "error", err) + return cursorLaunchScript(task, worktreeSessionID, sessionID, nil, "") + } + promptFile.WriteString(prompt) + promptFile.Close() + return cursorLaunchScript(task, worktreeSessionID, sessionID, nil, fmt.Sprintf(`"$(cat %q)"; rm -f %q`, promptFile.Name(), promptFile.Name())) + } + + return cursorLaunchScript(task, worktreeSessionID, sessionID, nil, "") +} + +func cursorDangerousEnabled(task *db.Task) bool { + if task != nil && task.IsDangerous() { + return true + } + return os.Getenv("WORKTREE_DANGEROUS_MODE") == "1" +} + +func cursorLaunchFlags(task *db.Task) string { + if cursorDangerousEnabled(task) { + return buildCursorDangerousFlag(true) + } + return "" +} + +func buildCursorDangerousFlag(enabled bool) string { + if !enabled && os.Getenv("WORKTREE_DANGEROUS_MODE") != "1" { + return "" + } + flag := strings.TrimSpace(os.Getenv("CURSOR_DANGEROUS_ARGS")) + if flag == "" { + flag = "--force" + } + if !strings.HasSuffix(flag, " ") { + flag += " " + } + return flag +} + +func cursorLaunchEnv(task *db.Task) string { + return dbPathEnvPrefix() + taskEnvPrefix(task) +} + +// cursorCLIFlags returns Cursor CLI flags (each with a trailing space). +// dangerousOverride is used by ResumeDangerous/ResumeSafe to force bypass on +// or off; nil honors the task. --approve-mcps is always included so the +// worktree-local taskyou MCP server is not blocked on a TUI prompt. +func cursorCLIFlags(task *db.Task, sessionID string, dangerousOverride *bool) string { + var perm string + if dangerousOverride != nil { + if *dangerousOverride { + perm = buildCursorDangerousFlag(true) + } + } else { + perm = cursorLaunchFlags(task) + } + model := "" + if task != nil { + model = modelFlag(task.Model) + } + resume := "" + if sessionID != "" { + resume = fmt.Sprintf("--resume %s ", sessionID) + } + return perm + "--approve-mcps " + model + resume +} + +func cursorLaunchScript(task *db.Task, worktreeSessionID, resumeSessionID string, dangerousOverride *bool, promptArg string) string { + bin := cursorLaunchBin() + if task == nil { + return bin + } + if worktreeSessionID == "" { + worktreeSessionID = fmt.Sprintf("%d", os.Getpid()) + } + flags := cursorCLIFlags(task, resumeSessionID, dangerousOverride) + script := fmt.Sprintf(`WORKTREE_TASK_ID=%d WORKTREE_SESSION_ID=%s WORKTREE_PORT=%d WORKTREE_PATH=%q %s%s %s%s`, + task.ID, worktreeSessionID, task.Port, task.WorktreePath, cursorLaunchEnv(task), bin, flags, promptArg) + return strings.TrimSpace(script) +} + +type cursorMCPFile struct { + MCPServers map[string]cursorMCPServer `json:"mcpServers"` +} + +type cursorMCPServer struct { + Command string `json:"command"` + Args []string `json:"args"` +} + +// ensureCursorWorktreeMCPConfig merges a taskyou stdio server into the +// worktree's `.cursor/mcp.json`. Cursor has no --mcp-config flag; it reads +// project MCP from that file. Existing servers (often committed in the repo) +// are preserved. +func ensureCursorWorktreeMCPConfig(workDir string, taskID int64) error { + if strings.TrimSpace(workDir) == "" || taskID == 0 { + return nil + } + dir := filepath.Join(workDir, ".cursor") + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + path := filepath.Join(dir, "mcp.json") + + cfg := cursorMCPFile{MCPServers: map[string]cursorMCPServer{}} + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + if err := json.Unmarshal(data, &cfg); err != nil { + cfg.MCPServers = map[string]cursorMCPServer{} + } + if cfg.MCPServers == nil { + cfg.MCPServers = map[string]cursorMCPServer{} + } + } + cfg.MCPServers["taskyou"] = cursorMCPServer{ + Command: resolveTaskExecutable(), + Args: []string{"mcp-server", "--task-id", fmt.Sprintf("%d", taskID)}, + } + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + out = append(out, '\n') + return os.WriteFile(path, out, 0644) +} + +// ---- Session and Dangerous Mode Support ---- + +// SupportsSessionResume returns true — Cursor supports --resume by chat ID. +func (c *CursorExecutor) SupportsSessionResume() bool { + return true +} + +// SupportsDangerousMode returns true — Cursor supports --force / --yolo. +func (c *CursorExecutor) SupportsDangerousMode() bool { + return true +} + +// FindSessionID discovers the most recent Cursor session ID for the given workDir. +func (c *CursorExecutor) FindSessionID(workDir string) string { + return findCursorSessionID(workDir) +} + +// ResumeDangerous kills the current Cursor process and restarts with --force. +func (c *CursorExecutor) ResumeDangerous(task *db.Task, workDir string) bool { + return c.resumeWithMode(task, workDir, true) +} + +// ResumeSafe kills the current Cursor process and restarts without --force. +func (c *CursorExecutor) ResumeSafe(task *db.Task, workDir string) bool { + return c.resumeWithMode(task, workDir, false) +} + +func (c *CursorExecutor) resumeWithMode(task *db.Task, workDir string, dangerousMode bool) bool { + taskID := task.ID + + sessionID := task.ClaudeSessionID + if sessionID == "" { + sessionID = findCursorSessionID(workDir) + } + if sessionID == "" || !cursorSessionExists(sessionID) { + c.executor.logLine(taskID, "system", "No Cursor session found - cannot toggle mode") + if sessionID != "" { + if err := c.executor.db.UpdateTaskClaudeSessionID(taskID, ""); err != nil { + c.logger.Warn("failed to clear stale session ID", "task", taskID, "error", err) + } + } + return false + } + + modeStr := "safe" + if dangerousMode { + modeStr = "dangerous" + } + c.executor.logLine(taskID, "system", fmt.Sprintf("Restarting Cursor in %s mode", modeStr)) + + if _, err := exec.LookPath("tmux"); err != nil { + c.executor.logLine(taskID, "system", "Tmux not available - cannot resume") + return false + } + + windowName := TmuxWindowName(taskID) + KillAllWindowsByNameAllSessions(windowName) + + daemonSession, err := ensureTmuxDaemon() + if err != nil { + c.logger.Warn("could not create task-daemon session", "error", err) + return false + } + + windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName) + + taskSessionID := os.Getenv("WORKTREE_SESSION_ID") + if taskSessionID == "" { + taskSessionID = fmt.Sprintf("%d", os.Getpid()) + } + + if err := ensureCursorWorktreeMCPConfig(workDir, taskID); err != nil { + c.logger.Warn("could not write cursor taskyou MCP config", "error", err) + } + override := dangerousMode + script := cursorLaunchScript(task, taskSessionID, sessionID, &override, "") + + actualSession, tmuxErr := createTmuxWindow(daemonSession, windowName, workDir, script, c.executor.getProjectDir(task.Project), task.ID) + if tmuxErr != nil { + c.logger.Warn("tmux failed to create window", "error", tmuxErr, "session", daemonSession) + return false + } + + if actualSession != daemonSession { + windowTarget = fmt.Sprintf("%s:%s", actualSession, windowName) + daemonSession = actualSession + } + + time.Sleep(200 * time.Millisecond) + + if err := c.executor.db.UpdateTaskDaemonSession(taskID, daemonSession); err != nil { + c.logger.Warn("failed to save daemon session", "task", taskID, "error", err) + } + if windowID := getWindowID(daemonSession, windowName); windowID != "" { + if err := c.executor.db.UpdateTaskWindowID(taskID, windowID); err != nil { + c.logger.Warn("failed to save window ID", "task", taskID, "error", err) + } + } + + paths := c.executor.claudePathsForTask(task) + c.executor.ensureShellPane(windowTarget, workDir, taskID, task.Port, task.WorktreePath, paths.configDir) + c.executor.configureTmuxWindow(windowTarget) + return true +} + +func cursorHomeDir() string { + if h := strings.TrimSpace(os.Getenv("CURSOR_CONFIG_DIR")); h != "" { + return h + } + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "cursor") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cursor") +} + +func cursorWorkspaceHash(path string) string { + // Cursor names CLI chat folders with MD5(cwd). This is a filesystem lookup + // key, not a security digest. + sum := md5.Sum([]byte(path)) //nolint:gosec // G401 + return hex.EncodeToString(sum[:]) +} + +func cursorChatsDir() string { + home := cursorHomeDir() + if home == "" { + return "" + } + return filepath.Join(home, "chats") +} + +func latestCursorSessionInGroup(group string) string { + entries, err := os.ReadDir(group) + if err != nil { + return "" + } + var latestTime time.Time + var latestID string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + mod := info.ModTime() + metaPath := filepath.Join(group, name, "meta.json") + if data, err := os.ReadFile(metaPath); err == nil { + var meta struct { + UpdatedAtMs int64 `json:"updatedAtMs"` + } + if json.Unmarshal(data, &meta) == nil && meta.UpdatedAtMs > 0 { + mod = time.UnixMilli(meta.UpdatedAtMs) + } else if st, err := os.Stat(metaPath); err == nil { + mod = st.ModTime() + } + } + if mod.After(latestTime) { + latestTime = mod + latestID = name + } + } + return latestID +} + +// findCursorSessionID discovers the most recent Cursor CLI chat ID for workDir. +// Cursor stores CLI chats at ~/.cursor/chats/// (see +// https://cursor.com/docs/cli). When the hash folder is missing, we fall back +// to meta.json files that record cwd. +func findCursorSessionID(workDir string) string { + if workDir == "" { + return "" + } + chats := cursorChatsDir() + if chats == "" { + return "" + } + + direct := filepath.Join(chats, cursorWorkspaceHash(workDir)) + if info, err := os.Stat(direct); err == nil && info.IsDir() { + if id := latestCursorSessionInGroup(direct); id != "" { + return id + } + } + + entries, err := os.ReadDir(chats) + if err != nil { + return "" + } + var latestTime time.Time + var latestID string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + group := filepath.Join(chats, entry.Name()) + sessions, err := os.ReadDir(group) + if err != nil { + continue + } + for _, session := range sessions { + if !session.IsDir() || strings.HasPrefix(session.Name(), ".") { + continue + } + metaPath := filepath.Join(group, session.Name(), "meta.json") + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta struct { + Cwd string `json:"cwd"` + UpdatedAtMs int64 `json:"updatedAtMs"` + } + if json.Unmarshal(data, &meta) != nil { + continue + } + if strings.TrimSpace(meta.Cwd) != workDir { + continue + } + mod := time.UnixMilli(meta.UpdatedAtMs) + if meta.UpdatedAtMs == 0 { + if st, err := os.Stat(metaPath); err == nil { + mod = st.ModTime() + } + } + if mod.After(latestTime) { + latestTime = mod + latestID = session.Name() + } + } + } + return latestID +} + +// cursorSessionExists reports whether a Cursor chat directory exists for sessionID. +func cursorSessionExists(sessionID string) bool { + if sessionID == "" { + return false + } + chats := cursorChatsDir() + if chats == "" { + return false + } + if _, err := os.Stat(chats); os.IsNotExist(err) { + return false + } + + found := false + filepath.Walk(chats, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || !info.IsDir() { + return nil + } + if info.Name() == sessionID { + found = true + return filepath.SkipAll + } + return nil + }) + return found +} diff --git a/internal/executor/cursor_executor_test.go b/internal/executor/cursor_executor_test.go new file mode 100644 index 00000000..ec3dc8fb --- /dev/null +++ b/internal/executor/cursor_executor_test.go @@ -0,0 +1,362 @@ +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bborn/workflow/internal/config" + "github.com/bborn/workflow/internal/db" +) + +func TestCursorExecutor_Name(t *testing.T) { + cursorExec := newTestCursorExecutor(t) + if cursorExec.Name() != db.ExecutorCursor { + t.Errorf("Name() = %q, want %q", cursorExec.Name(), db.ExecutorCursor) + } +} + +func TestCursorExecutor_Supports(t *testing.T) { + cursorExec := newTestCursorExecutor(t) + if !cursorExec.SupportsSessionResume() { + t.Error("Cursor should support session resume") + } + if !cursorExec.SupportsDangerousMode() { + t.Error("Cursor should support dangerous mode") + } +} + +func TestCursorWorkspaceHash(t *testing.T) { + path := "/Users/bruno/Projects/workflow" + got := cursorWorkspaceHash(path) + if len(got) != 32 { + t.Errorf("cursorWorkspaceHash() length = %d, want 32 hex chars, got %q", len(got), got) + } + for _, r := range got { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + t.Errorf("cursorWorkspaceHash() = %q, want lowercase hex", got) + break + } + } + if cursorWorkspaceHash(path) != got { + t.Error("cursorWorkspaceHash must be deterministic") + } + if cursorWorkspaceHash(path+"/other") == got { + t.Error("different paths must not hash to the same folder name") + } +} + +func TestFindCursorSessionID(t *testing.T) { + home := t.TempDir() + t.Setenv("CURSOR_CONFIG_DIR", home) + + workDir := "/tmp/proj/.task-worktrees/42-fix" + group := filepath.Join(home, "chats", cursorWorkspaceHash(workDir)) + if err := os.MkdirAll(filepath.Join(group, "session-old"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(group, "session-new"), 0755); err != nil { + t.Fatal(err) + } + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + if err := os.Chtimes(filepath.Join(group, "session-old"), oldTime, oldTime); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(group, "session-new"), newTime, newTime); err != nil { + t.Fatal(err) + } + + got := findCursorSessionID(workDir) + if got != "session-new" { + t.Errorf("findCursorSessionID() = %q, want session-new", got) + } + if !cursorSessionExists("session-new") { + t.Error("cursorSessionExists(session-new) = false, want true") + } + if cursorSessionExists("missing-session") { + t.Error("cursorSessionExists(missing-session) = true, want false") + } +} + +func TestFindCursorSessionID_MetaJSON(t *testing.T) { + home := t.TempDir() + t.Setenv("CURSOR_CONFIG_DIR", home) + + workDir := "/tmp/proj/.task-worktrees/99-meta" + group := filepath.Join(home, "chats", "not-the-hash") + if err := os.MkdirAll(filepath.Join(group, "abc-session"), 0755); err != nil { + t.Fatal(err) + } + meta := `{"cwd":"` + workDir + `","updatedAtMs":1700000000000,"title":"fix"}` + if err := os.WriteFile(filepath.Join(group, "abc-session", "meta.json"), []byte(meta), 0644); err != nil { + t.Fatal(err) + } + + got := findCursorSessionID(workDir) + if got != "abc-session" { + t.Errorf("findCursorSessionID via meta.json = %q, want abc-session", got) + } +} + +func TestBuildCursorDangerousFlag(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + t.Setenv("CURSOR_DANGEROUS_ARGS", "") + + if got := buildCursorDangerousFlag(false); got != "" { + t.Errorf("disabled = %q, want empty", got) + } + if got := buildCursorDangerousFlag(true); got != "--force " { + t.Errorf("enabled = %q, want %q", got, "--force ") + } + + t.Setenv("WORKTREE_DANGEROUS_MODE", "1") + if got := buildCursorDangerousFlag(false); got != "--force " { + t.Errorf("env override = %q, want --force ", got) + } + + t.Setenv("CURSOR_DANGEROUS_ARGS", "--yolo") + if got := buildCursorDangerousFlag(true); got != "--yolo " { + t.Errorf("custom args = %q, want %q", got, "--yolo ") + } +} + +func TestCursorLaunchFlags(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + t.Setenv("CURSOR_DANGEROUS_ARGS", "") + + dangerous := &db.Task{PermissionMode: db.PermissionModeDangerous} + if got := cursorLaunchFlags(dangerous); got != "--force " { + t.Errorf("dangerous = %q, want --force ", got) + } + + auto := &db.Task{PermissionMode: db.PermissionModeAuto} + if got := cursorLaunchFlags(auto); got != "" { + t.Errorf("auto = %q, want empty (Cursor has no --permission-mode)", got) + } + + accept := &db.Task{PermissionMode: db.PermissionModeAcceptEdits} + if got := cursorLaunchFlags(accept); got != "" { + t.Errorf("acceptEdits = %q, want empty", got) + } + + def := &db.Task{PermissionMode: db.PermissionModeDefault} + if got := cursorLaunchFlags(def); got != "" { + t.Errorf("default = %q, want empty", got) + } +} + +func TestCursorBuildCommand(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + t.Setenv("CURSOR_DANGEROUS_ARGS", "") + t.Setenv("WORKTREE_SESSION_ID", "sess") + + cursorExec := newTestCursorExecutor(t) + task := &db.Task{ + ID: 42, + Port: 3100, + WorktreePath: t.TempDir(), + PermissionMode: db.PermissionModeDangerous, + } + cmd := cursorExec.BuildCommand(task, "sess-id", "") + for _, want := range []string{ + "WORKTREE_TASK_ID=42", + "WORKTREE_PORT=3100", + "WORKTREE_PATH=", + "--force", + "--approve-mcps", + "--resume sess-id", + } { + if !strings.Contains(cmd, want) { + t.Errorf("BuildCommand missing %q in %q", want, cmd) + } + } + if !cursorCommandInvokesCLI(cmd) { + t.Errorf("BuildCommand must invoke the Cursor CLI; got %q", cmd) + } + if strings.Contains(cmd, "--worktree") { + t.Errorf("BuildCommand must not pass Cursor --worktree (TaskYou owns worktrees): %q", cmd) + } + + task.Model = "gpt-5" + cmd = cursorExec.BuildCommand(task, "", "") + if !strings.Contains(cmd, "--model 'gpt-5'") && !strings.Contains(cmd, `--model "gpt-5"`) { + t.Errorf("BuildCommand with model should contain --model gpt-5, got %q", cmd) + } +} + +func TestDetectExecutorIdentityCursor(t *testing.T) { + t.Setenv("TASK_EXECUTOR", "cursor") + slug, display := detectExecutorIdentity() + if slug != "cursor" { + t.Fatalf("expected slug cursor, got %q", slug) + } + if display != "Cursor" { + t.Fatalf("expected display Cursor, got %q", display) + } +} + +func newTestCursorExecutor(t *testing.T) TaskExecutor { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "tasks.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + ex := New(database, &config.Config{}) + cursorExec := ex.GetExecutor(db.ExecutorCursor) + if cursorExec == nil { + t.Fatal("cursor executor not registered") + } + return cursorExec +} + +func TestCursorBuildCommand_ResumeOnlyWithSessionID(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + cursorExec := newTestCursorExecutor(t) + task := &db.Task{ID: 9, Port: 3100, WorktreePath: t.TempDir()} + + fresh := cursorExec.BuildCommand(task, "", "") + if strings.Contains(fresh, "--resume") { + t.Errorf("BuildCommand with empty sessionID must not pass --resume; got:\n %s", fresh) + } + + resumed := cursorExec.BuildCommand(task, "01abc-session", "") + if !strings.Contains(resumed, "--resume 01abc-session") { + t.Errorf("BuildCommand with sessionID must pass --resume 01abc-session; got:\n %s", resumed) + } +} + +func TestCursorBuildCommand_ModelAndDBPath(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + cursorExec := newTestCursorExecutor(t) + task := &db.Task{ + ID: 11, + Port: 3100, + WorktreePath: t.TempDir(), + EffortLevel: db.EffortHigh, + Model: "composer-1", + } + + t.Setenv("WORKTREE_DB_PATH", "/tmp/iso/tasks.db") + cmd := cursorExec.BuildCommand(task, "", "") + if !strings.Contains(cmd, `WORKTREE_DB_PATH="/tmp/iso/tasks.db"`) { + t.Errorf("BuildCommand must carry WORKTREE_DB_PATH; got:\n %s", cmd) + } + bin := cursorLaunchBin() + if di, gi := strings.Index(cmd, "WORKTREE_DB_PATH="), strings.Index(cmd, bin+" "); di < 0 || gi < 0 || di > gi { + t.Errorf("WORKTREE_DB_PATH must precede `%s`; got:\n %s", bin, cmd) + } + if strings.Contains(cmd, "--effort") { + t.Errorf("Cursor CLI has no --effort flag; got:\n %s", cmd) + } + if !strings.Contains(cmd, "--model 'composer-1'") && !strings.Contains(cmd, `--model "composer-1"`) { + t.Errorf("BuildCommand with Model=composer-1 must contain --model composer-1; got:\n %s", cmd) + } + + t.Setenv("WORKTREE_DB_PATH", "") + task.EffortLevel = "" + task.Model = "" + def := cursorExec.BuildCommand(task, "", "") + if strings.Contains(def, "WORKTREE_DB_PATH=") { + t.Errorf("default instance: BuildCommand must NOT set WORKTREE_DB_PATH; got:\n %s", def) + } + if strings.Contains(def, "--force") { + t.Errorf("BuildCommand with default permission must not contain --force; got:\n %s", def) + } +} + +func TestCursorBuildCommand_WritesTaskyouMCPConfig(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + cursorExec := newTestCursorExecutor(t) + workDir := t.TempDir() + task := &db.Task{ID: 77, Port: 3100, WorktreePath: workDir} + + cmd := cursorExec.BuildCommand(task, "", "") + if !cursorCommandInvokesCLI(cmd) { + t.Fatalf("BuildCommand must invoke the Cursor CLI; got:\n %s", cmd) + } + + cfgPath := filepath.Join(workDir, ".cursor", "mcp.json") + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("expected cursor MCP config at %s: %v", cfgPath, err) + } + body := string(data) + if !strings.Contains(body, `"taskyou"`) { + t.Errorf("config missing taskyou server:\n%s", body) + } + if !strings.Contains(body, "mcp-server") { + t.Errorf("config must invoke mcp-server:\n%s", body) + } + if !strings.Contains(body, "--task-id") || !strings.Contains(body, "77") { + t.Errorf("config must pass --task-id 77:\n%s", body) + } +} + +func TestCursorBuildCommand_MergesExistingMCPConfig(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + cursorExec := newTestCursorExecutor(t) + workDir := t.TempDir() + mcpDir := filepath.Join(workDir, ".cursor") + if err := os.MkdirAll(mcpDir, 0755); err != nil { + t.Fatal(err) + } + existing := `{ + "mcpServers": { + "linear": { + "command": "npx", + "args": ["-y", "linear-mcp"] + } + } +}` + if err := os.WriteFile(filepath.Join(mcpDir, "mcp.json"), []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + task := &db.Task{ID: 12, Port: 3100, WorktreePath: workDir} + _ = cursorExec.BuildCommand(task, "", "") + + data, err := os.ReadFile(filepath.Join(mcpDir, "mcp.json")) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, `"linear"`) { + t.Errorf("merge dropped existing MCP server:\n%s", body) + } + if !strings.Contains(body, `"taskyou"`) { + t.Errorf("merge must add taskyou server:\n%s", body) + } +} + +func TestCursorResumeModeFlags(t *testing.T) { + t.Setenv("WORKTREE_DANGEROUS_MODE", "") + t.Setenv("CURSOR_DANGEROUS_ARGS", "") + task := &db.Task{ID: 3, Port: 3100, WorktreePath: "/tmp/wt", Model: "gpt-5"} + + dangerous := true + got := cursorCLIFlags(task, "sess-1", &dangerous) + if !strings.Contains(got, "--force") { + t.Errorf("ResumeDangerous flags must include --force; got %q", got) + } + if !strings.Contains(got, "--resume sess-1") { + t.Errorf("ResumeDangerous flags must include --resume sess-1; got %q", got) + } + + safe := false + got = cursorCLIFlags(task, "sess-1", &safe) + if strings.Contains(got, "--force") { + t.Errorf("ResumeSafe flags must not include --force; got %q", got) + } + if !strings.Contains(got, "--resume sess-1") { + t.Errorf("ResumeSafe flags must still include --resume; got %q", got) + } +} + +func cursorCommandInvokesCLI(cmd string) bool { + return strings.Contains(cmd, " cursor-agent ") || strings.Contains(cmd, " agent ") || + strings.HasSuffix(cmd, " cursor-agent") || strings.HasSuffix(cmd, " agent") +} diff --git a/internal/executor/dangerous_mode_test.go b/internal/executor/dangerous_mode_test.go index 3d49c976..3c5bcdd1 100644 --- a/internal/executor/dangerous_mode_test.go +++ b/internal/executor/dangerous_mode_test.go @@ -66,6 +66,13 @@ func TestExecutorInterfaceImplementation(t *testing.T) { supportsDangerousMode: true, dangerousFlag: "--always-approve", }, + { + name: "Cursor executor", + executorName: db.ExecutorCursor, + supportsSessionResume: true, + supportsDangerousMode: true, + dangerousFlag: "--force", + }, { name: "OpenClaw executor", executorName: db.ExecutorOpenClaw, @@ -195,6 +202,18 @@ func TestBuildCommandDangerousMode(t *testing.T) { dangerousMode: false, wantFlag: "", }, + { + name: "Cursor with dangerous mode enabled", + executorName: db.ExecutorCursor, + dangerousMode: true, + wantFlag: "--force", + }, + { + name: "Cursor with dangerous mode disabled", + executorName: db.ExecutorCursor, + dangerousMode: false, + wantFlag: "", + }, } for _, tt := range tests { @@ -220,6 +239,8 @@ func TestBuildCommandDangerousMode(t *testing.T) { "--dangerously-bypass-approvals-and-sandbox", "--dangerously-allow-run", "--always-approve", + "--force", + "--yolo", } for _, flag := range dangerousFlags { if strings.Contains(cmd, flag) { @@ -270,6 +291,7 @@ func TestBuildCommandDangerousModeEnvVar(t *testing.T) { {db.ExecutorCodex, "--dangerously-bypass-approvals-and-sandbox"}, {db.ExecutorGemini, "--dangerously-allow-run"}, {db.ExecutorGrok, "--always-approve"}, + {db.ExecutorCursor, "--force"}, } for _, tt := range tests { @@ -577,6 +599,12 @@ func TestBuildCommandWithSessionResume(t *testing.T) { sessionID: "grok-session-012", wantContains: "--resume grok-session-012", }, + { + name: "Cursor with session ID", + executorName: db.ExecutorCursor, + sessionID: "cursor-session-345", + wantContains: "--resume cursor-session-345", + }, } for _, tt := range tests { @@ -635,6 +663,7 @@ func TestBuildCommandWithDangerousAndResume(t *testing.T) { {db.ExecutorCodex, "--dangerously-bypass-approvals-and-sandbox"}, {db.ExecutorGemini, "--dangerously-allow-run"}, {db.ExecutorGrok, "--always-approve"}, + {db.ExecutorCursor, "--force"}, } sessionID := "test-session-combined" @@ -1023,7 +1052,7 @@ func TestBuildCommandIncludesEnvironmentVariables(t *testing.T) { WorktreePath: "/home/user/projects/myapp/.task-worktrees/42-fix-bug", } - executors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorGrok, db.ExecutorOpenClaw, db.ExecutorOpenCode} + executors := []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini, db.ExecutorGrok, db.ExecutorCursor, db.ExecutorOpenClaw, db.ExecutorOpenCode} for _, name := range executors { t.Run(name, func(t *testing.T) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index f8ab86e1..1f60588a 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -146,6 +146,8 @@ func formatExecutorDisplayName(slug, raw string) string { return "Gemini" case "grok": return "Grok" + case "cursor": + return "Cursor" case "pi": return "Pi" } @@ -239,6 +241,7 @@ func (e *Executor) registerBuiltinExecutors() { e.executorFactory.Register(NewCodexExecutor(e)) e.executorFactory.Register(NewGeminiExecutor(e)) e.executorFactory.Register(NewGrokExecutor(e)) + e.executorFactory.Register(NewCursorExecutor(e)) e.executorFactory.Register(NewOpenClawExecutor(e)) e.executorFactory.Register(NewOpenCodeExecutor(e)) e.executorFactory.Register(NewPiExecutor(e)) diff --git a/internal/executor/worktree_guard.go b/internal/executor/worktree_guard.go index 6ff2e094..dddb52a0 100644 --- a/internal/executor/worktree_guard.go +++ b/internal/executor/worktree_guard.go @@ -33,7 +33,7 @@ import ( // WorktreeGuardInput is the canonical, executor-agnostic slice of a pre-tool-use // payload the guard needs. Every supported agent CLI (Claude, Codex, Gemini, -// Grok, OpenCode) normalizes its native hook payload into this shape before evaluating +// Grok, Cursor, OpenCode) normalizes its native hook payload into this shape before evaluating // the guard, so the policy below has a single source of truth. It is also // decoupled from any hook JSON struct so the guard can be unit-tested without // constructing full hook payloads. @@ -114,17 +114,22 @@ func IsManagedWorktree(path string) bool { func externalWriteTargets(in WorktreeGuardInput, root string, allowExternal []string) []string { var raw []string switch in.ToolName { - // File-edit tools whose target lives in a "file_path" field. + // File-edit tools whose target lives in a "file_path" (or Cursor "path") field. // Claude: Edit / Write / MultiEdit · Gemini: write_file / replace - // Grok: search_replace / write + // Grok: search_replace / write · Cursor: Write / StrReplace / Delete // (The OpenCode plugin normalizes its write/edit tools to "Write" + file_path.) - case "Edit", "Write", "MultiEdit", "write_file", "replace", "search_replace", "write": + case "Edit", "Write", "MultiEdit", "write_file", "replace", "search_replace", "write", + "StrReplace", "SearchReplace", "Delete": raw = stringField(in.ToolInput, "file_path") + if len(raw) == 0 { + raw = stringField(in.ToolInput, "path") + } case "NotebookEdit": raw = stringField(in.ToolInput, "notebook_path") // Shell tools whose command lives in a "command" field. // Claude: Bash · Gemini: run_shell_command · Grok: run_terminal_command - case "Bash", "run_shell_command", "run_terminal_command": + // Cursor: Shell + case "Bash", "run_shell_command", "run_terminal_command", "Shell", "shell": raw = bashWriteTargets(in.ToolInput) // Codex (and the OpenCode plugin) edit files through apply_patch; the write // targets live as marker lines inside the patch envelope carried in "command". diff --git a/internal/executor/worktree_guard_hooks.go b/internal/executor/worktree_guard_hooks.go index 3912fdde..1e1eed54 100644 --- a/internal/executor/worktree_guard_hooks.go +++ b/internal/executor/worktree_guard_hooks.go @@ -33,6 +33,14 @@ import ( // Project hooks require folder trust; the executor launches with // GROK_FOLDER_TRUST=0 so the guard actually runs in daemon-driven sessions. // +// - Cursor CLI — preToolUse + beforeShellExecution hooks +// (`/.cursor/hooks.json`). Cursor's project hook file is +// `{version, hooks: {preToolUse: [{command}]}}` (flat command entries, not +// Codex's nested hooks array). Native tools include Write / StrReplace / +// Shell with a `path` field; ask is not supported, so ask→deny. A deny is +// signalled with `{permission: deny}` JSON (and exit 2 as a belt-and-braces +// match for Cursor's documented "exit 2 blocks" contract). +// // - OpenCode — `tool.execute.before` plugin hook (a generated JS plugin in // `/.opencode/plugins/`). The plugin normalizes the tool call and // shells back into `worktree-guard`; a non-zero exit makes it throw, which @@ -95,6 +103,54 @@ func (e *Executor) setupGrokWorktreeGuard(workDir, projectDir string) (func(), e return cleanup, err } +// setupCursorWorktreeGuard writes Cursor preToolUse and beforeShellExecution +// hooks into the worktree that enforce the write-guard. Returns a cleanup that +// restores the prior hooks.json (or removes it if we created it). +func (e *Executor) setupCursorWorktreeGuard(workDir, projectDir string) (func(), error) { + path := filepath.Join(workDir, ".cursor", "hooks.json") + cmd := fmt.Sprintf("%q worktree-guard --format cursor", resolveTaskBin()) + + existingData, existingErr := os.ReadFile(path) + + cfg := map[string]any{"version": 1} + if existingErr == nil { + if err := json.Unmarshal(existingData, &cfg); err != nil { + cfg = map[string]any{"version": 1} + } + } + + hooks, _ := cfg["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + } + entry := map[string]any{"command": cmd} + for _, event := range []string{"preToolUse", "beforeShellExecution"} { + events, _ := hooks[event].([]any) + hooks[event] = append(events, entry) + } + cfg["hooks"] = hooks + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(path, data, 0644); err != nil { + return nil, err + } + + cleanup := func() { + if existingErr == nil { + _ = os.WriteFile(path, existingData, 0644) + } else { + _ = os.Remove(path) + } + } + return cleanup, nil +} + // writeMergedCommandHook appends a {"type":"command","command":cmd} hook under // hooks. in the JSON file at path, preserving any existing configuration, // and returns a cleanup that restores the original file (or removes it if it was diff --git a/internal/executor/worktree_guard_hooks_test.go b/internal/executor/worktree_guard_hooks_test.go index bf0f4503..b47076f1 100644 --- a/internal/executor/worktree_guard_hooks_test.go +++ b/internal/executor/worktree_guard_hooks_test.go @@ -44,6 +44,14 @@ func TestSetupWorktreeGuardHooks(t *testing.T) { wantFormat: "grok", jsonHook: true, }, + { + name: "cursor", + setup: func(e *Executor, wd string) (func(), error) { return e.setupCursorWorktreeGuard(wd, "") }, + relPath: ".cursor/hooks.json", + wantEvent: "preToolUse", + wantFormat: "cursor", + jsonHook: false, + }, { name: "opencode", setup: func(e *Executor, wd string) (func(), error) { return e.setupOpenCodeWorktreeGuard(wd, "") }, @@ -78,6 +86,14 @@ func TestSetupWorktreeGuardHooks(t *testing.T) { if !strings.Contains(command, "worktree-guard --format "+c.wantFormat) { t.Errorf("hook command = %q, want it to invoke worktree-guard --format %s", command, c.wantFormat) } + } else if c.name == "cursor" { + body := string(data) + if !strings.Contains(body, `"preToolUse"`) || !strings.Contains(body, `"beforeShellExecution"`) { + t.Errorf("cursor hooks.json missing preToolUse/beforeShellExecution:\n%s", body) + } + if !strings.Contains(body, "worktree-guard --format cursor") { + t.Errorf("cursor hooks.json must invoke worktree-guard --format cursor:\n%s", body) + } } else { // OpenCode plugin: must register the tool.execute.before hook. if !strings.Contains(string(data), "tool.execute.before") { diff --git a/internal/executor/worktree_guard_test.go b/internal/executor/worktree_guard_test.go index a02ec964..74a16a15 100644 --- a/internal/executor/worktree_guard_test.go +++ b/internal/executor/worktree_guard_test.go @@ -161,6 +161,29 @@ func TestEvaluateWorktreeWriteGuard(t *testing.T) { in: WorktreeGuardInput{ToolName: "run_terminal_command", Cwd: wt}, want: "ask", }, + { + name: "cursor Write path outside asks", + root: wt, + in: WorktreeGuardInput{ToolName: "Write", Cwd: wt}, + want: "ask", + }, + { + name: "cursor Write path inside worktree allowed", + root: wt, + in: WorktreeGuardInput{ToolName: "Write", Cwd: wt}, + }, + { + name: "cursor StrReplace path outside asks", + root: wt, + in: WorktreeGuardInput{ToolName: "StrReplace", Cwd: wt}, + want: "ask", + }, + { + name: "cursor Shell redirect outside asks", + root: wt, + in: WorktreeGuardInput{ToolName: "Shell", Cwd: wt}, + want: "ask", + }, { name: "codex apply_patch update outside asks", root: wt, @@ -199,6 +222,10 @@ func TestEvaluateWorktreeWriteGuard(t *testing.T) { "grok search_replace outside asks": {"file_path": "/home/u/proj/config.yaml"}, "grok search_replace inside worktree allowed": {"file_path": wt + "/config.yaml"}, "grok run_terminal_command redirect outside asks": {"command": "echo data > /home/u/proj/out.txt"}, + "cursor Write path outside asks": {"path": "/home/u/proj/config.yaml"}, + "cursor Write path inside worktree allowed": {"path": wt + "/config.yaml"}, + "cursor StrReplace path outside asks": {"path": "/home/u/proj/app/models/event.rb"}, + "cursor Shell redirect outside asks": {"command": "echo data > /home/u/proj/out.txt"}, "codex apply_patch update outside asks": {"command": applyPatch("*** Update File: /home/u/proj/app/models/event.rb")}, "codex apply_patch update inside worktree allowed": {"command": applyPatch("*** Update File: app/models/event.rb")}, "codex apply_patch outside in bypass mode denies": {"command": applyPatch("*** Add File: /home/u/proj/new.rb")}, diff --git a/internal/pipeline/generate.go b/internal/pipeline/generate.go index c0f87e41..2cb51722 100644 --- a/internal/pipeline/generate.go +++ b/internal/pipeline/generate.go @@ -63,7 +63,7 @@ name: description: steps: - name: - executor: # optional, default claude + executor: # optional, default claude model: # optional, only meaningful for claude deps: [] # omit for the first step prompt: | diff --git a/internal/ui/app.go b/internal/ui/app.go index 5ef29fd2..6af9c93b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -531,6 +531,8 @@ func taskExecutorDisplayName(task *db.Task) string { return "Gemini" case db.ExecutorGrok: return "Grok" + case db.ExecutorCursor: + return "Cursor" case db.ExecutorOpenClaw: return "OpenClaw" default: @@ -1836,7 +1838,7 @@ func (m *AppModel) renderWelcomeMessage(height int) string { warningStyle := lipgloss.NewStyle(). Foreground(ColorWarning) lines = append(lines, warningStyle.Render(IconBlocked()+" No AI executor found")) - lines = append(lines, descStyle.Render(" Install one: claude, codex, gemini, or grok")) + lines = append(lines, descStyle.Render(" Install one: claude, codex, gemini, grok, or cursor")) } else { readyStyle := lipgloss.NewStyle(). Foreground(ColorDone) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 1c0ceddb..0c849737 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -343,6 +343,8 @@ func (m *DetailModel) executorDisplayName() string { return "Gemini" case db.ExecutorGrok: return "Grok" + case db.ExecutorCursor: + return "Cursor" case db.ExecutorOpenClaw: return "OpenClaw" default: diff --git a/internal/ui/form.go b/internal/ui/form.go index 8a05c0bf..77f2102e 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -100,7 +100,7 @@ type FormModel struct { taskType string // Selected kind: a task type (single task) or a workflow name. typeIdx int types []string // Unified kind list: "" (none), task types, then workflow kinds. - executor string // "claude", "codex", "gemini", "grok" + executor string // "claude", "codex", "gemini", "grok", "cursor" executorIdx int executors []string availableExecutors []string // Original list of available executors (for rebuilding when project changes)