Add Cursor agent support on new agent interface#392
Conversation
PR SummaryMedium Risk Overview Implements Cursor hook management for Written by Cursor Bugbot for commit ac26b1b. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
| // handleCursorPostTodo handles the PostToolUse[TodoWrite] hook for Cursor. | ||
| // Reuses the same incremental checkpoint logic as Claude Code. | ||
| func handleCursorPostTodo() error { | ||
| return handleClaudeCodePostTodo() |
There was a problem hiding this comment.
Cursor post-todo loses session fallback
Medium Severity
handleCursorPostTodo delegates to handleClaudeCodePostTodo, which parses post-todo payloads using session_id only. Cursor payloads can use conversation_id fallback, so SessionID can become empty for Cursor post-todo events. That causes incremental checkpoints to be associated with the wrong session metadata path.
Additional Locations (1)
There was a problem hiding this comment.
Pull request overview
Adds Cursor as a first-class agent implementation in the refactored agent/hook architecture, enabling lifecycle event dispatch, hook installation into .cursor/hooks.json, and reuse of the existing JSONL transcript + incremental checkpoint flow.
Changes:
- Introduces
cmd/entire/cli/agent/cursor/implementing Cursor agent identity, transcript handling, lifecycle event mapping, and hook management. - Wires Cursor into hook command registration/dispatch (including PostTodo incremental checkpoints) and agent registry constants.
- Updates summarization to treat Cursor transcripts as JSONL (shared path with Claude Code).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| cmd/entire/cli/summarize/summarize.go | Routes Cursor to the JSONL condensed-transcript path. |
| cmd/entire/cli/setup.go | Adds Cursor to “Preview” messaging and to global hook removal. |
| cmd/entire/cli/hooks_cursor_posttodo.go | Adds Cursor PostTodo handler delegating to Claude’s incremental checkpoint logic. |
| cmd/entire/cli/hooks_cmd.go | Ensures Cursor agent package is registered via blank import. |
| cmd/entire/cli/hook_registry.go | Adds Cursor PostTodo dispatch path alongside Claude’s. |
| cmd/entire/cli/agent/registry.go | Adds AgentNameCursor / AgentTypeCursor constants. |
| cmd/entire/cli/agent/cursor/types.go | Defines Cursor hooks.json structures + hook input raw types with conversation_id fallback. |
| cmd/entire/cli/agent/cursor/lifecycle.go | Implements ParseHookEvent mapping Cursor hooks to normalized lifecycle events + transcript analyzer methods. |
| cmd/entire/cli/agent/cursor/lifecycle_test.go | Adds unit tests for lifecycle mapping and conversation_id fallback behavior. |
| cmd/entire/cli/agent/cursor/hooks.go | Implements install/uninstall/detection for .cursor/hooks.json with matcher-based tool hooks. |
| cmd/entire/cli/agent/cursor/hooks_test.go | Adds unit tests for hook install/uninstall/idempotency/preservation behavior. |
| cmd/entire/cli/agent/cursor/cursor.go | Implements Cursor agent identity, legacy hook parsing, session I/O, transcript chunking, and modified-file extraction. |
cmd/entire/cli/agent/cursor/hooks.go
Outdated
There was a problem hiding this comment.
AreHooksInstalled() only checks Stop/SessionStart/BeforeSubmitPrompt. If those entries are removed but Entire tool hooks (PreToolUse/PostToolUse) or SessionEnd remain, this will incorrectly report hooks as not installed (affecting uninstall/remove messaging and any logic that relies on this flag). Consider checking all hook sections (SessionEnd, PreToolUse, PostToolUse, etc.), similar to how Gemini CLI’s AreHooksInstalled scans every hook type.
| hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt) | |
| hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt) || | |
| hasEntireHook(hooksFile.Hooks.SessionEnd) || | |
| hasEntireHook(hooksFile.Hooks.PreToolUse) || | |
| hasEntireHook(hooksFile.Hooks.PostToolUse) |
| // Read existing hooks file if it exists | ||
| var hooksFile CursorHooksFile | ||
|
|
||
| existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path | ||
| if readErr == nil { | ||
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | ||
| return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err) | ||
| } |
There was a problem hiding this comment.
InstallHooks unmarshals the existing .cursor/hooks.json into CursorHooksFile (a fixed struct) and later marshals it back. Any unknown top-level fields or unmodeled hook sections present in a user’s hooks.json will be dropped on write, which can unintentionally delete user/Cursor config. Consider preserving unknown JSON by parsing into a raw map (e.g., map[string]json.RawMessage for the hooks object) and only mutating the specific hook arrays you manage (similar to the Claude/Gemini hook installers).
905aeb4 to
2abcac1
Compare
Entire-Checkpoint: 04c6b0cd0999
dd067cd to
c072c01
Compare
| // Read existing hooks file if it exists | ||
| var hooksFile CursorHooksFile | ||
|
|
||
| existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path | ||
| if readErr == nil { | ||
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | ||
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | ||
| } | ||
| } else { | ||
| hooksFile.Version = 1 | ||
| } | ||
|
|
||
| // If force is true, remove all existing Entire hooks first | ||
| if force { | ||
| hooksFile.Hooks.SessionStart = removeEntireHooks(hooksFile.Hooks.SessionStart) | ||
| hooksFile.Hooks.SessionEnd = removeEntireHooks(hooksFile.Hooks.SessionEnd) | ||
| hooksFile.Hooks.BeforeSubmitPrompt = removeEntireHooks(hooksFile.Hooks.BeforeSubmitPrompt) | ||
| hooksFile.Hooks.Stop = removeEntireHooks(hooksFile.Hooks.Stop) | ||
| hooksFile.Hooks.PreToolUse = removeEntireHooks(hooksFile.Hooks.PreToolUse) | ||
| hooksFile.Hooks.PostToolUse = removeEntireHooks(hooksFile.Hooks.PostToolUse) |
There was a problem hiding this comment.
InstallHooks unmarshals .cursor/hooks.json into a typed struct and later writes it back. Any unknown top-level fields (or unknown hook categories under hooks) will be silently dropped on write, which is destructive to user configuration. To avoid data loss, preserve unknown JSON fields (e.g., unmarshal into map[string]json.RawMessage and only update the known sections, similar to the Claude Code hook installer).
| // Read existing hooks file if it exists | |
| var hooksFile CursorHooksFile | |
| existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path | |
| if readErr == nil { | |
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | |
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | |
| } | |
| } else { | |
| hooksFile.Version = 1 | |
| } | |
| // If force is true, remove all existing Entire hooks first | |
| if force { | |
| hooksFile.Hooks.SessionStart = removeEntireHooks(hooksFile.Hooks.SessionStart) | |
| hooksFile.Hooks.SessionEnd = removeEntireHooks(hooksFile.Hooks.SessionEnd) | |
| hooksFile.Hooks.BeforeSubmitPrompt = removeEntireHooks(hooksFile.Hooks.BeforeSubmitPrompt) | |
| hooksFile.Hooks.Stop = removeEntireHooks(hooksFile.Hooks.Stop) | |
| hooksFile.Hooks.PreToolUse = removeEntireHooks(hooksFile.Hooks.PreToolUse) | |
| hooksFile.Hooks.PostToolUse = removeEntireHooks(hooksFile.Hooks.PostToolUse) | |
| // Read existing hooks file if it exists, preserving unknown fields. | |
| type cursorHooksConfig struct { | |
| Hooks CursorHooks `json:"hooks,omitempty"` | |
| } | |
| raw := make(map[string]json.RawMessage) | |
| var hooksConfig cursorHooksConfig | |
| existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path | |
| if readErr == nil { | |
| if err := json.Unmarshal(existingData, &raw); err != nil { | |
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | |
| } | |
| if hooksRaw, ok := raw["hooks"]; ok && len(hooksRaw) > 0 { | |
| if err := json.Unmarshal(hooksRaw, &hooksConfig.Hooks); err != nil { | |
| return 0, fmt.Errorf("failed to parse hooks section in "+HooksFileName+": %w", err) | |
| } | |
| } | |
| } else if os.IsNotExist(readErr) { | |
| // No existing file; initialize empty raw map and set default version. | |
| versionBytes, marshalErr := json.Marshal(1) | |
| if marshalErr != nil { | |
| return 0, fmt.Errorf("failed to marshal default version for "+HooksFileName+": %w", marshalErr) | |
| } | |
| raw["version"] = versionBytes | |
| } else { | |
| return 0, fmt.Errorf("failed to read existing "+HooksFileName+": %w", readErr) | |
| } | |
| // If force is true, remove all existing Entire hooks first | |
| if force { | |
| hooksConfig.Hooks.SessionStart = removeEntireHooks(hooksConfig.Hooks.SessionStart) | |
| hooksConfig.Hooks.SessionEnd = removeEntireHooks(hooksConfig.Hooks.SessionEnd) | |
| hooksConfig.Hooks.BeforeSubmitPrompt = removeEntireHooks(hooksConfig.Hooks.BeforeSubmitPrompt) | |
| hooksConfig.Hooks.Stop = removeEntireHooks(hooksConfig.Hooks.Stop) | |
| hooksConfig.Hooks.PreToolUse = removeEntireHooks(hooksConfig.Hooks.PreToolUse) | |
| hooksConfig.Hooks.PostToolUse = removeEntireHooks(hooksConfig.Hooks.PostToolUse) |
|
|
||
| // handleCursorPostTodo handles the PostToolUse[TodoWrite] hook for Cursor. | ||
| // Reuses the same incremental checkpoint logic as Claude Code. | ||
| func handleCursorPostTodo() error { |
There was a problem hiding this comment.
handleCursorPostTodo currently delegates directly to handleClaudeCodePostTodo(), but the Claude handler expects a Claude-style PostToolUse payload (notably tool_name). Cursor hook inputs in this PR’s tests don’t include tool_name, so incremental checkpoints will be recorded with an empty IncrementalType (and log output will be missing the tool name). Prefer parsing Cursor’s payload here and setting the tool name explicitly (e.g., TodoWrite) before calling shared incremental-checkpoint logic.
| // handleCursorPostTodo handles the PostToolUse[TodoWrite] hook for Cursor. | |
| // Reuses the same incremental checkpoint logic as Claude Code. | |
| func handleCursorPostTodo() error { | |
| import ( | |
| "bytes" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "os" | |
| ) | |
| // handleCursorPostTodo handles the PostToolUse[TodoWrite] hook for Cursor. | |
| // It normalizes Cursor's payload to the Claude-style PostToolUse payload by | |
| // ensuring a tool_name is present before delegating to the shared handler. | |
| // Reuses the same incremental checkpoint logic as Claude Code. | |
| func handleCursorPostTodo() error { | |
| // Read the original Cursor payload from stdin. | |
| input, err := io.ReadAll(os.Stdin) | |
| if err != nil { | |
| return fmt.Errorf("failed to read Cursor PostTodo payload: %w", err) | |
| } | |
| // Attempt to parse the payload as JSON so we can inject tool_name. | |
| var payload map[string]any | |
| if err := json.Unmarshal(input, &payload); err != nil { | |
| // Fall back to the original handler behavior if parsing fails. | |
| // Restore stdin so handleClaudeCodePostTodo can see the original payload. | |
| os.Stdin = io.NopCloser(bytes.NewReader(input)) | |
| return handleClaudeCodePostTodo() | |
| } | |
| // Ensure tool_name is set so incremental checkpoints and logs can record it. | |
| if _, ok := payload["tool_name"]; !ok { | |
| payload["tool_name"] = "TodoWrite" | |
| } | |
| modified, err := json.Marshal(payload) | |
| if err != nil { | |
| return fmt.Errorf("failed to marshal normalized Cursor PostTodo payload: %w", err) | |
| } | |
| // Replace stdin with the normalized payload for the shared Claude handler. | |
| os.Stdin = io.NopCloser(bytes.NewReader(modified)) |
cmd/entire/cli/setup.go
Outdated
There was a problem hiding this comment.
Cursor hooks are now removed in removeAgentHooks, but runUninstall’s “anything to uninstall?” check and its confirmation messaging only account for Claude Code and Gemini hooks. This can cause entire uninstall to exit early (and/or omit Cursor from the list) when Cursor hooks are the only remaining install artifact. Add a checkCursorHooksInstalled() path and include it in both the early-exit predicate and the user-facing uninstall summary.
| // readAndParse reads stdin and unmarshals JSON into the given type. | ||
| func readAndParse[T any](stdin io.Reader) (*T, error) { | ||
| data, err := io.ReadAll(stdin) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read hook input: %w", err) | ||
| } | ||
| if len(data) == 0 { | ||
| return nil, errors.New("empty hook input") | ||
| } | ||
| var result T | ||
| if err := json.Unmarshal(data, &result); err != nil { | ||
| return nil, fmt.Errorf("failed to parse hook input: %w", err) | ||
| } | ||
| return &result, nil | ||
| } |
There was a problem hiding this comment.
This file reimplements readAndParse even though there is already a shared helper agent.ReadAndParseHookInput used by the Claude Code and Gemini lifecycle parsers. Reusing the shared helper would reduce duplicated parsing/error-message logic and keep Cursor consistent with the other agents’ lifecycle implementations.
| // readAndParse reads stdin and unmarshals JSON into the given type. | |
| func readAndParse[T any](stdin io.Reader) (*T, error) { | |
| data, err := io.ReadAll(stdin) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to read hook input: %w", err) | |
| } | |
| if len(data) == 0 { | |
| return nil, errors.New("empty hook input") | |
| } | |
| var result T | |
| if err := json.Unmarshal(data, &result); err != nil { | |
| return nil, fmt.Errorf("failed to parse hook input: %w", err) | |
| } | |
| return &result, nil | |
| } | |
| // readAndParse delegates to the shared agent.ReadAndParseHookInput helper | |
| // to keep parsing and error handling consistent across agents. | |
| func readAndParse[T any](stdin io.Reader) (*T, error) { | |
| return agent.ReadAndParseHookInput[T](stdin) | |
| } |
| // ParseHookInput parses Cursor hook input from stdin. | ||
| func (c *CursorAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { | ||
| data, err := io.ReadAll(reader) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read input: %w", err) |
There was a problem hiding this comment.
The Cursor agent introduces new parsing/IO behavior (e.g., ParseHookInput and ReadSession) but there are currently no unit tests covering these paths, unlike the existing Claude/Gemini agents which test hook parsing and session I/O. Adding tests here would catch schema drift (role-vs-type transcripts, conversation_id fallback) and ensure file-modification extraction stays correct.
| // Line represents a single line in a Claude Code or Cursor JSONL transcript. | ||
| type Line struct { | ||
| Type string `json:"type"` | ||
| Role string `json:"role"` |
There was a problem hiding this comment.
transcript.Line now includes a Role field without omitempty. Any code that re-serializes parsed Claude transcripts (e.g., truncation/rewind) will start emitting "role":"" on every line, which changes the transcript format and could break downstream consumers. Consider tagging this field as optional (e.g., json:"role,omitempty") so Claude JSONL round-trips don’t gain empty fields while Cursor transcripts can still populate it.
| Role string `json:"role"` | |
| Role string `json:"role,omitempty"` |
# Conflicts: # cmd/entire/cli/setup.go
| // Read existing hooks file if it exists | ||
| var hooksFile CursorHooksFile | ||
|
|
||
| existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path | ||
| if readErr == nil { | ||
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | ||
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | ||
| } |
There was a problem hiding this comment.
hooks.json is unmarshaled into CursorHooksFile/CursorHooks and then written back, which will drop any unknown top-level fields or unknown hook groups under hooks. For forward-compatibility and to avoid clobbering user config, consider preserving unknown JSON (e.g., parse the hooks section as map[string]json.RawMessage and only mutate the known keys).
| if !hookCommandExists(hooksFile.Hooks.SessionStart, sessionStartCmd) { | ||
| hooksFile.Hooks.SessionStart = append(hooksFile.Hooks.SessionStart, CursorHookEntry{Command: sessionStartCmd}) | ||
| count++ | ||
| } | ||
| if !hookCommandExists(hooksFile.Hooks.SessionEnd, sessionEndCmd) { | ||
| hooksFile.Hooks.SessionEnd = append(hooksFile.Hooks.SessionEnd, CursorHookEntry{Command: sessionEndCmd}) | ||
| count++ | ||
| } | ||
| if !hookCommandExists(hooksFile.Hooks.BeforeSubmitPrompt, beforeSubmitPromptCmd) { | ||
| hooksFile.Hooks.BeforeSubmitPrompt = append(hooksFile.Hooks.BeforeSubmitPrompt, CursorHookEntry{Command: beforeSubmitPromptCmd}) | ||
| count++ | ||
| } | ||
| if !hookCommandExists(hooksFile.Hooks.Stop, stopCmd) { | ||
| hooksFile.Hooks.Stop = append(hooksFile.Hooks.Stop, CursorHookEntry{Command: stopCmd}) | ||
| count++ | ||
| } | ||
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PreToolUse, "Task", preTaskCmd) { | ||
| hooksFile.Hooks.PreToolUse = append(hooksFile.Hooks.PreToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Task"}) | ||
| count++ | ||
| } | ||
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PostToolUse, "Task", postTaskCmd) { | ||
| hooksFile.Hooks.PostToolUse = append(hooksFile.Hooks.PostToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Task"}) | ||
| count++ | ||
| } | ||
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PostToolUse, "TodoWrite", postTodoCmd) { | ||
| hooksFile.Hooks.PostToolUse = append(hooksFile.Hooks.PostToolUse, CursorHookEntry{Command: postTodoCmd, Matcher: "TodoWrite"}) |
There was a problem hiding this comment.
InstallHooks can add duplicate hook entries when switching between localDev=true (go run …) and localDev=false (entire …) because it only checks for the exact command string. Consider detecting an existing Entire hook with the other prefix and replacing/removing it (or require force for mode switching) to keep the file idempotent across mode changes.
| if !hookCommandExists(hooksFile.Hooks.SessionStart, sessionStartCmd) { | |
| hooksFile.Hooks.SessionStart = append(hooksFile.Hooks.SessionStart, CursorHookEntry{Command: sessionStartCmd}) | |
| count++ | |
| } | |
| if !hookCommandExists(hooksFile.Hooks.SessionEnd, sessionEndCmd) { | |
| hooksFile.Hooks.SessionEnd = append(hooksFile.Hooks.SessionEnd, CursorHookEntry{Command: sessionEndCmd}) | |
| count++ | |
| } | |
| if !hookCommandExists(hooksFile.Hooks.BeforeSubmitPrompt, beforeSubmitPromptCmd) { | |
| hooksFile.Hooks.BeforeSubmitPrompt = append(hooksFile.Hooks.BeforeSubmitPrompt, CursorHookEntry{Command: beforeSubmitPromptCmd}) | |
| count++ | |
| } | |
| if !hookCommandExists(hooksFile.Hooks.Stop, stopCmd) { | |
| hooksFile.Hooks.Stop = append(hooksFile.Hooks.Stop, CursorHookEntry{Command: stopCmd}) | |
| count++ | |
| } | |
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PreToolUse, "Task", preTaskCmd) { | |
| hooksFile.Hooks.PreToolUse = append(hooksFile.Hooks.PreToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Task"}) | |
| count++ | |
| } | |
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PostToolUse, "Task", postTaskCmd) { | |
| hooksFile.Hooks.PostToolUse = append(hooksFile.Hooks.PostToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Task"}) | |
| count++ | |
| } | |
| if !hookCommandExistsWithMatcher(hooksFile.Hooks.PostToolUse, "TodoWrite", postTodoCmd) { | |
| hooksFile.Hooks.PostToolUse = append(hooksFile.Hooks.PostToolUse, CursorHookEntry{Command: postTodoCmd, Matcher: "TodoWrite"}) | |
| normalizeEntireHookCommand := func(cmd string) string { | |
| trimmed := strings.TrimSpace(cmd) | |
| if strings.HasPrefix(trimmed, "go run ") { | |
| return strings.TrimSpace(strings.TrimPrefix(trimmed, "go run ")) | |
| } | |
| if strings.HasPrefix(trimmed, "entire ") { | |
| return strings.TrimSpace(strings.TrimPrefix(trimmed, "entire ")) | |
| } | |
| return trimmed | |
| } | |
| upsertHook := func(entries []CursorHookEntry, newCmd string) ([]CursorHookEntry, bool) { | |
| normNew := normalizeEntireHookCommand(newCmd) | |
| for i, e := range entries { | |
| if normalizeEntireHookCommand(e.Command) == normNew { | |
| // Existing hook for this Entire command; update if prefix differs. | |
| if e.Command == newCmd { | |
| return entries, false | |
| } | |
| entries[i].Command = newCmd | |
| return entries, true | |
| } | |
| } | |
| entries = append(entries, CursorHookEntry{Command: newCmd}) | |
| return entries, true | |
| } | |
| upsertHookWithMatcher := func(entries []CursorHookEntry, matcher, newCmd string) ([]CursorHookEntry, bool) { | |
| normNew := normalizeEntireHookCommand(newCmd) | |
| for i, e := range entries { | |
| if e.Matcher == matcher && normalizeEntireHookCommand(e.Command) == normNew { | |
| // Existing hook for this Entire command/matcher; update if prefix differs. | |
| if e.Command == newCmd { | |
| return entries, false | |
| } | |
| entries[i].Command = newCmd | |
| return entries, true | |
| } | |
| } | |
| entries = append(entries, CursorHookEntry{Command: newCmd, Matcher: matcher}) | |
| return entries, true | |
| } | |
| var changed bool | |
| hooksFile.Hooks.SessionStart, changed = upsertHook(hooksFile.Hooks.SessionStart, sessionStartCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.SessionEnd, changed = upsertHook(hooksFile.Hooks.SessionEnd, sessionEndCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.BeforeSubmitPrompt, changed = upsertHook(hooksFile.Hooks.BeforeSubmitPrompt, beforeSubmitPromptCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.Stop, changed = upsertHook(hooksFile.Hooks.Stop, stopCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.PreToolUse, changed = upsertHookWithMatcher(hooksFile.Hooks.PreToolUse, "Task", preTaskCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.PostToolUse, changed = upsertHookWithMatcher(hooksFile.Hooks.PostToolUse, "Task", postTaskCmd) | |
| if changed { | |
| count++ | |
| } | |
| hooksFile.Hooks.PostToolUse, changed = upsertHookWithMatcher(hooksFile.Hooks.PostToolUse, "TodoWrite", postTodoCmd) | |
| if changed { |
| for _, tc := range testCases { | ||
| t.Run(tc.hookName, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ag := &CursorAgent{} | ||
| event, err := ag.ParseHookEvent(tc.hookName, strings.NewReader(tc.inputTemplate)) | ||
|
|
There was a problem hiding this comment.
In TestParseHookEvent_AllHookTypes, the range variable tc is captured by a parallel subtest (t.Parallel()), which can lead to flakes/wrong assertions. Capture the loop variable inside the loop (e.g., tc := tc) before calling t.Run.
| // ReadSession reads a session from Cursor's storage (JSONL transcript file). | ||
| func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { | ||
| if input.SessionRef == "" { | ||
| return nil, errors.New("session reference (transcript path) is required") | ||
| } | ||
|
|
||
| data, err := os.ReadFile(input.SessionRef) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read transcript: %w", err) | ||
| } | ||
|
|
||
| lines, err := transcript.ParseFromBytes(data) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to parse transcript: %w", err) | ||
| } | ||
|
|
||
| return &agent.AgentSession{ | ||
| SessionID: input.SessionID, | ||
| AgentName: c.Name(), | ||
| SessionRef: input.SessionRef, | ||
| StartTime: time.Now(), | ||
| NativeData: data, | ||
| ModifiedFiles: extractModifiedFiles(lines), | ||
| }, nil |
There was a problem hiding this comment.
ReadSession’s ModifiedFiles computation hinges on the Cursor JSONL schema (assistant detection via type vs role, tool_use parsing, file_path vs notebook_path). There aren’t tests covering this behavior for Cursor; adding a small unit test for ReadSession/extractModifiedFiles (similar to the Claude Code ExtractModifiedFiles tests) would help prevent regressions.
| if readErr == nil { | ||
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | ||
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | ||
| } | ||
| } else { | ||
| hooksFile.Version = 1 |
There was a problem hiding this comment.
InstallHooks treats any os.ReadFile error as "file doesn’t exist" and proceeds with an empty config. This means unexpected errors (e.g. permission errors, transient IO issues) aren’t surfaced as install failures. Consider returning an error when readErr != nil and !os.IsNotExist(readErr).
| if readErr == nil { | |
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | |
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | |
| } | |
| } else { | |
| hooksFile.Version = 1 | |
| switch { | |
| case readErr == nil: | |
| if err := json.Unmarshal(existingData, &hooksFile); err != nil { | |
| return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) | |
| } | |
| case os.IsNotExist(readErr): | |
| hooksFile.Version = 1 | |
| default: | |
| return 0, fmt.Errorf("failed to read %s: %w", hooksPath, readErr) |


Summary
cmd/entire/cli/agent/cursor/) following the dispatcher-driven model from the agent refactorParseHookEventmapping 7 Cursor hooks to normalized lifecycle events (SessionStart, TurnStart, TurnEnd, SessionEnd, SubagentStart, SubagentEnd).cursor/hooks.jsonwith matcher support for tool-specific hooksFiles created (6)
agent/cursor/types.go— Cursor-specific types withconversation_idfallbackagent/cursor/cursor.go— Main agent implementation (Agent + TranscriptAnalyzer interfaces)agent/cursor/lifecycle.go—ParseHookEventwith 7 hook mappingsagent/cursor/hooks.go— Hook installation for.cursor/hooks.jsonagent/cursor/hooks_test.go— 9 hook testsagent/cursor/lifecycle_test.go— 13 lifecycle testsFiles modified (6)
agent/registry.go— AddedAgentNameCursor/AgentTypeCursorconstantshooks_cmd.go— Blank import to triggerinit()registrationhook_registry.go— Cursor post-todo dispatch casehooks_cursor_posttodo.go— Post-todo handler (delegates to Claude's)setup.go— Preview label +removeAgentHooksfor Cursorsummarize/summarize.go— Exhaustive switch case forAgentTypeCursorTest plan
mise run test)mise run test:integration)mise run lint)go build ./cmd/entire/)entire enable --agent cursorcreates.cursor/hooks.jsonentire disableremoves hooks from.cursor/hooks.jsonentire hooks cursor --helpshows all hook verbs🤖 Generated with Claude Code