Skip to content

Add Cursor agent support on new agent interface#392

Draft
squishykid wants to merge 3 commits intomainfrom
robin/cursor-agent
Draft

Add Cursor agent support on new agent interface#392
squishykid wants to merge 3 commits intomainfrom
robin/cursor-agent

Conversation

@squishykid
Copy link
Member

Summary

  • Implements the full Cursor agent package (cmd/entire/cli/agent/cursor/) following the dispatcher-driven model from the agent refactor
  • Adds ParseHookEvent mapping 7 Cursor hooks to normalized lifecycle events (SessionStart, TurnStart, TurnEnd, SessionEnd, SubagentStart, SubagentEnd)
  • Implements hook installation/uninstallation for .cursor/hooks.json with matcher support for tool-specific hooks
  • Reuses JSONL transcript infrastructure (same format as Claude Code) and the existing post-todo incremental checkpoint logic

Files created (6)

  • agent/cursor/types.go — Cursor-specific types with conversation_id fallback
  • agent/cursor/cursor.go — Main agent implementation (Agent + TranscriptAnalyzer interfaces)
  • agent/cursor/lifecycle.goParseHookEvent with 7 hook mappings
  • agent/cursor/hooks.go — Hook installation for .cursor/hooks.json
  • agent/cursor/hooks_test.go — 9 hook tests
  • agent/cursor/lifecycle_test.go — 13 lifecycle tests

Files modified (6)

  • agent/registry.go — Added AgentNameCursor/AgentTypeCursor constants
  • hooks_cmd.go — Blank import to trigger init() registration
  • hook_registry.go — Cursor post-todo dispatch case
  • hooks_cursor_posttodo.go — Post-todo handler (delegates to Claude's)
  • setup.go — Preview label + removeAgentHooks for Cursor
  • summarize/summarize.go — Exhaustive switch case for AgentTypeCursor

Test plan

  • Unit tests pass (mise run test)
  • Integration tests pass (mise run test:integration)
  • Lint clean (mise run lint)
  • Builds successfully (go build ./cmd/entire/)
  • Manual: entire enable --agent cursor creates .cursor/hooks.json
  • Manual: entire disable removes hooks from .cursor/hooks.json
  • Manual: entire hooks cursor --help shows all hook verbs

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings February 17, 2026 17:05
@cursor
Copy link

cursor bot commented Feb 17, 2026

PR Summary

Medium Risk
Adds a new agent and hook wiring that touches CLI setup/uninstall and hook dispatch paths; mistakes could cause hooks not to fire or create incorrect checkpoints, but changes are mostly additive and well-tested.

Overview
Adds Cursor editor support as a first-class agent, including registration (AgentNameCursor/AgentTypeCursor) and a new agent/cursor package that can detect Cursor projects, resolve/read/write JSONL transcripts, and extract modified files/prompts/summaries for checkpoints.

Implements Cursor hook management for .cursor/hooks.json (install/uninstall/idempotent/force + tool matcher support) and maps Cursor hook invocations to normalized lifecycle events via ParseHookEvent, with post-todo reusing the existing incremental checkpoint handler; wiring updates include hook command registration, uninstall cleanup, and treating Cursor transcripts as JSONL in summarization. Extensive unit tests cover hook installation behavior and lifecycle parsing (including conversation_id fallback).

Written by Cursor Bugbot for commit ac26b1b. Configure here.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt)
hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt) ||
hasEntireHook(hooksFile.Hooks.SessionEnd) ||
hasEntireHook(hooksFile.Hooks.PreToolUse) ||
hasEntireHook(hooksFile.Hooks.PostToolUse)

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 76
// 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)
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@Soph Soph force-pushed the soph/agent-refactor branch from 905aeb4 to 2abcac1 Compare February 17, 2026 19:53
Base automatically changed from soph/agent-refactor to main February 18, 2026 02:16
@squishykid squishykid self-assigned this Feb 18, 2026
@squishykid squishykid added enhancement New feature or request agent-support adding support for additional AI agents labels Feb 18, 2026
Entire-Checkpoint: 04c6b0cd0999
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

Comment on lines +69 to +88
// 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)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +7

// handleCursorPostTodo handles the PostToolUse[TodoWrite] hook for Cursor.
// Reuses the same incremental checkpoint logic as Claude Code.
func handleCursorPostTodo() error {
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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))

Copilot uses AI. Check for mistakes.
Comment on lines 1258 to 1262
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +156
// 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
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)
}

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +77
// 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)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// Line represents a single line in a Claude Code or Cursor JSONL transcript.
type Line struct {
Type string `json:"type"`
Role string `json:"role"`
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Role string `json:"role"`
Role string `json:"role,omitempty"`

Copilot uses AI. Check for mistakes.
@squishykid squishykid marked this pull request as ready for review February 18, 2026 16:18
@squishykid squishykid requested a review from a team as a code owner February 18, 2026 16:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Comment on lines +69 to +76
// 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)
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +135
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"})
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 {

Copilot uses AI. Check for mistakes.
Comment on lines +363 to +369
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))

Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +189
// 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
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +78
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
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
@squishykid squishykid marked this pull request as draft February 19, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-support adding support for additional AI agents enhancement New feature or request

Development

Successfully merging this pull request may close these issues.

1 participant

Comments