From 4ae0a704f1063ed02c6e0d5f6a0872b2dc3a4365 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Thu, 19 Feb 2026 14:08:37 +0100 Subject: [PATCH 01/21] cursor: initial support --- cmd/entire/cli/agent/cursor/cursor.go | 291 ++++++++++++ cmd/entire/cli/agent/cursor/hooks.go | 379 +++++++++++++++ cmd/entire/cli/agent/cursor/hooks_test.go | 442 ++++++++++++++++++ cmd/entire/cli/agent/cursor/lifecycle.go | 138 ++++++ cmd/entire/cli/agent/cursor/lifecycle_test.go | 389 +++++++++++++++ cmd/entire/cli/agent/cursor/types.go | 116 +++++ cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/hooks_cmd.go | 1 + cmd/entire/cli/setup.go | 95 ++-- cmd/entire/cli/summarize/summarize.go | 4 +- cmd/entire/cli/transcript/types.go | 3 +- 11 files changed, 1797 insertions(+), 63 deletions(-) create mode 100644 cmd/entire/cli/agent/cursor/cursor.go create mode 100644 cmd/entire/cli/agent/cursor/hooks.go create mode 100644 cmd/entire/cli/agent/cursor/hooks_test.go create mode 100644 cmd/entire/cli/agent/cursor/lifecycle.go create mode 100644 cmd/entire/cli/agent/cursor/lifecycle_test.go create mode 100644 cmd/entire/cli/agent/cursor/types.go diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go new file mode 100644 index 0000000000..37c8d47310 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -0,0 +1,291 @@ +// Package cursor implements the Agent interface for Cursor. +package cursor + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/transcript" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameCursor, NewCursorAgent) +} + +// CursorAgent implements the Agent interface for Cursor. +// +//nolint:revive // CursorAgent is clearer than Agent in this context +type CursorAgent struct{} + +// NewCursorAgent creates a new Cursor agent instance. +func NewCursorAgent() agent.Agent { + return &CursorAgent{} +} + +// Name returns the agent registry key. +func (c *CursorAgent) Name() agent.AgentName { + return agent.AgentNameCursor +} + +// Type returns the agent type identifier. +func (c *CursorAgent) Type() agent.AgentType { + return agent.AgentTypeCursor +} + +// Description returns a human-readable description. +func (c *CursorAgent) Description() string { + return "Cursor - AI-powered code editor" +} + +func (c *CursorAgent) IsPreview() bool { return true } + +// DetectPresence checks if Cursor is configured in the repository. +func (c *CursorAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + cursorDir := filepath.Join(repoRoot, ".cursor") + if _, err := os.Stat(cursorDir); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to Cursor's hook config file. +func (c *CursorAgent) GetHookConfigPath() string { + return ".cursor/" + HooksFileName +} + +// SupportsHooks returns true as Cursor supports lifecycle hooks. +func (c *CursorAgent) SupportsHooks() bool { + return true +} + +// 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) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + switch hookType { + case agent.HookUserPromptSubmit: + var raw userPromptSubmitRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse user prompt submit: %w", err) + } + input.SessionID = raw.getSessionID() + input.SessionRef = raw.TranscriptPath + input.UserPrompt = raw.Prompt + + case agent.HookSessionStart, agent.HookSessionEnd, agent.HookStop: + var raw sessionInfoRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse session info: %w", err) + } + input.SessionID = raw.getSessionID() + input.SessionRef = raw.TranscriptPath + + case agent.HookPreToolUse: + var raw taskHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse pre-tool input: %w", err) + } + input.SessionID = raw.getSessionID() + input.SessionRef = raw.TranscriptPath + input.ToolUseID = raw.ToolUseID + input.ToolInput = raw.ToolInput + + case agent.HookPostToolUse: + var raw postToolHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse post-tool input: %w", err) + } + input.SessionID = raw.getSessionID() + input.SessionRef = raw.TranscriptPath + input.ToolUseID = raw.ToolUseID + input.ToolInput = raw.ToolInput + if raw.ToolResponse.AgentID != "" { + input.RawData["agent_id"] = raw.ToolResponse.AgentID + } + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (c *CursorAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ResolveSessionFile returns the path to a Cursor session file. +// Cursor uses JSONL format like Claude Code. +func (c *CursorAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ProtectedDirs returns directories that Cursor uses for config/state. +func (c *CursorAgent) ProtectedDirs() []string { return []string{".cursor"} } + +// GetSessionDir returns the directory where Cursor stores session transcripts. +func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_CURSOR_PROJECT_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + projectDir := sanitizePathForCursor(repoPath) + return filepath.Join(homeDir, ".cursor", "projects", projectDir), nil +} + +// 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 +} + +// WriteSession writes a session to Cursor's storage (JSONL transcript file). +func (c *CursorAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != c.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, c.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Cursor session. +func (c *CursorAgent) FormatResumeCommand(sessionID string) string { + return "cursor --resume " + sessionID +} + +// sanitizePathForCursor converts a path to Cursor's project directory format. +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +func sanitizePathForCursor(path string) string { + return nonAlphanumericRegex.ReplaceAllString(path, "-") +} + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (c *CursorAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +func (c *CursorAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// extractModifiedFiles extracts file paths from transcript lines that contain file-modifying tools. +func extractModifiedFiles(lines []transcript.Line) []string { + seen := make(map[string]bool) + var files []string + + for i := range lines { + if lines[i].Role != transcript.TypeAssistant && lines[i].Type != transcript.TypeAssistant { + continue + } + + var msg transcript.AssistantMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + + for _, block := range msg.Content { + if block.Type != transcript.ContentTypeToolUse { + continue + } + + isModifyTool := false + for _, name := range FileModificationTools { + if block.Name == name { + isModifyTool = true + break + } + } + if !isModifyTool { + continue + } + + var toolInput transcript.ToolInput + if err := json.Unmarshal(block.Input, &toolInput); err != nil { + continue + } + + file := toolInput.FilePath + if file == "" { + file = toolInput.NotebookPath + } + if file != "" && !seen[file] { + seen[file] = true + files = append(files, file) + } + } + } + + return files +} diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go new file mode 100644 index 0000000000..fab242b3be --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -0,0 +1,379 @@ +package cursor + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure CursorAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*CursorAgent)(nil) + _ agent.HookHandler = (*CursorAgent)(nil) +) + +// Cursor hook names - these become subcommands under `entire hooks cursor` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameBeforeSubmitPrompt = "before-submit-prompt" + HookNameStop = "stop" + HookNamePreTask = "pre-task" + HookNamePostTask = "post-task" + HookNamePostTodo = "post-todo" +) + +// HooksFileName is the hooks file used by Cursor. +const HooksFileName = "hooks.json" + +// entireHookPrefixes are command prefixes that identify Entire hooks +var entireHookPrefixes = []string{ + "entire ", + "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go ", +} + +// GetHookNames returns the hook verbs Cursor supports. +// These become subcommands: entire hooks cursor +func (c *CursorAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeSubmitPrompt, + HookNameStop, + HookNamePreTask, + HookNamePostTask, + HookNamePostTodo, + } +} + +// InstallHooks installs Cursor hooks in .cursor/hooks.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +// Unknown top-level fields and hook types are preserved on round-trip. +func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + + // Use raw maps to preserve unknown fields on round-trip + var rawFile map[string]json.RawMessage + var rawHooks map[string]json.RawMessage + + existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawFile); err != nil { + return 0, fmt.Errorf("failed to parse existing "+HooksFileName+": %w", err) + } + if hooksRaw, ok := rawFile["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return 0, fmt.Errorf("failed to parse hooks in "+HooksFileName+": %w", err) + } + } + } else { + rawFile = map[string]json.RawMessage{ + "version": json.RawMessage(`1`), + } + } + + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse only the hook types we manage + var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preToolUse, postToolUse []CursorHookEntry + parseCursorHookType(rawHooks, "sessionStart", &sessionStart) + parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) + parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) + parseCursorHookType(rawHooks, "stop", &stop) + parseCursorHookType(rawHooks, "preToolUse", &preToolUse) + parseCursorHookType(rawHooks, "postToolUse", &postToolUse) + + // If force is true, remove all existing Entire hooks first + if force { + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) + stop = removeEntireHooks(stop) + preToolUse = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(postToolUse) + } + + // Define hook commands + var cmdPrefix string + if localDev { + cmdPrefix = "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go hooks cursor " + } else { + cmdPrefix = "entire hooks cursor " + } + + sessionStartCmd := cmdPrefix + "session-start" + sessionEndCmd := cmdPrefix + "session-end" + beforeSubmitPromptCmd := cmdPrefix + "before-submit-prompt" + stopCmd := cmdPrefix + "stop" + preTaskCmd := cmdPrefix + "pre-task" + postTaskCmd := cmdPrefix + "post-task" + postTodoCmd := cmdPrefix + "post-todo" + + count := 0 + + // Add hooks if they don't exist + if !hookCommandExists(sessionStart, sessionStartCmd) { + sessionStart = append(sessionStart, CursorHookEntry{Command: sessionStartCmd}) + count++ + } + if !hookCommandExists(sessionEnd, sessionEndCmd) { + sessionEnd = append(sessionEnd, CursorHookEntry{Command: sessionEndCmd}) + count++ + } + if !hookCommandExists(beforeSubmitPrompt, beforeSubmitPromptCmd) { + beforeSubmitPrompt = append(beforeSubmitPrompt, CursorHookEntry{Command: beforeSubmitPromptCmd}) + count++ + } + if !hookCommandExists(stop, stopCmd) { + stop = append(stop, CursorHookEntry{Command: stopCmd}) + count++ + } + if !hookCommandExistsWithMatcher(preToolUse, "Task", preTaskCmd) { + preToolUse = append(preToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Task"}) + count++ + } + if !hookCommandExistsWithMatcher(postToolUse, "Task", postTaskCmd) { + postToolUse = append(postToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Task"}) + count++ + } + if !hookCommandExistsWithMatcher(postToolUse, "TodoWrite", postTodoCmd) { + postToolUse = append(postToolUse, CursorHookEntry{Command: postTodoCmd, Matcher: "TodoWrite"}) + count++ + } + + if count == 0 { + return 0, nil + } + + // Marshal modified hook types back into rawHooks + marshalCursorHookType(rawHooks, "sessionStart", sessionStart) + marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd) + marshalCursorHookType(rawHooks, "beforeSubmitPrompt", beforeSubmitPrompt) + marshalCursorHookType(rawHooks, "stop", stop) + marshalCursorHookType(rawHooks, "preToolUse", preToolUse) + marshalCursorHookType(rawHooks, "postToolUse", postToolUse) + + // Marshal hooks and update raw file + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawFile["hooks"] = hooksJSON + + // Write to file + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .cursor directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal "+HooksFileName+": %w", err) + } + + if err := os.WriteFile(hooksPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write "+HooksFileName+": %w", err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from Cursor HooksFileName. +// Unknown top-level fields and hook types are preserved on round-trip. +func (c *CursorAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No hooks file means nothing to uninstall + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return fmt.Errorf("failed to parse "+HooksFileName+": %w", err) + } + + var rawHooks map[string]json.RawMessage + if hooksRaw, ok := rawFile["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return fmt.Errorf("failed to parse hooks in "+HooksFileName+": %w", err) + } + } + if rawHooks == nil { + rawHooks = make(map[string]json.RawMessage) + } + + // Parse only the hook types we manage + var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preToolUse, postToolUse []CursorHookEntry + parseCursorHookType(rawHooks, "sessionStart", &sessionStart) + parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) + parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) + parseCursorHookType(rawHooks, "stop", &stop) + parseCursorHookType(rawHooks, "preToolUse", &preToolUse) + parseCursorHookType(rawHooks, "postToolUse", &postToolUse) + + // Remove Entire hooks from all hook types + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) + stop = removeEntireHooks(stop) + preToolUse = removeEntireHooks(preToolUse) + postToolUse = removeEntireHooks(postToolUse) + + // Marshal modified hook types back into rawHooks + marshalCursorHookType(rawHooks, "sessionStart", sessionStart) + marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd) + marshalCursorHookType(rawHooks, "beforeSubmitPrompt", beforeSubmitPrompt) + marshalCursorHookType(rawHooks, "stop", stop) + marshalCursorHookType(rawHooks, "preToolUse", preToolUse) + marshalCursorHookType(rawHooks, "postToolUse", postToolUse) + + // Marshal hooks back (preserving unknown hook types) + if len(rawHooks) > 0 { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawFile["hooks"] = hooksJSON + } else { + delete(rawFile, "hooks") + } + + // Write back + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal "+HooksFileName+": %w", err) + } + + if err := os.WriteFile(hooksPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write "+HooksFileName+": %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (c *CursorAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var hooksFile CursorHooksFile + if err := json.Unmarshal(data, &hooksFile); err != nil { + return false + } + + return hasEntireHook(hooksFile.Hooks.SessionStart) || + hasEntireHook(hooksFile.Hooks.SessionEnd) || + hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt) || + hasEntireHook(hooksFile.Hooks.Stop) || + hasEntireHook(hooksFile.Hooks.PreToolUse) || + hasEntireHook(hooksFile.Hooks.PostToolUse) +} + +// GetSupportedHooks returns the hook types Cursor supports. +func (c *CursorAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookUserPromptSubmit, + agent.HookStop, + agent.HookPreToolUse, + agent.HookPostToolUse, + } +} + +// parseCursorHookType parses a specific hook type from rawHooks into the target slice. +// Silently ignores parse errors (leaves target unchanged). +func parseCursorHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]CursorHookEntry) { + if data, ok := rawHooks[hookType]; ok { + //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty + json.Unmarshal(data, target) + } +} + +// marshalCursorHookType marshals a hook type back into rawHooks. +// If the slice is empty, removes the key from rawHooks. +func marshalCursorHookType(rawHooks map[string]json.RawMessage, hookType string, entries []CursorHookEntry) { + if len(entries) == 0 { + delete(rawHooks, hookType) + return + } + data, err := json.Marshal(entries) + if err != nil { + return // Silently ignore marshal errors (shouldn't happen) + } + rawHooks[hookType] = data +} + +// Helper functions for hook management + +func hookCommandExists(entries []CursorHookEntry, command string) bool { + for _, entry := range entries { + if entry.Command == command { + return true + } + } + return false +} + +func hookCommandExistsWithMatcher(entries []CursorHookEntry, matcher, command string) bool { + for _, entry := range entries { + if entry.Matcher == matcher && entry.Command == command { + return true + } + } + return false +} + +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +func hasEntireHook(entries []CursorHookEntry) bool { + for _, entry := range entries { + if isEntireHook(entry.Command) { + return true + } + } + return false +} + +func removeEntireHooks(entries []CursorHookEntry) []CursorHookEntry { + result := make([]CursorHookEntry, 0, len(entries)) + for _, entry := range entries { + if !isEntireHook(entry.Command) { + result = append(result, entry) + } + } + return result +} diff --git a/cmd/entire/cli/agent/cursor/hooks_test.go b/cmd/entire/cli/agent/cursor/hooks_test.go new file mode 100644 index 0000000000..ed05a7041e --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -0,0 +1,442 @@ +package cursor + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) + } + + hooksFile := readHooksFile(t, tempDir) + + // Verify all hooks are present + if len(hooksFile.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d, want 1", len(hooksFile.Hooks.SessionStart)) + } + if len(hooksFile.Hooks.SessionEnd) != 1 { + t.Errorf("SessionEnd hooks = %d, want 1", len(hooksFile.Hooks.SessionEnd)) + } + if len(hooksFile.Hooks.BeforeSubmitPrompt) != 1 { + t.Errorf("BeforeSubmitPrompt hooks = %d, want 1", len(hooksFile.Hooks.BeforeSubmitPrompt)) + } + if len(hooksFile.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d, want 1", len(hooksFile.Hooks.Stop)) + } + // PreToolUse has 1 (Task) + if len(hooksFile.Hooks.PreToolUse) != 1 { + t.Errorf("PreToolUse hooks = %d, want 1", len(hooksFile.Hooks.PreToolUse)) + } + // PostToolUse has 2 (Task + TodoWrite) + if len(hooksFile.Hooks.PostToolUse) != 2 { + t.Errorf("PostToolUse hooks = %d, want 2", len(hooksFile.Hooks.PostToolUse)) + } + + // Verify version + if hooksFile.Version != 1 { + t.Errorf("Version = %d, want 1", hooksFile.Version) + } + + // Verify commands + assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") + assertEntryCommand(t, hooksFile.Hooks.SessionStart, "entire hooks cursor session-start") + assertEntryCommand(t, hooksFile.Hooks.BeforeSubmitPrompt, "entire hooks cursor before-submit-prompt") + + // Verify matchers on tool hooks + assertEntryWithMatcher(t, hooksFile.Hooks.PreToolUse, "Task", "entire hooks cursor pre-task") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Task", "entire hooks cursor post-task") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "TodoWrite", "entire hooks cursor post-todo") +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + + // First install + count1, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 7 { + t.Errorf("first InstallHooks() count = %d, want 7", count1) + } + + // Second install + count2, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (already installed)", count2) + } + + // Verify no duplicates + hooksFile := readHooksFile(t, tempDir) + if len(hooksFile.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d after double install, want 1", len(hooksFile.Hooks.Stop)) + } +} + +func TestAreHooksInstalled_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true, want false (no hooks.json)") + } +} + +func TestAreHooksInstalled_AfterInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if !ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false, want true") + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + + // Install + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if !ag.AreHooksInstalled() { + t.Fatal("hooks should be installed before uninstall") + } + + // Uninstall + err = ag.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true after uninstall, want false") + } +} + +func TestUninstallHooks_NoHooksFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + + // Should not error when no hooks file exists + err := ag.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() should not error when no hooks file: %v", err) + } +} + +func TestInstallHooks_ForceReinstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + + // Install normally + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall + count, err := ag.InstallHooks(false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 7 { + t.Errorf("force InstallHooks() count = %d, want 7", count) + } + + // Verify no duplicates + hooksFile := readHooksFile(t, tempDir) + if len(hooksFile.Hooks.Stop) != 1 { + t.Errorf("Stop hooks = %d after force reinstall, want 1", len(hooksFile.Hooks.Stop)) + } +} + +func TestInstallHooks_PreservesExistingHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create hooks file with existing user hooks + writeHooksFile(t, tempDir, CursorHooksFile{ + Version: 1, + Hooks: CursorHooks{ + Stop: []CursorHookEntry{ + {Command: "echo user hook"}, + }, + PostToolUse: []CursorHookEntry{ + {Command: "echo file written", Matcher: "Write"}, + }, + }, + }) + + ag := &CursorAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + hooksFile := readHooksFile(t, tempDir) + + // Stop should have user hook + entire hook + if len(hooksFile.Hooks.Stop) != 2 { + t.Errorf("Stop hooks = %d, want 2 (user + entire)", len(hooksFile.Hooks.Stop)) + } + assertEntryCommand(t, hooksFile.Hooks.Stop, "echo user hook") + assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") + + // PostToolUse should have user Write hook + Task hook + TodoWrite hook + if len(hooksFile.Hooks.PostToolUse) != 3 { + t.Errorf("PostToolUse hooks = %d, want 3 (user Write + Task + TodoWrite)", len(hooksFile.Hooks.PostToolUse)) + } + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Write", "echo file written") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Task", "entire hooks cursor post-task") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "TodoWrite", "entire hooks cursor post-todo") +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CursorAgent{} + _, err := ag.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks(localDev=true) error = %v", err) + } + + hooksFile := readHooksFile(t, tempDir) + assertEntryCommand(t, hooksFile.Hooks.Stop, "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go hooks cursor stop") +} + +func TestInstallHooks_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create a hooks file with unknown top-level fields and unknown hook types + existingJSON := `{ + "version": 1, + "cursorSettings": {"theme": "dark"}, + "hooks": { + "stop": [{"command": "echo user stop"}], + "onNotification": [{"command": "echo notify", "filter": "error"}], + "customHook": [{"command": "echo custom"}] + } +}` + cursorDir := filepath.Join(tempDir, ".cursor") + if err := os.MkdirAll(cursorDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cursorDir, HooksFileName), []byte(existingJSON), 0o644); err != nil { + t.Fatal(err) + } + + ag := &CursorAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) + } + + // Read the raw JSON to verify unknown fields are preserved + data, err := os.ReadFile(filepath.Join(cursorDir, HooksFileName)) + if err != nil { + t.Fatal(err) + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + t.Fatal(err) + } + + // Verify unknown top-level field "cursorSettings" is preserved + if _, ok := rawFile["cursorSettings"]; !ok { + t.Error("unknown top-level field 'cursorSettings' was dropped") + } + + // Verify hooks object contains unknown hook types + var rawHooks map[string]json.RawMessage + if err := json.Unmarshal(rawFile["hooks"], &rawHooks); err != nil { + t.Fatal(err) + } + + if _, ok := rawHooks["onNotification"]; !ok { + t.Error("unknown hook type 'onNotification' was dropped") + } + if _, ok := rawHooks["customHook"]; !ok { + t.Error("unknown hook type 'customHook' was dropped") + } + + // Verify user's existing stop hook is preserved alongside ours + var stopHooks []CursorHookEntry + if err := json.Unmarshal(rawHooks["stop"], &stopHooks); err != nil { + t.Fatal(err) + } + if len(stopHooks) != 2 { + t.Errorf("stop hooks = %d, want 2 (user + entire)", len(stopHooks)) + } + assertEntryCommand(t, stopHooks, "echo user stop") + assertEntryCommand(t, stopHooks, "entire hooks cursor stop") +} + +func TestUninstallHooks_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Install hooks first + ag := &CursorAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatal(err) + } + + // Add unknown fields to the file + hooksPath := filepath.Join(tempDir, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + t.Fatal(err) + } + rawFile["cursorSettings"] = json.RawMessage(`{"theme":"dark"}`) + + var rawHooks map[string]json.RawMessage + if err := json.Unmarshal(rawFile["hooks"], &rawHooks); err != nil { + t.Fatal(err) + } + rawHooks["onNotification"] = json.RawMessage(`[{"command":"echo notify"}]`) + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + t.Fatal(err) + } + rawFile["hooks"] = hooksJSON + + updatedData, err := json.MarshalIndent(rawFile, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, updatedData, 0o644); err != nil { + t.Fatal(err) + } + + // Uninstall hooks + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Read and verify unknown fields are preserved + data, err = os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + + if err := json.Unmarshal(data, &rawFile); err != nil { + t.Fatal(err) + } + + if _, ok := rawFile["cursorSettings"]; !ok { + t.Error("unknown top-level field 'cursorSettings' was dropped after uninstall") + } + + if err := json.Unmarshal(rawFile["hooks"], &rawHooks); err != nil { + t.Fatal(err) + } + + if _, ok := rawHooks["onNotification"]; !ok { + t.Error("unknown hook type 'onNotification' was dropped after uninstall") + } + + // Verify Entire hooks were actually removed + if ag.AreHooksInstalled() { + t.Error("Entire hooks should be removed after uninstall") + } +} + +// --- Test helpers --- + +func readHooksFile(t *testing.T, tempDir string) CursorHooksFile { + t.Helper() + hooksPath := filepath.Join(tempDir, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatalf("failed to read "+HooksFileName+": %v", err) + } + + var hooksFile CursorHooksFile + if err := json.Unmarshal(data, &hooksFile); err != nil { + t.Fatalf("failed to parse "+HooksFileName+": %v", err) + } + return hooksFile +} + +func writeHooksFile(t *testing.T, tempDir string, hooksFile CursorHooksFile) { + t.Helper() + cursorDir := filepath.Join(tempDir, ".cursor") + if err := os.MkdirAll(cursorDir, 0o755); err != nil { + t.Fatalf("failed to create .cursor dir: %v", err) + } + data, err := json.MarshalIndent(hooksFile, "", " ") + if err != nil { + t.Fatalf("failed to marshal "+HooksFileName+": %v", err) + } + hooksPath := filepath.Join(cursorDir, HooksFileName) + if err := os.WriteFile(hooksPath, data, 0o644); err != nil { + t.Fatalf("failed to write "+HooksFileName+": %v", err) + } +} + +func assertEntryCommand(t *testing.T, entries []CursorHookEntry, command string) { + t.Helper() + for _, entry := range entries { + if entry.Command == command { + return + } + } + t.Errorf("hook with command %q not found", command) +} + +func assertEntryWithMatcher(t *testing.T, entries []CursorHookEntry, matcher, command string) { + t.Helper() + for _, entry := range entries { + if entry.Matcher == matcher && entry.Command == command { + return + } + } + t.Errorf("hook with matcher=%q command=%q not found", matcher, command) +} diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go new file mode 100644 index 0000000000..f72e4f78b6 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -0,0 +1,138 @@ +package cursor + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// HookNames returns the hook verbs Cursor supports. +// Delegates to GetHookNames for backward compatibility. +func (c *CursorAgent) HookNames() []string { + return c.GetHookNames() +} + +// ParseHookEvent translates a Cursor hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (c *CursorAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return c.parseSessionStart(stdin) + case HookNameBeforeSubmitPrompt: + return c.parseTurnStart(stdin) + case HookNameStop: + return c.parseTurnEnd(stdin) + case HookNameSessionEnd: + return c.parseSessionEnd(stdin) + case HookNamePreTask: + return c.parseSubagentStart(stdin) + case HookNamePostTask: + return c.parseSubagentEnd(stdin) + case HookNamePostTodo: + // PostTodo is handled outside the generic dispatcher (incremental checkpoints). + return nil, nil //nolint:nilnil // nil event = no lifecycle action + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + return data, nil +} + +// --- Internal hook parsing functions --- + +func (c *CursorAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[userPromptSubmitRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + Prompt: raw.Prompt, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[taskHookInputRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SubagentStart, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[postToolHookInputRaw](stdin) + if err != nil { + return nil, err + } + event := &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.getSessionID(), + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + } + if raw.ToolResponse.AgentID != "" { + event.SubagentID = raw.ToolResponse.AgentID + } + return event, nil +} diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go new file mode 100644 index 0000000000..1e76259f4f --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -0,0 +1,389 @@ +package cursor + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "test-session-123", "transcript_path": "/tmp/transcript.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionStart { + t.Errorf("expected event type %v, got %v", agent.SessionStart, event.Type) + } + if event.SessionID != "test-session-123" { + t.Errorf("expected session_id 'test-session-123', got %q", event.SessionID) + } + if event.SessionRef != "/tmp/transcript.jsonl" { + t.Errorf("expected session_ref '/tmp/transcript.jsonl', got %q", event.SessionRef) + } + if event.Timestamp.IsZero() { + t.Error("expected non-zero timestamp") + } +} + +func TestParseHookEvent_TurnStart(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "sess-456", "transcript_path": "/tmp/t.jsonl", "prompt": "Hello world"}` + + event, err := ag.ParseHookEvent(HookNameBeforeSubmitPrompt, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnStart { + t.Errorf("expected event type %v, got %v", agent.TurnStart, event.Type) + } + if event.SessionID != "sess-456" { + t.Errorf("expected session_id 'sess-456', got %q", event.SessionID) + } + if event.Prompt != "Hello world" { + t.Errorf("expected prompt 'Hello world', got %q", event.Prompt) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "sess-789", "transcript_path": "/tmp/stop.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameStop, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnEnd { + t.Errorf("expected event type %v, got %v", agent.TurnEnd, event.Type) + } + if event.SessionID != "sess-789" { + t.Errorf("expected session_id 'sess-789', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "ending-session", "transcript_path": "/tmp/end.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionEnd { + t.Errorf("expected event type %v, got %v", agent.SessionEnd, event.Type) + } + if event.SessionID != "ending-session" { + t.Errorf("expected session_id 'ending-session', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SubagentStart(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + toolInput := json.RawMessage(`{"description": "test task", "prompt": "do something"}`) + inputData := map[string]any{ + "session_id": "main-session", + "transcript_path": "/tmp/main.jsonl", + "tool_use_id": "toolu_abc123", + "tool_input": toolInput, + } + inputBytes, marshalErr := json.Marshal(inputData) + if marshalErr != nil { + t.Fatalf("failed to marshal test input: %v", marshalErr) + } + + event, err := ag.ParseHookEvent(HookNamePreTask, strings.NewReader(string(inputBytes))) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SubagentStart { + t.Errorf("expected event type %v, got %v", agent.SubagentStart, event.Type) + } + if event.SessionID != "main-session" { + t.Errorf("expected session_id 'main-session', got %q", event.SessionID) + } + if event.ToolUseID != "toolu_abc123" { + t.Errorf("expected tool_use_id 'toolu_abc123', got %q", event.ToolUseID) + } + if event.ToolInput == nil { + t.Error("expected tool_input to be set") + } +} + +func TestParseHookEvent_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + inputData := map[string]any{ + "session_id": "main-session", + "transcript_path": "/tmp/main.jsonl", + "tool_use_id": "toolu_xyz789", + "tool_input": json.RawMessage(`{"prompt": "task done"}`), + "tool_response": map[string]string{ + "agentId": "agent-subagent-001", + }, + } + inputBytes, marshalErr := json.Marshal(inputData) + if marshalErr != nil { + t.Fatalf("failed to marshal test input: %v", marshalErr) + } + + event, err := ag.ParseHookEvent(HookNamePostTask, strings.NewReader(string(inputBytes))) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SubagentEnd { + t.Errorf("expected event type %v, got %v", agent.SubagentEnd, event.Type) + } + if event.ToolUseID != "toolu_xyz789" { + t.Errorf("expected tool_use_id 'toolu_xyz789', got %q", event.ToolUseID) + } + if event.SubagentID != "agent-subagent-001" { + t.Errorf("expected subagent_id 'agent-subagent-001', got %q", event.SubagentID) + } +} + +func TestParseHookEvent_PostTodo_ReturnsNil(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "todo-session", "transcript_path": "/tmp/todo.jsonl"}` + + event, err := ag.ParseHookEvent(HookNamePostTodo, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for post-todo, got %+v", event) + } +} + +func TestParseHookEvent_UnknownHook_ReturnsNil(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "unknown", "transcript_path": "/tmp/unknown.jsonl"}` + + event, err := ag.ParseHookEvent("unknown-hook-name", strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for unknown hook, got %+v", event) + } +} + +func TestParseHookEvent_EmptyInput_ReturnsError(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("")) + + if err == nil { + t.Fatal("expected error for empty input, got nil") + } + if !strings.Contains(err.Error(), "empty hook input") { + t.Errorf("expected 'empty hook input' error, got: %v", err) + } +} + +func TestParseHookEvent_ConversationIDFallback(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + + t.Run("uses session_id when present", func(t *testing.T) { + t.Parallel() + input := `{"session_id": "preferred-id", "conversation_id": "fallback-id", "transcript_path": "/tmp/t.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.SessionID != "preferred-id" { + t.Errorf("expected session_id 'preferred-id', got %q", event.SessionID) + } + }) + + t.Run("falls back to conversation_id", func(t *testing.T) { + t.Parallel() + input := `{"conversation_id": "fallback-id", "transcript_path": "/tmp/t.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.SessionID != "fallback-id" { + t.Errorf("expected session_id 'fallback-id' (from conversation_id), got %q", event.SessionID) + } + }) + + t.Run("conversation_id fallback for turn start", func(t *testing.T) { + t.Parallel() + input := `{"conversation_id": "conv-123", "transcript_path": "/tmp/t.jsonl", "prompt": "hi"}` + + event, err := ag.ParseHookEvent(HookNameBeforeSubmitPrompt, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.SessionID != "conv-123" { + t.Errorf("expected session_id 'conv-123', got %q", event.SessionID) + } + }) + + t.Run("conversation_id fallback for subagent start", func(t *testing.T) { + t.Parallel() + input := `{"conversation_id": "conv-sub", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t1", "tool_input": {}}` + + event, err := ag.ParseHookEvent(HookNamePreTask, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.SessionID != "conv-sub" { + t.Errorf("expected session_id 'conv-sub', got %q", event.SessionID) + } + }) + + t.Run("conversation_id fallback for subagent end", func(t *testing.T) { + t.Parallel() + input := `{"conversation_id": "conv-end", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}` + + event, err := ag.ParseHookEvent(HookNamePostTask, strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.SessionID != "conv-end" { + t.Errorf("expected session_id 'conv-end', got %q", event.SessionID) + } + }) +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"session_id": "test", "transcript_path": INVALID}` + + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if !strings.Contains(err.Error(), "failed to parse hook input") { + t.Errorf("expected 'failed to parse hook input' error, got: %v", err) + } +} + +func TestParseHookEvent_AllHookTypes(t *testing.T) { + t.Parallel() + + testCases := []struct { + hookName string + expectedType agent.EventType + expectNil bool + inputTemplate string + }{ + { + hookName: HookNameSessionStart, + expectedType: agent.SessionStart, + inputTemplate: `{"session_id": "s1", "transcript_path": "/t"}`, + }, + { + hookName: HookNameBeforeSubmitPrompt, + expectedType: agent.TurnStart, + inputTemplate: `{"session_id": "s2", "transcript_path": "/t", "prompt": "hi"}`, + }, + { + hookName: HookNameStop, + expectedType: agent.TurnEnd, + inputTemplate: `{"session_id": "s3", "transcript_path": "/t"}`, + }, + { + hookName: HookNameSessionEnd, + expectedType: agent.SessionEnd, + inputTemplate: `{"session_id": "s4", "transcript_path": "/t"}`, + }, + { + hookName: HookNamePreTask, + expectedType: agent.SubagentStart, + inputTemplate: `{"session_id": "s5", "transcript_path": "/t", "tool_use_id": "t1", "tool_input": {}}`, + }, + { + hookName: HookNamePostTask, + expectedType: agent.SubagentEnd, + inputTemplate: `{"session_id": "s6", "transcript_path": "/t", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}`, + }, + { + hookName: HookNamePostTodo, + expectNil: true, + inputTemplate: `{"session_id": "s7", "transcript_path": "/t"}`, + }, + } + + 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)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectNil { + if event != nil { + t.Errorf("expected nil event, got %+v", event) + } + return + } + + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != tc.expectedType { + t.Errorf("expected event type %v, got %v", tc.expectedType, event.Type) + } + }) + } +} diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go new file mode 100644 index 0000000000..a7ad3c02de --- /dev/null +++ b/cmd/entire/cli/agent/cursor/types.go @@ -0,0 +1,116 @@ +package cursor + +import "encoding/json" + +// CursorHooksFile represents the .cursor/HooksFileName structure. +// Cursor uses a flat JSON file with version and hooks sections. +// +//nolint:revive // CursorHooksFile is clearer than HooksFile when used outside this package +type CursorHooksFile struct { + Version int `json:"version"` + Hooks CursorHooks `json:"hooks"` +} + +// CursorHooks contains all hook configurations using camelCase keys. +// +//nolint:revive // CursorHooks is clearer than Hooks when used outside this package +type CursorHooks struct { + SessionStart []CursorHookEntry `json:"sessionStart,omitempty"` + SessionEnd []CursorHookEntry `json:"sessionEnd,omitempty"` + BeforeSubmitPrompt []CursorHookEntry `json:"beforeSubmitPrompt,omitempty"` + Stop []CursorHookEntry `json:"stop,omitempty"` + PreToolUse []CursorHookEntry `json:"preToolUse,omitempty"` + PostToolUse []CursorHookEntry `json:"postToolUse,omitempty"` +} + +// CursorHookEntry represents a single hook command. +// Cursor hooks have a command string and an optional matcher field for filtering by tool name. +// +//nolint:revive // CursorHookEntry is clearer than HookEntry when used outside this package +type CursorHookEntry struct { + Command string `json:"command"` + Matcher string `json:"matcher,omitempty"` +} + +// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop hooks. +// Cursor may provide session_id or conversation_id (fallback). +type sessionInfoRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` +} + +// getSessionID returns session_id if present, falling back to conversation_id. +func (s *sessionInfoRaw) getSessionID() string { + if s.SessionID != "" { + return s.SessionID + } + return s.ConversationID +} + +// userPromptSubmitRaw is the JSON structure from BeforeSubmitPrompt hooks. +type userPromptSubmitRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt"` +} + +// getSessionID returns session_id if present, falling back to conversation_id. +func (u *userPromptSubmitRaw) getSessionID() string { + if u.SessionID != "" { + return u.SessionID + } + return u.ConversationID +} + +// taskHookInputRaw is the JSON structure from PreToolUse[Task] hook. +type taskHookInputRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` +} + +// getSessionID returns session_id if present, falling back to conversation_id. +func (t *taskHookInputRaw) getSessionID() string { + if t.SessionID != "" { + return t.SessionID + } + return t.ConversationID +} + +// postToolHookInputRaw is the JSON structure from PostToolUse hooks. +type postToolHookInputRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse struct { + AgentID string `json:"agentId"` + } `json:"tool_response"` +} + +// getSessionID returns session_id if present, falling back to conversation_id. +func (p *postToolHookInputRaw) getSessionID() string { + if p.SessionID != "" { + return p.SessionID + } + return p.ConversationID +} + +// Tool names used in Cursor transcripts (same as Claude Code) +const ( + ToolWrite = "Write" + ToolEdit = "Edit" + ToolNotebookEdit = "NotebookEdit" +) + +// FileModificationTools lists tools that create or modify files +var FileModificationTools = []string{ + ToolWrite, + ToolEdit, + ToolNotebookEdit, +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 0be89d8b6d..116f458e40 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -92,12 +92,14 @@ type AgentType string // Agent name constants (registry keys) const ( AgentNameClaudeCode AgentName = "claude-code" + AgentNameCursor AgentName = "cursor" AgentNameGemini AgentName = "gemini" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeCursor AgentType = "Cursor" AgentTypeGemini AgentType = "Gemini CLI" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d129225235..ad7f0e271c 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,6 +4,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 80fb2932dd..6313918deb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -1068,13 +1068,12 @@ func runUninstall(w, errW io.Writer, force bool) error { sessionStateCount := countSessionStates() shadowBranchCount := countShadowBranches() gitHooksInstalled := strategy.IsGitHookInstalled() - claudeHooksInstalled := checkClaudeCodeHooksInstalled() - geminiHooksInstalled := checkGeminiCLIHooksInstalled() + agentsWithInstalledHooks := installedAgentHooks() entireDirExists := checkEntireDirExists() // Check if there's anything to uninstall if !entireDirExists && !gitHooksInstalled && sessionStateCount == 0 && - shadowBranchCount == 0 && !claudeHooksInstalled && !geminiHooksInstalled { + shadowBranchCount == 0 && len(agentsWithInstalledHooks) == 0 { fmt.Fprintln(w, "Entire is not installed in this repository.") return nil } @@ -1094,13 +1093,15 @@ func runUninstall(w, errW io.Writer, force bool) error { if shadowBranchCount > 0 { fmt.Fprintf(w, " - Shadow branches (%d)\n", shadowBranchCount) } - switch { - case claudeHooksInstalled && geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code, Gemini CLI)") - case claudeHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code)") - case geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Gemini CLI)") + if len(agentsWithInstalledHooks) > 0 { + fmt.Fprint(w, " - Agent hooks (") + for i, ag := range agentsWithInstalledHooks { + if i != 0 { + fmt.Fprint(w, ", ") + } + fmt.Fprintf(w, "%s", ag.Type()) + } + fmt.Fprintln(w, ")") } fmt.Fprintln(w) @@ -1128,7 +1129,7 @@ func runUninstall(w, errW io.Writer, force bool) error { fmt.Fprintln(w, "\nUninstalling Entire CLI...") // 1. Remove agent hooks (lowest risk) - if err := removeAgentHooks(w); err != nil { + if err := removeAgentHooks(w, agentsWithInstalledHooks); err != nil { fmt.Fprintf(errW, "Warning: failed to remove agent hooks: %v\n", err) } @@ -1189,30 +1190,23 @@ func countShadowBranches() int { return len(branches) } -// checkClaudeCodeHooksInstalled checks if Claude Code hooks are installed. -func checkClaudeCodeHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameClaudeCode) - if err != nil { - return false - } - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return false - } - return hookAgent.AreHooksInstalled() -} - -// checkGeminiCLIHooksInstalled checks if Gemini CLI hooks are installed. -func checkGeminiCLIHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameGemini) - if err != nil { - return false - } - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return false +func installedAgentHooks() []agent.HookSupport { + var installed []agent.HookSupport + for _, a := range agent.List() { + ag, err := agent.Get(a) + if err != nil { + continue + } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + continue + } + if !hookAgent.AreHooksInstalled() { + continue + } + installed = append(installed, hookAgent) } - return hookAgent.AreHooksInstalled() + return installed } // checkEntireDirExists checks if the .entire directory exists. @@ -1226,35 +1220,16 @@ func checkEntireDirExists() bool { } // removeAgentHooks removes hooks from all agents that support hooks. -func removeAgentHooks(w io.Writer) error { +// take list of agents to process, so we only remove hooks for the agents we previously listed. +func removeAgentHooks(w io.Writer, agents []agent.HookSupport) error { var errs []error - - // Remove Claude Code hooks - claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) - if err == nil { - if hookAgent, ok := claudeAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Claude Code hooks") - } - } - } - - // Remove Gemini CLI hooks - geminiAgent, err := agent.Get(agent.AgentNameGemini) - if err == nil { - if hookAgent, ok := geminiAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Gemini CLI hooks") - } + for _, ag := range agents { + if err := ag.UninstallHooks(); err != nil { + errs = append(errs, err) + } else { + fmt.Fprintf(w, " Removed %s hooks\n", ag.Type()) } } - return errors.Join(errs...) } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4c..1770b4892a 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -116,8 +116,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: - // Claude format - fall through to shared logic below + case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: + // JSONL format (Claude Code, Cursor, Unknown) - fall through to shared logic below } // Claude format (JSONL) - handles Claude Code, Unknown, and any future agent types lines, err := transcript.ParseFromBytes(content) diff --git a/cmd/entire/cli/transcript/types.go b/cmd/entire/cli/transcript/types.go index c86399294c..3ebd2cf16b 100644 --- a/cmd/entire/cli/transcript/types.go +++ b/cmd/entire/cli/transcript/types.go @@ -16,9 +16,10 @@ const ( ContentTypeToolUse = "tool_use" ) -// Line represents a single line in a Claude Code JSONL transcript. +// Line represents a single line in a Claude Code or Cursor JSONL transcript. type Line struct { Type string `json:"type"` + Role string `json:"role"` UUID string `json:"uuid"` Message json.RawMessage `json:"message"` } From 5b802c3c3c6bddf8d449ebfc916c8181d520fd72 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Thu, 19 Feb 2026 14:26:47 +0100 Subject: [PATCH 02/21] cursor: use Type field in transcript --- cmd/entire/cli/agent/cursor/cursor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 37c8d47310..9ae12d976a 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -246,7 +246,7 @@ func extractModifiedFiles(lines []transcript.Line) []string { var files []string for i := range lines { - if lines[i].Role != transcript.TypeAssistant && lines[i].Type != transcript.TypeAssistant { + if lines[i].Type != transcript.TypeAssistant { continue } From b504a25a2c1be6fb63a98cf65875480e525026bd Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 11:11:35 +0100 Subject: [PATCH 03/21] explain: add cursor --- cmd/entire/cli/explain.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 97aea8d659..d36b4286c5 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -536,7 +536,7 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT switch agentType { case agent.AgentTypeGemini: return geminicli.SliceFromMessage(fullTranscript, startOffset) - case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) @@ -1536,7 +1536,7 @@ func transcriptOffset(transcriptBytes []byte, agentType agent.AgentType) int { return 0 } return len(t.Messages) - case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeOpenCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: return countLines(transcriptBytes) } return countLines(transcriptBytes) From d62f41c0ae636a8b0b97b9b989a041bbda72860c Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:11:56 +0100 Subject: [PATCH 04/21] Fix Cursor agent issues found in PR 392 review against PR 442 checklist - Remove dead code: ParseHookInput, GetHookConfigPath, SupportsHooks (zero callers) - Remove incorrect extractModifiedFiles/FileModificationTools (Cursor transcript lacks tool_use blocks) - Fix FormatResumeCommand: return human-readable instruction instead of invalid CLI command - Document transcript.Line.Role field (Cursor uses "role", Claude Code uses "type") - Add shared transcript.ExtractModifiedFiles utility to transcript package - ReadSession no longer populates ModifiedFiles (relies on git status instead) Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: cd204de06bd2 --- cmd/entire/cli/agent/cursor/cursor.go | 150 ++--------------------- cmd/entire/cli/agent/cursor/lifecycle.go | 4 + cmd/entire/cli/agent/cursor/types.go | 14 --- cmd/entire/cli/transcript/parse.go | 46 +++++++ cmd/entire/cli/transcript/types.go | 6 +- 5 files changed, 65 insertions(+), 155 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 9ae12d976a..3f5439c838 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -2,10 +2,8 @@ package cursor import ( - "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" "regexp" @@ -13,7 +11,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/transcript" ) //nolint:gochecknoinits // Agent self-registration is the intended pattern @@ -62,78 +59,6 @@ func (c *CursorAgent) DetectPresence() (bool, error) { return false, nil } -// GetHookConfigPath returns the path to Cursor's hook config file. -func (c *CursorAgent) GetHookConfigPath() string { - return ".cursor/" + HooksFileName -} - -// SupportsHooks returns true as Cursor supports lifecycle hooks. -func (c *CursorAgent) SupportsHooks() bool { - return true -} - -// 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) - } - - if len(data) == 0 { - return nil, errors.New("empty input") - } - - input := &agent.HookInput{ - HookType: hookType, - Timestamp: time.Now(), - RawData: make(map[string]interface{}), - } - - switch hookType { - case agent.HookUserPromptSubmit: - var raw userPromptSubmitRaw - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse user prompt submit: %w", err) - } - input.SessionID = raw.getSessionID() - input.SessionRef = raw.TranscriptPath - input.UserPrompt = raw.Prompt - - case agent.HookSessionStart, agent.HookSessionEnd, agent.HookStop: - var raw sessionInfoRaw - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse session info: %w", err) - } - input.SessionID = raw.getSessionID() - input.SessionRef = raw.TranscriptPath - - case agent.HookPreToolUse: - var raw taskHookInputRaw - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse pre-tool input: %w", err) - } - input.SessionID = raw.getSessionID() - input.SessionRef = raw.TranscriptPath - input.ToolUseID = raw.ToolUseID - input.ToolInput = raw.ToolInput - - case agent.HookPostToolUse: - var raw postToolHookInputRaw - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse post-tool input: %w", err) - } - input.SessionID = raw.getSessionID() - input.SessionRef = raw.TranscriptPath - input.ToolUseID = raw.ToolUseID - input.ToolInput = raw.ToolInput - if raw.ToolResponse.AgentID != "" { - input.RawData["agent_id"] = raw.ToolResponse.AgentID - } - } - - return input, nil -} - // GetSessionID extracts the session ID from hook input. func (c *CursorAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID @@ -164,6 +89,8 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) { } // ReadSession reads a session from Cursor's storage (JSONL transcript file). +// Note: ModifiedFiles is left empty because Cursor's transcript format does not +// contain tool_use blocks. File detection relies on git status instead. func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { if input.SessionRef == "" { return nil, errors.New("session reference (transcript path) is required") @@ -174,18 +101,12 @@ func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, 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), + SessionID: input.SessionID, + AgentName: c.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, }, nil } @@ -214,9 +135,10 @@ func (c *CursorAgent) WriteSession(session *agent.AgentSession) error { return nil } -// FormatResumeCommand returns the command to resume a Cursor session. -func (c *CursorAgent) FormatResumeCommand(sessionID string) string { - return "cursor --resume " + sessionID +// FormatResumeCommand returns an instruction to resume a Cursor session. +// Cursor is a GUI IDE, so there's no CLI command to resume a session directly. +func (c *CursorAgent) FormatResumeCommand(_ string) string { + return "Open this project in Cursor to continue the session." } // sanitizePathForCursor converts a path to Cursor's project directory format. @@ -239,53 +161,3 @@ func (c *CursorAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, er func (c *CursorAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { return agent.ReassembleJSONL(chunks), nil } - -// extractModifiedFiles extracts file paths from transcript lines that contain file-modifying tools. -func extractModifiedFiles(lines []transcript.Line) []string { - seen := make(map[string]bool) - var files []string - - for i := range lines { - if lines[i].Type != transcript.TypeAssistant { - continue - } - - var msg transcript.AssistantMessage - if err := json.Unmarshal(lines[i].Message, &msg); err != nil { - continue - } - - for _, block := range msg.Content { - if block.Type != transcript.ContentTypeToolUse { - continue - } - - isModifyTool := false - for _, name := range FileModificationTools { - if block.Name == name { - isModifyTool = true - break - } - } - if !isModifyTool { - continue - } - - var toolInput transcript.ToolInput - if err := json.Unmarshal(block.Input, &toolInput); err != nil { - continue - } - - file := toolInput.FilePath - if file == "" { - file = toolInput.NotebookPath - } - if file != "" && !seen[file] { - seen[file] = true - files = append(files, file) - } - } - } - - return files -} diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index f72e4f78b6..cbfcdd8505 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -48,6 +48,10 @@ func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) { return data, nil } +// Note: CursorAgent does NOT implement TranscriptAnalyzer. Cursor's transcript +// format does not contain tool_use blocks that would allow extracting modified +// files. File detection relies on git status instead. + // --- Internal hook parsing functions --- func (c *CursorAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index a7ad3c02de..298bc3e995 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -100,17 +100,3 @@ func (p *postToolHookInputRaw) getSessionID() string { } return p.ConversationID } - -// Tool names used in Cursor transcripts (same as Claude Code) -const ( - ToolWrite = "Write" - ToolEdit = "Edit" - ToolNotebookEdit = "NotebookEdit" -) - -// FileModificationTools lists tools that create or modify files -var FileModificationTools = []string{ - ToolWrite, - ToolEdit, - ToolNotebookEdit, -} diff --git a/cmd/entire/cli/transcript/parse.go b/cmd/entire/cli/transcript/parse.go index 154529c968..1c0b18cf49 100644 --- a/cmd/entire/cli/transcript/parse.go +++ b/cmd/entire/cli/transcript/parse.go @@ -131,6 +131,52 @@ func SliceFromLine(content []byte, startLine int) []byte { return content[offset:] } +// ExtractModifiedFiles extracts file paths from assistant tool_use blocks that match +// the given set of file modification tool names (e.g., Write, Edit, NotebookEdit). +// Returns a deduplicated list of file paths in the order they first appear. +func ExtractModifiedFiles(lines []Line, toolNames []string) []string { + seen := make(map[string]bool) + var files []string + + toolSet := make(map[string]bool, len(toolNames)) + for _, name := range toolNames { + toolSet[name] = true + } + + for i := range lines { + if lines[i].Type != TypeAssistant { + continue + } + + var msg AssistantMessage + if err := json.Unmarshal(lines[i].Message, &msg); err != nil { + continue + } + + for _, block := range msg.Content { + if block.Type != ContentTypeToolUse || !toolSet[block.Name] { + continue + } + + var input ToolInput + if err := json.Unmarshal(block.Input, &input); err != nil { + continue + } + + file := input.FilePath + if file == "" { + file = input.NotebookPath + } + if file != "" && !seen[file] { + seen[file] = true + files = append(files, file) + } + } + } + + return files +} + // ExtractUserContent extracts user content from a raw message. // Handles both string and array content formats. // IDE-injected context tags (like ) are stripped from the result. diff --git a/cmd/entire/cli/transcript/types.go b/cmd/entire/cli/transcript/types.go index 3ebd2cf16b..55134d44b9 100644 --- a/cmd/entire/cli/transcript/types.go +++ b/cmd/entire/cli/transcript/types.go @@ -1,5 +1,5 @@ -// Package transcript provides shared types for parsing Claude Code transcripts. -// This package contains only data structures and constants, not parsing logic. +// Package transcript provides shared types and utilities for parsing JSONL transcripts. +// Used by agents that share the same JSONL format (Claude Code, Cursor). package transcript import "encoding/json" @@ -17,6 +17,8 @@ const ( ) // Line represents a single line in a Claude Code or Cursor JSONL transcript. +// Claude Code uses "type" to distinguish user/assistant messages. +// Cursor uses "role" for the same purpose. type Line struct { Type string `json:"type"` Role string `json:"role"` From 362e60db3c7cd3f809f60c7c824d801e980face1 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:14:39 +0100 Subject: [PATCH 05/21] dead code --- cmd/entire/cli/transcript/parse.go | 46 ------------------------------ 1 file changed, 46 deletions(-) diff --git a/cmd/entire/cli/transcript/parse.go b/cmd/entire/cli/transcript/parse.go index 1c0b18cf49..154529c968 100644 --- a/cmd/entire/cli/transcript/parse.go +++ b/cmd/entire/cli/transcript/parse.go @@ -131,52 +131,6 @@ func SliceFromLine(content []byte, startLine int) []byte { return content[offset:] } -// ExtractModifiedFiles extracts file paths from assistant tool_use blocks that match -// the given set of file modification tool names (e.g., Write, Edit, NotebookEdit). -// Returns a deduplicated list of file paths in the order they first appear. -func ExtractModifiedFiles(lines []Line, toolNames []string) []string { - seen := make(map[string]bool) - var files []string - - toolSet := make(map[string]bool, len(toolNames)) - for _, name := range toolNames { - toolSet[name] = true - } - - for i := range lines { - if lines[i].Type != TypeAssistant { - continue - } - - var msg AssistantMessage - if err := json.Unmarshal(lines[i].Message, &msg); err != nil { - continue - } - - for _, block := range msg.Content { - if block.Type != ContentTypeToolUse || !toolSet[block.Name] { - continue - } - - var input ToolInput - if err := json.Unmarshal(block.Input, &input); err != nil { - continue - } - - file := input.FilePath - if file == "" { - file = input.NotebookPath - } - if file != "" && !seen[file] { - seen[file] = true - files = append(files, file) - } - } - } - - return files -} - // ExtractUserContent extracts user content from a raw message. // Handles both string and array content formats. // IDE-injected context tags (like ) are stripped from the result. From 600d98117cf29c3dbe2bf647402d76f600a558d1 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:27:32 +0100 Subject: [PATCH 06/21] Add Cursor agent session and transcript tests Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: d5891c95e5a4 --- cmd/entire/cli/agent/cursor/cursor_test.go | 670 +++++++++++++++++++++ 1 file changed, 670 insertions(+) create mode 100644 cmd/entire/cli/agent/cursor/cursor_test.go diff --git a/cmd/entire/cli/agent/cursor/cursor_test.go b/cmd/entire/cli/agent/cursor/cursor_test.go new file mode 100644 index 0000000000..76551e993b --- /dev/null +++ b/cmd/entire/cli/agent/cursor/cursor_test.go @@ -0,0 +1,670 @@ +package cursor + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// sampleTranscriptLines returns JSONL lines matching real Cursor transcript format. +// Based on an actual Cursor session: uses "role" (not "type"), wraps user text +// in tags, and contains no tool_use blocks. +func sampleTranscriptLines() []string { + return []string{ + `{"role":"user","message":{"content":[{"type":"text","text":"\nhello\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}}`, + `{"role":"user","message":{"content":[{"type":"text","text":"\nadd 'one' to a file and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Created one.txt with one and committed."}]}}`, + } +} + +func writeSampleTranscript(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "transcript.jsonl") + content := strings.Join(sampleTranscriptLines(), "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write sample transcript: %v", err) + } + return path +} + +// --- Identity --- + +func TestCursorAgent_Name(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + if ag.Name() != agent.AgentNameCursor { + t.Errorf("Name() = %q, want %q", ag.Name(), agent.AgentNameCursor) + } +} + +func TestCursorAgent_Type(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + if ag.Type() != agent.AgentTypeCursor { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeCursor) + } +} + +func TestCursorAgent_Description(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + if ag.Description() == "" { + t.Error("Description() returned empty string") + } +} + +func TestCursorAgent_IsPreview(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + if !ag.IsPreview() { + t.Error("IsPreview() = false, want true") + } +} + +func TestCursorAgent_ProtectedDirs(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".cursor" { + t.Errorf("ProtectedDirs() = %v, want [.cursor]", dirs) + } +} + +func TestCursorAgent_FormatResumeCommand(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + cmd := ag.FormatResumeCommand("some-session-id") + if !strings.Contains(cmd, "Cursor") { + t.Errorf("FormatResumeCommand() = %q, expected mention of Cursor", cmd) + } +} + +// --- GetSessionID --- + +func TestCursorAgent_GetSessionID(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + input := &agent.HookInput{SessionID: "cursor-sess-42"} + if id := ag.GetSessionID(input); id != "cursor-sess-42" { + t.Errorf("GetSessionID() = %q, want cursor-sess-42", id) + } +} + +// --- ResolveSessionFile --- + +func TestCursorAgent_ResolveSessionFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + result := ag.ResolveSessionFile("/tmp/sessions", "abc123") + expected := "/tmp/sessions/abc123.jsonl" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +// --- GetSessionDir --- + +func TestCursorAgent_GetSessionDir_EnvOverride(t *testing.T) { + ag := &CursorAgent{} + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", "/test/override") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/test/override" { + t.Errorf("GetSessionDir() = %q, want /test/override", dir) + } +} + +func TestCursorAgent_GetSessionDir_DefaultPath(t *testing.T) { + ag := &CursorAgent{} + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", "") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if !filepath.IsAbs(dir) { + t.Errorf("GetSessionDir() should return absolute path, got %q", dir) + } + if !strings.Contains(dir, ".cursor") { + t.Errorf("GetSessionDir() = %q, expected path containing .cursor", dir) + } +} + +// --- ReadSession --- + +func TestReadSession_Success(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + input := &agent.HookInput{ + SessionID: "cursor-session-1", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "cursor-session-1" { + t.Errorf("SessionID = %q, want cursor-session-1", session.SessionID) + } + if session.AgentName != agent.AgentNameCursor { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameCursor) + } + if session.SessionRef != transcriptPath { + t.Errorf("SessionRef = %q, want %q", session.SessionRef, transcriptPath) + } + if len(session.NativeData) == 0 { + t.Error("NativeData is empty") + } + if session.StartTime.IsZero() { + t.Error("StartTime is zero") + } +} + +func TestReadSession_NativeDataMatchesFile(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + input := &agent.HookInput{ + SessionID: "sess-read", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + fileData, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read transcript file: %v", err) + } + + if !bytes.Equal(session.NativeData, fileData) { + t.Error("NativeData does not match file contents") + } +} + +func TestReadSession_ModifiedFilesEmpty(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + input := &agent.HookInput{ + SessionID: "sess-nofiles", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Cursor transcripts don't contain tool_use blocks, so ModifiedFiles + // should not be populated (file detection relies on git status instead). + if len(session.ModifiedFiles) != 0 { + t.Errorf("ModifiedFiles = %v, want empty (Cursor relies on git status)", session.ModifiedFiles) + } +} + +func TestReadSession_EmptySessionRef(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + input := &agent.HookInput{SessionID: "sess-no-ref"} + + _, err := ag.ReadSession(input) + if err == nil { + t.Fatal("ReadSession() should error when SessionRef is empty") + } +} + +func TestReadSession_MissingFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + input := &agent.HookInput{ + SessionID: "sess-missing", + SessionRef: "/nonexistent/path/transcript.jsonl", + } + + _, err := ag.ReadSession(input) + if err == nil { + t.Fatal("ReadSession() should error when transcript file doesn't exist") + } +} + +// --- WriteSession --- + +func TestWriteSession_Success(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "output.jsonl") + + content := strings.Join(sampleTranscriptLines(), "\n") + "\n" + + ag := &CursorAgent{} + session := &agent.AgentSession{ + SessionID: "write-session-1", + AgentName: agent.AgentNameCursor, + SessionRef: transcriptPath, + NativeData: []byte(content), + } + + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + data, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(data) != content { + t.Errorf("written content does not match original") + } +} + +func TestWriteSession_RoundTrip(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + + // Read + input := &agent.HookInput{ + SessionID: "roundtrip-session", + SessionRef: transcriptPath, + } + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Write to new path + newPath := filepath.Join(tmpDir, "roundtrip.jsonl") + session.SessionRef = newPath + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Read back and compare + original, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read original: %v", err) + } + written, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("failed to read written: %v", err) + } + if !bytes.Equal(original, written) { + t.Error("round-trip data mismatch: written file differs from original") + } +} + +func TestWriteSession_Nil(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + if err := ag.WriteSession(nil); err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + session := &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/path/to/file", + NativeData: []byte("data"), + } + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error for wrong agent") + } +} + +func TestWriteSession_EmptyAgentName(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := filepath.Join(tmpDir, "empty-agent.jsonl") + + ag := &CursorAgent{} + session := &agent.AgentSession{ + AgentName: "", // Empty agent name should be accepted + SessionRef: transcriptPath, + NativeData: []byte("data"), + } + if err := ag.WriteSession(session); err != nil { + t.Errorf("WriteSession() with empty AgentName should succeed, got: %v", err) + } +} + +func TestWriteSession_NoSessionRef(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameCursor, + NativeData: []byte("data"), + } + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_NoNativeData(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameCursor, + SessionRef: "/path/to/file", + } + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error when NativeData is empty") + } +} + +// --- ReadTranscript --- + +func TestReadTranscript_Success(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + data, err := ag.ReadTranscript(transcriptPath) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + if len(data) == 0 { + t.Error("ReadTranscript() returned empty data") + } + + // Verify it contains the expected Cursor format markers + content := string(data) + if !strings.Contains(content, `"role":"user"`) { + t.Error("transcript missing 'role' field (Cursor uses 'role', not 'type')") + } + if !strings.Contains(content, "") { + t.Error("transcript missing tags (Cursor wraps user text)") + } +} + +func TestReadTranscript_MissingFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") + if err == nil { + t.Fatal("ReadTranscript() should error for missing file") + } +} + +func TestReadTranscript_MatchesReadSession(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + + // ReadTranscript + transcriptData, err := ag.ReadTranscript(transcriptPath) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + + // ReadSession + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "compare-session", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if !bytes.Equal(transcriptData, session.NativeData) { + t.Error("ReadTranscript() and ReadSession().NativeData should return identical bytes") + } +} + +// --- ChunkTranscript / ReassembleTranscript --- + +func TestChunkTranscript_SmallContent(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + content := []byte(strings.Join(sampleTranscriptLines(), "\n")) + + chunks, err := ag.ChunkTranscript(content, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 1 { + t.Errorf("expected 1 chunk for small content, got %d", len(chunks)) + } + if !bytes.Equal(chunks[0], content) { + t.Error("single chunk should be identical to input") + } +} + +func TestChunkTranscript_ForcesMultipleChunks(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + // Build content large enough to require chunking at a small maxSize + var lines []string + for i := range 20 { + if i%2 == 0 { + lines = append(lines, `{"role":"user","message":{"content":[{"type":"text","text":"\nmessage `+strings.Repeat("x", 100)+`\n"}]}}`) + } else { + lines = append(lines, `{"role":"assistant","message":{"content":[{"type":"text","text":"response `+strings.Repeat("y", 100)+`"}]}}`) + } + } + content := []byte(strings.Join(lines, "\n")) + + // Force chunking with a small max size + maxSize := 500 + chunks, err := ag.ChunkTranscript(content, maxSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) < 2 { + t.Errorf("expected multiple chunks, got %d", len(chunks)) + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + // Build a multi-line JSONL transcript + var lines []string + for i := range 10 { + if i%2 == 0 { + lines = append(lines, `{"role":"user","message":{"content":[{"type":"text","text":"\nmsg-`+string(rune('A'+i))+`\n"}]}}`) + } else { + lines = append(lines, `{"role":"assistant","message":{"content":[{"type":"text","text":"reply-`+string(rune('A'+i))+`"}]}}`) + } + } + original := []byte(strings.Join(lines, "\n")) + + // Chunk with small max to force splits + chunks, err := ag.ChunkTranscript(original, 300) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + // Reassemble + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if !bytes.Equal(original, reassembled) { + t.Errorf("round-trip mismatch:\n original len=%d\n reassembled len=%d", len(original), len(reassembled)) + } +} + +func TestChunkTranscript_SingleChunkRoundTrip(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + content := []byte(strings.Join(sampleTranscriptLines(), "\n")) + + chunks, err := ag.ChunkTranscript(content, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if !bytes.Equal(content, reassembled) { + t.Error("single-chunk round-trip should preserve content exactly") + } +} + +func TestChunkTranscript_EmptyContent(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + chunks, err := ag.ChunkTranscript([]byte{}, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 0 { + t.Errorf("expected 0 chunks for empty content, got %d", len(chunks)) + } +} + +func TestReassembleTranscript_EmptyChunks(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + result, err := ag.ReassembleTranscript([][]byte{}) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + if len(result) != 0 { + t.Errorf("expected empty result for empty chunks, got %d bytes", len(result)) + } +} + +func TestChunkTranscript_PreservesLineOrder(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + + // Create numbered lines for order verification + var lines []string + for i := range 20 { + lines = append(lines, `{"role":"user","message":{"content":[{"type":"text","text":"line-`+ + strings.Repeat("0", 3-len(string(rune('0'+i/10))))+string(rune('0'+i/10))+string(rune('0'+i%10))+`"}]}}`) + } + original := strings.Join(lines, "\n") + + chunks, err := ag.ChunkTranscript([]byte(original), 400) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if string(reassembled) != original { + t.Error("chunk/reassemble did not preserve line order") + } +} + +// --- DetectPresence --- + +func TestDetectPresence_NoCursorDir(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + ag := &CursorAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } +} + +func TestDetectPresence_WithCursorDir(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if err := os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0o755); err != nil { + t.Fatalf("failed to create .cursor: %v", err) + } + + // DetectPresence uses paths.RepoRoot(), which may not find a git repo. + // Initialize one. + initGitRepo(t, tmpDir) + + ag := &CursorAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } +} + +// --- sanitizePathForCursor --- + +func TestSanitizePathForCursor(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"/Users/robin/project", "-Users-robin-project"}, + {"/tmp/test", "-tmp-test"}, + {"simple", "simple"}, + {"/path/with spaces/dir", "-path-with-spaces-dir"}, + {"/path.with.dots/dir", "-path-with-dots-dir"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + result := sanitizePathForCursor(tt.input) + if result != tt.expected { + t.Errorf("sanitizePathForCursor(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// --- helpers --- + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git: %v", err) + } + // Minimal HEAD file so go-git / paths.RepoRoot() can find it + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatalf("failed to write HEAD: %v", err) + } +} From b7c2b2a6546da5989b3eaeade8b6e85db3ca7aa7 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:30:38 +0100 Subject: [PATCH 07/21] Move ReadTranscript tests to lifecycle_test.go Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 86d61e18931c --- cmd/entire/cli/agent/cursor/cursor_test.go | 62 ------------------ cmd/entire/cli/agent/cursor/lifecycle_test.go | 63 +++++++++++++++++++ 2 files changed, 63 insertions(+), 62 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/cursor_test.go b/cmd/entire/cli/agent/cursor/cursor_test.go index 76551e993b..b536131d8c 100644 --- a/cmd/entire/cli/agent/cursor/cursor_test.go +++ b/cmd/entire/cli/agent/cursor/cursor_test.go @@ -376,68 +376,6 @@ func TestWriteSession_NoNativeData(t *testing.T) { } } -// --- ReadTranscript --- - -func TestReadTranscript_Success(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - transcriptPath := writeSampleTranscript(t, tmpDir) - - ag := &CursorAgent{} - data, err := ag.ReadTranscript(transcriptPath) - if err != nil { - t.Fatalf("ReadTranscript() error = %v", err) - } - if len(data) == 0 { - t.Error("ReadTranscript() returned empty data") - } - - // Verify it contains the expected Cursor format markers - content := string(data) - if !strings.Contains(content, `"role":"user"`) { - t.Error("transcript missing 'role' field (Cursor uses 'role', not 'type')") - } - if !strings.Contains(content, "") { - t.Error("transcript missing tags (Cursor wraps user text)") - } -} - -func TestReadTranscript_MissingFile(t *testing.T) { - t.Parallel() - ag := &CursorAgent{} - _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") - if err == nil { - t.Fatal("ReadTranscript() should error for missing file") - } -} - -func TestReadTranscript_MatchesReadSession(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - transcriptPath := writeSampleTranscript(t, tmpDir) - - ag := &CursorAgent{} - - // ReadTranscript - transcriptData, err := ag.ReadTranscript(transcriptPath) - if err != nil { - t.Fatalf("ReadTranscript() error = %v", err) - } - - // ReadSession - session, err := ag.ReadSession(&agent.HookInput{ - SessionID: "compare-session", - SessionRef: transcriptPath, - }) - if err != nil { - t.Fatalf("ReadSession() error = %v", err) - } - - if !bytes.Equal(transcriptData, session.NativeData) { - t.Error("ReadTranscript() and ReadSession().NativeData should return identical bytes") - } -} - // --- ChunkTranscript / ReassembleTranscript --- func TestChunkTranscript_SmallContent(t *testing.T) { diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index 1e76259f4f..8f40806a9d 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -1,6 +1,7 @@ package cursor import ( + "bytes" "encoding/json" "strings" "testing" @@ -387,3 +388,65 @@ func TestParseHookEvent_AllHookTypes(t *testing.T) { }) } } + +// --- ReadTranscript --- + +func TestReadTranscript_Success(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + data, err := ag.ReadTranscript(transcriptPath) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + if len(data) == 0 { + t.Error("ReadTranscript() returned empty data") + } + + // Verify it contains the expected Cursor format markers + content := string(data) + if !strings.Contains(content, `"role":"user"`) { + t.Error("transcript missing 'role' field (Cursor uses 'role', not 'type')") + } + if !strings.Contains(content, "") { + t.Error("transcript missing tags (Cursor wraps user text)") + } +} + +func TestReadTranscript_MissingFile(t *testing.T) { + t.Parallel() + ag := &CursorAgent{} + _, err := ag.ReadTranscript("/nonexistent/path/transcript.jsonl") + if err == nil { + t.Fatal("ReadTranscript() should error for missing file") + } +} + +func TestReadTranscript_MatchesReadSession(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + transcriptPath := writeSampleTranscript(t, tmpDir) + + ag := &CursorAgent{} + + // ReadTranscript + transcriptData, err := ag.ReadTranscript(transcriptPath) + if err != nil { + t.Fatalf("ReadTranscript() error = %v", err) + } + + // ReadSession + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "compare-session", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if !bytes.Equal(transcriptData, session.NativeData) { + t.Error("ReadTranscript() and ReadSession().NativeData should return identical bytes") + } +} From 9fbe8bdd796a4d9c26aca10d78620e2adbfcfe9b Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:48:45 +0100 Subject: [PATCH 08/21] omit empty 'Role' field Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/entire/cli/transcript/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/transcript/types.go b/cmd/entire/cli/transcript/types.go index 55134d44b9..3c0e284e3a 100644 --- a/cmd/entire/cli/transcript/types.go +++ b/cmd/entire/cli/transcript/types.go @@ -21,7 +21,7 @@ const ( // Cursor uses "role" for the same purpose. type Line struct { Type string `json:"type"` - Role string `json:"role"` + Role string `json:"role,omitempty"` UUID string `json:"uuid"` Message json.RawMessage `json:"message"` } From 0070be03eeaecd0327b16cebc05d08bb5fcb7816 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 14:56:07 +0100 Subject: [PATCH 09/21] Fix Cursor transcripts producing empty condensed output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor JSONL uses "role" instead of "type" to distinguish user/assistant messages. Normalize role→type during transcript parsing so all downstream consumers (summarize, explain) work uniformly. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: fac4ff295b71 --- cmd/entire/cli/summarize/summarize_test.go | 70 +++++++++++++ cmd/entire/cli/transcript/parse.go | 12 +++ cmd/entire/cli/transcript/parse_test.go | 112 +++++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/cmd/entire/cli/summarize/summarize_test.go b/cmd/entire/cli/summarize/summarize_test.go index 5891aa32ce..87df98aefe 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -784,6 +784,76 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSONL(t *testing.T) { } } +func TestBuildCondensedTranscriptFromBytes_CursorRoleBasedJSONL(t *testing.T) { + // Cursor transcripts use "role" instead of "type" and wrap user text in tags. + // The transcript parser normalizes role→type, so condensation should work. + cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"\nhello\n"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}} +{"role":"user","message":{"content":[{"type":"text","text":"\nadd one to a file and commit\n"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Created one.txt with one and committed."}]}} +` + + entries, err := BuildCondensedTranscriptFromBytes([]byte(cursorJSONL), agent.AgentTypeCursor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(entries) == 0 { + t.Fatal("expected non-empty entries for Cursor transcript, got 0 (role→type normalization may be broken)") + } + + // Should have 4 entries: 2 user + 2 assistant + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + if entries[0].Type != EntryTypeUser { + t.Errorf("entry 0: expected type %s, got %s", EntryTypeUser, entries[0].Type) + } + if !strings.Contains(entries[0].Content, "hello") { + t.Errorf("entry 0: expected content containing 'hello', got %q", entries[0].Content) + } + + if entries[1].Type != EntryTypeAssistant { + t.Errorf("entry 1: expected type %s, got %s", EntryTypeAssistant, entries[1].Type) + } + if entries[1].Content != "Hi there!" { + t.Errorf("entry 1: expected 'Hi there!', got %q", entries[1].Content) + } + + if entries[2].Type != EntryTypeUser { + t.Errorf("entry 2: expected type %s, got %s", EntryTypeUser, entries[2].Type) + } + + if entries[3].Type != EntryTypeAssistant { + t.Errorf("entry 3: expected type %s, got %s", EntryTypeAssistant, entries[3].Type) + } +} + +func TestBuildCondensedTranscriptFromBytes_CursorNoToolUseBlocks(t *testing.T) { + // Cursor transcripts have no tool_use blocks — only text content. + // This verifies we get entries (not an empty result) even without tool calls. + cursorJSONL := `{"role":"user","message":{"content":[{"type":"text","text":"write a poem"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Here is a poem about code."}]}} +` + + entries, err := BuildCondensedTranscriptFromBytes([]byte(cursorJSONL), agent.AgentTypeCursor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // No tool entries should appear + for i, e := range entries { + if e.Type == EntryTypeTool { + t.Errorf("entry %d: unexpected tool entry in Cursor transcript", i) + } + } +} + // mustMarshal is a test helper that marshals v to JSON, failing the test on error. func mustMarshal(t *testing.T, v interface{}) json.RawMessage { t.Helper() diff --git a/cmd/entire/cli/transcript/parse.go b/cmd/entire/cli/transcript/parse.go index 154529c968..15a95ec85a 100644 --- a/cmd/entire/cli/transcript/parse.go +++ b/cmd/entire/cli/transcript/parse.go @@ -34,6 +34,7 @@ func ParseFromBytes(content []byte) ([]Line, error) { var line Line if err := json.Unmarshal(lineBytes, &line); err == nil { + normalizeLineType(&line) lines = append(lines, line) } @@ -83,6 +84,7 @@ func ParseFromFileAtLine(path string, startLine int) ([]Line, int, error) { if totalLines >= startLine { var line Line if err := json.Unmarshal(lineBytes, &line); err == nil { + normalizeLineType(&line) lines = append(lines, line) } } @@ -96,6 +98,16 @@ func ParseFromFileAtLine(path string, startLine int) ([]Line, int, error) { return lines, totalLines, nil } +// normalizeLineType ensures line.Type is populated for all transcript formats. +// Claude Code uses "type" while Cursor uses "role" for the same purpose. +// When Type is empty but Role is set, we copy Role into Type so all downstream +// consumers can switch on Type uniformly. +func normalizeLineType(line *Line) { + if line.Type == "" && line.Role != "" { + line.Type = line.Role + } +} + // SliceFromLine returns the content starting from line number `startLine` (0-indexed). // This is used to extract only the checkpoint-specific portion of a cumulative transcript. // For example, if startLine is 2, lines 0 and 1 are skipped and the result starts at line 2. diff --git a/cmd/entire/cli/transcript/parse_test.go b/cmd/entire/cli/transcript/parse_test.go index cd4ade9136..dd4ebd6673 100644 --- a/cmd/entire/cli/transcript/parse_test.go +++ b/cmd/entire/cli/transcript/parse_test.go @@ -455,3 +455,115 @@ invalid json line t.Errorf("len(lines) = %d, want 2 (valid lines after offset)", len(lines)) } } + +// --- Role→Type normalization tests (Cursor format) --- + +func TestParseFromBytes_NormalizesRoleToType(t *testing.T) { + t.Parallel() + + // Cursor transcript uses "role" instead of "type" + content := []byte(`{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Hi there!"}]}} +`) + + lines, err := ParseFromBytes(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + // Type should be populated from Role + if lines[0].Type != TypeUser { + t.Errorf("line 0: Type = %q, want %q (normalized from role)", lines[0].Type, TypeUser) + } + if lines[0].Role != "user" { + t.Errorf("line 0: Role = %q, want 'user' (preserved)", lines[0].Role) + } + + if lines[1].Type != TypeAssistant { + t.Errorf("line 1: Type = %q, want %q (normalized from role)", lines[1].Type, TypeAssistant) + } + if lines[1].Role != "assistant" { + t.Errorf("line 1: Role = %q, want 'assistant' (preserved)", lines[1].Role) + } +} + +func TestParseFromBytes_TypeTakesPrecedenceOverRole(t *testing.T) { + t.Parallel() + + // When both type and role are set, type should win (Claude Code format) + content := []byte(`{"type":"user","role":"something-else","uuid":"u1","message":{"content":"hello"}} +`) + + lines, err := ParseFromBytes(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + + if lines[0].Type != TypeUser { + t.Errorf("Type = %q, want %q (type should take precedence over role)", lines[0].Type, TypeUser) + } +} + +func TestParseFromFileAtLine_NormalizesRoleToType(t *testing.T) { + t.Parallel() + + // Cursor transcript format + content := `{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Hi!"}]}}` + + tmpFile := createTempTranscript(t, content) + + lines, totalLines, err := ParseFromFileAtLine(tmpFile, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if totalLines != 2 { + t.Errorf("totalLines = %d, want 2", totalLines) + } + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + if lines[0].Type != TypeUser { + t.Errorf("line 0: Type = %q, want %q (normalized from role)", lines[0].Type, TypeUser) + } + if lines[1].Type != TypeAssistant { + t.Errorf("line 1: Type = %q, want %q (normalized from role)", lines[1].Type, TypeAssistant) + } +} + +func TestParseFromFileAtLine_NormalizesRoleWithOffset(t *testing.T) { + t.Parallel() + + content := `{"role":"user","message":{"content":[{"type":"text","text":"first"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"response"}]}} +{"role":"user","message":{"content":[{"type":"text","text":"second"}]}}` + + tmpFile := createTempTranscript(t, content) + + // Skip first line + lines, _, err := ParseFromFileAtLine(tmpFile, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + if lines[0].Type != TypeAssistant { + t.Errorf("line 0: Type = %q, want %q", lines[0].Type, TypeAssistant) + } + if lines[1].Type != TypeUser { + t.Errorf("line 1: Type = %q, want %q", lines[1].Type, TypeUser) + } +} From 7062343ed7403ec06aed3a9eb8bdfa024c9a3ddd Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Fri, 20 Feb 2026 18:31:57 +0100 Subject: [PATCH 10/21] tuning hooks Entire-Checkpoint: f15cd73bd481 --- cmd/entire/cli/agent/cursor/hooks.go | 27 ++--- cmd/entire/cli/agent/cursor/hooks_test.go | 34 +++--- cmd/entire/cli/agent/cursor/lifecycle.go | 37 +++--- cmd/entire/cli/agent/cursor/lifecycle_test.go | 76 +++--------- cmd/entire/cli/agent/cursor/types.go | 109 +++++++++--------- 5 files changed, 114 insertions(+), 169 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go index 206c1be38a..4c9ab9260b 100644 --- a/cmd/entire/cli/agent/cursor/hooks.go +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -23,9 +23,8 @@ const ( HookNameSessionEnd = "session-end" HookNameBeforeSubmitPrompt = "before-submit-prompt" HookNameStop = "stop" - HookNamePreTask = "pre-task" - HookNamePostTask = "post-task" - HookNamePostTodo = "post-todo" + HookNamePreTool = "pre-tool" + HookNamePostTool = "post-tool" ) // HooksFileName is the hooks file used by Cursor. @@ -45,9 +44,8 @@ func (c *CursorAgent) GetHookNames() []string { HookNameSessionEnd, HookNameBeforeSubmitPrompt, HookNameStop, - HookNamePreTask, - HookNamePostTask, - HookNamePostTodo, + HookNamePreTool, + HookNamePostTool, } } @@ -121,9 +119,8 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { sessionEndCmd := cmdPrefix + "session-end" beforeSubmitPromptCmd := cmdPrefix + "before-submit-prompt" stopCmd := cmdPrefix + "stop" - preTaskCmd := cmdPrefix + "pre-task" - postTaskCmd := cmdPrefix + "post-task" - postTodoCmd := cmdPrefix + "post-todo" + preTaskCmd := cmdPrefix + HookNamePreTool + postTaskCmd := cmdPrefix + HookNamePostTool count := 0 @@ -144,16 +141,12 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { stop = append(stop, CursorHookEntry{Command: stopCmd}) count++ } - if !hookCommandExistsWithMatcher(preToolUse, "Task", preTaskCmd) { - preToolUse = append(preToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Task"}) + if !hookCommandExistsWithMatcher(preToolUse, "Subagent", preTaskCmd) { + preToolUse = append(preToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Subagent"}) count++ } - if !hookCommandExistsWithMatcher(postToolUse, "Task", postTaskCmd) { - postToolUse = append(postToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Task"}) - count++ - } - if !hookCommandExistsWithMatcher(postToolUse, "TodoWrite", postTodoCmd) { - postToolUse = append(postToolUse, CursorHookEntry{Command: postTodoCmd, Matcher: "TodoWrite"}) + if !hookCommandExistsWithMatcher(postToolUse, "Subagent", postTaskCmd) { + postToolUse = append(postToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Subagent"}) count++ } diff --git a/cmd/entire/cli/agent/cursor/hooks_test.go b/cmd/entire/cli/agent/cursor/hooks_test.go index ed05a7041e..3516aa70fe 100644 --- a/cmd/entire/cli/agent/cursor/hooks_test.go +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -17,8 +17,8 @@ func TestInstallHooks_FreshInstall(t *testing.T) { t.Fatalf("InstallHooks() error = %v", err) } - if count != 7 { - t.Errorf("InstallHooks() count = %d, want 7", count) + if count != 6 { + t.Errorf("InstallHooks() count = %d, want 6", count) } hooksFile := readHooksFile(t, tempDir) @@ -40,9 +40,9 @@ func TestInstallHooks_FreshInstall(t *testing.T) { if len(hooksFile.Hooks.PreToolUse) != 1 { t.Errorf("PreToolUse hooks = %d, want 1", len(hooksFile.Hooks.PreToolUse)) } - // PostToolUse has 2 (Task + TodoWrite) - if len(hooksFile.Hooks.PostToolUse) != 2 { - t.Errorf("PostToolUse hooks = %d, want 2", len(hooksFile.Hooks.PostToolUse)) + // PostToolUse has 1 (Task) + if len(hooksFile.Hooks.PostToolUse) != 1 { + t.Errorf("PostToolUse hooks = %d, want 1", len(hooksFile.Hooks.PostToolUse)) } // Verify version @@ -56,9 +56,8 @@ func TestInstallHooks_FreshInstall(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.BeforeSubmitPrompt, "entire hooks cursor before-submit-prompt") // Verify matchers on tool hooks - assertEntryWithMatcher(t, hooksFile.Hooks.PreToolUse, "Task", "entire hooks cursor pre-task") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Task", "entire hooks cursor post-task") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "TodoWrite", "entire hooks cursor post-todo") + assertEntryWithMatcher(t, hooksFile.Hooks.PreToolUse, "Subagent", "entire hooks cursor pre-tool") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Subagent", "entire hooks cursor post-tool") } func TestInstallHooks_Idempotent(t *testing.T) { @@ -72,8 +71,8 @@ func TestInstallHooks_Idempotent(t *testing.T) { if err != nil { t.Fatalf("first InstallHooks() error = %v", err) } - if count1 != 7 { - t.Errorf("first InstallHooks() count = %d, want 7", count1) + if count1 != 6 { + t.Errorf("first InstallHooks() count = %d, want 6", count1) } // Second install @@ -174,8 +173,8 @@ func TestInstallHooks_ForceReinstall(t *testing.T) { if err != nil { t.Fatalf("force InstallHooks() error = %v", err) } - if count != 7 { - t.Errorf("force InstallHooks() count = %d, want 7", count) + if count != 6 { + t.Errorf("force InstallHooks() count = %d, want 6", count) } // Verify no duplicates @@ -218,12 +217,11 @@ func TestInstallHooks_PreservesExistingHooks(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") // PostToolUse should have user Write hook + Task hook + TodoWrite hook - if len(hooksFile.Hooks.PostToolUse) != 3 { - t.Errorf("PostToolUse hooks = %d, want 3 (user Write + Task + TodoWrite)", len(hooksFile.Hooks.PostToolUse)) + if len(hooksFile.Hooks.PostToolUse) != 2 { + t.Errorf("PostToolUse hooks = %d, want 2 (user Write + Task)", len(hooksFile.Hooks.PostToolUse)) } assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Write", "echo file written") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Task", "entire hooks cursor post-task") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "TodoWrite", "entire hooks cursor post-todo") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Subagent", "entire hooks cursor post-tool") } func TestInstallHooks_LocalDev(t *testing.T) { @@ -267,8 +265,8 @@ func TestInstallHooks_PreservesUnknownFields(t *testing.T) { if err != nil { t.Fatalf("InstallHooks() error = %v", err) } - if count != 7 { - t.Errorf("InstallHooks() count = %d, want 7", count) + if count != 6 { + t.Errorf("InstallHooks() count = %d, want 6", count) } // Read the raw JSON to verify unknown fields are preserved diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index cbfcdd8505..c2656f3f98 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -27,13 +27,10 @@ func (c *CursorAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.E return c.parseTurnEnd(stdin) case HookNameSessionEnd: return c.parseSessionEnd(stdin) - case HookNamePreTask: - return c.parseSubagentStart(stdin) - case HookNamePostTask: - return c.parseSubagentEnd(stdin) - case HookNamePostTodo: - // PostTodo is handled outside the generic dispatcher (incremental checkpoints). - return nil, nil //nolint:nilnil // nil event = no lifecycle action + case HookNamePreTool: + return c.parsePreToolUse(stdin) + case HookNamePostTool: + return c.parsePostToolUse(stdin) default: return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action } @@ -61,20 +58,20 @@ func (c *CursorAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { } return &agent.Event{ Type: agent.SessionStart, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, Timestamp: time.Now(), }, nil } func (c *CursorAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[userPromptSubmitRaw](stdin) + raw, err := agent.ReadAndParseHookInput[beforeSubmitPromptInputRaw](stdin) if err != nil { return nil, err } return &agent.Event{ Type: agent.TurnStart, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, Prompt: raw.Prompt, Timestamp: time.Now(), @@ -88,7 +85,7 @@ func (c *CursorAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { } return &agent.Event{ Type: agent.TurnEnd, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, Timestamp: time.Now(), }, nil @@ -101,20 +98,20 @@ func (c *CursorAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { } return &agent.Event{ Type: agent.SessionEnd, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, Timestamp: time.Now(), }, nil } -func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[taskHookInputRaw](stdin) +func (c *CursorAgent) parsePreToolUse(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[preToolUseHookInputRaw](stdin) if err != nil { return nil, err } return &agent.Event{ Type: agent.SubagentStart, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, ToolUseID: raw.ToolUseID, ToolInput: raw.ToolInput, @@ -122,21 +119,19 @@ func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) }, nil } -func (c *CursorAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[postToolHookInputRaw](stdin) +func (c *CursorAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[postToolUseHookInputRaw](stdin) if err != nil { return nil, err } event := &agent.Event{ Type: agent.SubagentEnd, - SessionID: raw.getSessionID(), + SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, ToolUseID: raw.ToolUseID, ToolInput: raw.ToolInput, Timestamp: time.Now(), } - if raw.ToolResponse.AgentID != "" { - event.SubagentID = raw.ToolResponse.AgentID - } + // TODO "tool_output": "{\"status\":\"success\",\"agentId\":\"3211cc34-8d8f-42de-9dcf-c19625b17566\",\"durationMs\":7901,\"messageCount\":1,\"toolCallCount\":1}", return event, nil } diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index 8f40806a9d..ccec9badab 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -13,7 +13,7 @@ func TestParseHookEvent_SessionStart(t *testing.T) { t.Parallel() ag := &CursorAgent{} - input := `{"session_id": "test-session-123", "transcript_path": "/tmp/transcript.jsonl"}` + input := `{"conversation_id": "test-session-123", "transcript_path": "/tmp/transcript.jsonl"}` event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) @@ -41,7 +41,7 @@ func TestParseHookEvent_TurnStart(t *testing.T) { t.Parallel() ag := &CursorAgent{} - input := `{"session_id": "sess-456", "transcript_path": "/tmp/t.jsonl", "prompt": "Hello world"}` + input := `{"conversation_id": "sess-456", "transcript_path": "/tmp/t.jsonl", "prompt": "Hello world"}` event, err := ag.ParseHookEvent(HookNameBeforeSubmitPrompt, strings.NewReader(input)) @@ -66,7 +66,7 @@ func TestParseHookEvent_TurnEnd(t *testing.T) { t.Parallel() ag := &CursorAgent{} - input := `{"session_id": "sess-789", "transcript_path": "/tmp/stop.jsonl"}` + input := `{"conversation_id": "sess-789", "transcript_path": "/tmp/stop.jsonl"}` event, err := ag.ParseHookEvent(HookNameStop, strings.NewReader(input)) @@ -80,7 +80,7 @@ func TestParseHookEvent_TurnEnd(t *testing.T) { t.Errorf("expected event type %v, got %v", agent.TurnEnd, event.Type) } if event.SessionID != "sess-789" { - t.Errorf("expected session_id 'sess-789', got %q", event.SessionID) + t.Errorf("expected conversation_id 'sess-789', got %q", event.SessionID) } } @@ -88,7 +88,7 @@ func TestParseHookEvent_SessionEnd(t *testing.T) { t.Parallel() ag := &CursorAgent{} - input := `{"session_id": "ending-session", "transcript_path": "/tmp/end.jsonl"}` + input := `{"conversation_id": "ending-session", "transcript_path": "/tmp/end.jsonl"}` event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) @@ -102,7 +102,7 @@ func TestParseHookEvent_SessionEnd(t *testing.T) { t.Errorf("expected event type %v, got %v", agent.SessionEnd, event.Type) } if event.SessionID != "ending-session" { - t.Errorf("expected session_id 'ending-session', got %q", event.SessionID) + t.Errorf("expected conversation_id 'ending-session', got %q", event.SessionID) } } @@ -112,7 +112,7 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { ag := &CursorAgent{} toolInput := json.RawMessage(`{"description": "test task", "prompt": "do something"}`) inputData := map[string]any{ - "session_id": "main-session", + "conversation_id": "main-session", "transcript_path": "/tmp/main.jsonl", "tool_use_id": "toolu_abc123", "tool_input": toolInput, @@ -122,7 +122,7 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { t.Fatalf("failed to marshal test input: %v", marshalErr) } - event, err := ag.ParseHookEvent(HookNamePreTask, strings.NewReader(string(inputBytes))) + event, err := ag.ParseHookEvent(HookNamePreTool, strings.NewReader(string(inputBytes))) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -149,20 +149,17 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) { ag := &CursorAgent{} inputData := map[string]any{ - "session_id": "main-session", + "conversation_id": "main-session", "transcript_path": "/tmp/main.jsonl", "tool_use_id": "toolu_xyz789", "tool_input": json.RawMessage(`{"prompt": "task done"}`), - "tool_response": map[string]string{ - "agentId": "agent-subagent-001", - }, } inputBytes, marshalErr := json.Marshal(inputData) if marshalErr != nil { t.Fatalf("failed to marshal test input: %v", marshalErr) } - event, err := ag.ParseHookEvent(HookNamePostTask, strings.NewReader(string(inputBytes))) + event, err := ag.ParseHookEvent(HookNamePostTool, strings.NewReader(string(inputBytes))) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -176,25 +173,6 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) { if event.ToolUseID != "toolu_xyz789" { t.Errorf("expected tool_use_id 'toolu_xyz789', got %q", event.ToolUseID) } - if event.SubagentID != "agent-subagent-001" { - t.Errorf("expected subagent_id 'agent-subagent-001', got %q", event.SubagentID) - } -} - -func TestParseHookEvent_PostTodo_ReturnsNil(t *testing.T) { - t.Parallel() - - ag := &CursorAgent{} - input := `{"session_id": "todo-session", "transcript_path": "/tmp/todo.jsonl"}` - - event, err := ag.ParseHookEvent(HookNamePostTodo, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if event != nil { - t.Errorf("expected nil event for post-todo, got %+v", event) - } } func TestParseHookEvent_UnknownHook_ReturnsNil(t *testing.T) { @@ -233,29 +211,16 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { ag := &CursorAgent{} - t.Run("uses session_id when present", func(t *testing.T) { - t.Parallel() - input := `{"session_id": "preferred-id", "conversation_id": "fallback-id", "transcript_path": "/tmp/t.jsonl"}` - - event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if event.SessionID != "preferred-id" { - t.Errorf("expected session_id 'preferred-id', got %q", event.SessionID) - } - }) - - t.Run("falls back to conversation_id", func(t *testing.T) { + t.Run("uses conversation_id", func(t *testing.T) { t.Parallel() - input := `{"conversation_id": "fallback-id", "transcript_path": "/tmp/t.jsonl"}` + input := `{"conversation_id": "bingo-id", "transcript_path": "/tmp/t.jsonl"}` event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) if err != nil { t.Fatalf("unexpected error: %v", err) } - if event.SessionID != "fallback-id" { - t.Errorf("expected session_id 'fallback-id' (from conversation_id), got %q", event.SessionID) + if event.SessionID != "bingo-id" { + t.Errorf("expected session_id 'bingo-id' (from conversation_id), got %q", event.SessionID) } }) @@ -276,7 +241,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Parallel() input := `{"conversation_id": "conv-sub", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t1", "tool_input": {}}` - event, err := ag.ParseHookEvent(HookNamePreTask, strings.NewReader(input)) + event, err := ag.ParseHookEvent(HookNamePreTool, strings.NewReader(input)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -289,7 +254,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Parallel() input := `{"conversation_id": "conv-end", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}` - event, err := ag.ParseHookEvent(HookNamePostTask, strings.NewReader(input)) + event, err := ag.ParseHookEvent(HookNamePostTool, strings.NewReader(input)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -345,20 +310,15 @@ func TestParseHookEvent_AllHookTypes(t *testing.T) { inputTemplate: `{"session_id": "s4", "transcript_path": "/t"}`, }, { - hookName: HookNamePreTask, + hookName: HookNamePreTool, expectedType: agent.SubagentStart, inputTemplate: `{"session_id": "s5", "transcript_path": "/t", "tool_use_id": "t1", "tool_input": {}}`, }, { - hookName: HookNamePostTask, + hookName: HookNamePostTool, expectedType: agent.SubagentEnd, inputTemplate: `{"session_id": "s6", "transcript_path": "/t", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}`, }, - { - hookName: HookNamePostTodo, - expectNil: true, - inputTemplate: `{"session_id": "s7", "transcript_path": "/t"}`, - }, } for _, tc := range testCases { diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index 298bc3e995..16563edbb2 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -35,68 +35,67 @@ type CursorHookEntry struct { // sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop hooks. // Cursor may provide session_id or conversation_id (fallback). type sessionInfoRaw struct { - SessionID string `json:"session_id"` - ConversationID string `json:"conversation_id"` - TranscriptPath string `json:"transcript_path"` + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` } -// getSessionID returns session_id if present, falling back to conversation_id. -func (s *sessionInfoRaw) getSessionID() string { - if s.SessionID != "" { - return s.SessionID - } - return s.ConversationID -} +// beforeSubmitPromptInputRaw is the JSON structure from BeforeSubmitPrompt hooks. +type beforeSubmitPromptInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` -// userPromptSubmitRaw is the JSON structure from BeforeSubmitPrompt hooks. -type userPromptSubmitRaw struct { - SessionID string `json:"session_id"` - ConversationID string `json:"conversation_id"` - TranscriptPath string `json:"transcript_path"` - Prompt string `json:"prompt"` + // hook specific + Prompt string `json:"prompt"` } -// getSessionID returns session_id if present, falling back to conversation_id. -func (u *userPromptSubmitRaw) getSessionID() string { - if u.SessionID != "" { - return u.SessionID - } - return u.ConversationID -} +// preToolUseHookInputRaw is the JSON structure from PreToolUse[Task] hook. +type preToolUseHookInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` -// taskHookInputRaw is the JSON structure from PreToolUse[Task] hook. -type taskHookInputRaw struct { - SessionID string `json:"session_id"` - ConversationID string `json:"conversation_id"` - TranscriptPath string `json:"transcript_path"` - ToolUseID string `json:"tool_use_id"` - ToolInput json.RawMessage `json:"tool_input"` + // hook specific + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolName string `json:"tool_name"` } -// getSessionID returns session_id if present, falling back to conversation_id. -func (t *taskHookInputRaw) getSessionID() string { - if t.SessionID != "" { - return t.SessionID - } - return t.ConversationID -} - -// postToolHookInputRaw is the JSON structure from PostToolUse hooks. -type postToolHookInputRaw struct { - SessionID string `json:"session_id"` - ConversationID string `json:"conversation_id"` - TranscriptPath string `json:"transcript_path"` - ToolUseID string `json:"tool_use_id"` - ToolInput json.RawMessage `json:"tool_input"` - ToolResponse struct { - AgentID string `json:"agentId"` - } `json:"tool_response"` -} +// postToolUseHookInputRaw is the JSON structure from PostToolUse hooks. +type postToolUseHookInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` -// getSessionID returns session_id if present, falling back to conversation_id. -func (p *postToolHookInputRaw) getSessionID() string { - if p.SessionID != "" { - return p.SessionID - } - return p.ConversationID + // hook specific + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolOutput string `json:"tool_output"` + ToolUseID string `json:"tool_use_id"` + Cwd string `json:"cwd"` } From 6e9672cdcb2f4dd33aeb2a706617679b086020fc Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 11:42:55 +0100 Subject: [PATCH 11/21] fix hooks Entire-Checkpoint: f5946bdba9af --- cmd/entire/cli/agent/cursor/hooks.go | 80 ++++++++++--------- cmd/entire/cli/agent/cursor/hooks_test.go | 28 +++---- cmd/entire/cli/agent/cursor/lifecycle.go | 53 ++++++++---- cmd/entire/cli/agent/cursor/lifecycle_test.go | 12 +-- cmd/entire/cli/agent/cursor/types.go | 63 +++++++++++---- 5 files changed, 151 insertions(+), 85 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go index 4c9ab9260b..204832d122 100644 --- a/cmd/entire/cli/agent/cursor/hooks.go +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -23,8 +23,9 @@ const ( HookNameSessionEnd = "session-end" HookNameBeforeSubmitPrompt = "before-submit-prompt" HookNameStop = "stop" - HookNamePreTool = "pre-tool" - HookNamePostTool = "post-tool" + HookNamePreCompact = "pre-compact" + HookNameSubagentStart = "subagent-start" + HookNameSubagentStop = "subagent-stop" ) // HooksFileName is the hooks file used by Cursor. @@ -44,8 +45,9 @@ func (c *CursorAgent) GetHookNames() []string { HookNameSessionEnd, HookNameBeforeSubmitPrompt, HookNameStop, - HookNamePreTool, - HookNamePostTool, + HookNamePreCompact, + HookNameSubagentStart, + HookNameSubagentStop, } } @@ -78,6 +80,9 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { return 0, fmt.Errorf("failed to parse hooks in "+HooksFileName+": %w", err) } } + if _, ok := rawFile["version"]; !ok { + rawFile["version"] = json.RawMessage(`1`) + } } else { rawFile = map[string]json.RawMessage{ "version": json.RawMessage(`1`), @@ -89,13 +94,14 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { } // Parse only the hook types we manage - var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preToolUse, postToolUse []CursorHookEntry + var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preCompact, subagentStart, subagentStop []CursorHookEntry parseCursorHookType(rawHooks, "sessionStart", &sessionStart) parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) parseCursorHookType(rawHooks, "stop", &stop) - parseCursorHookType(rawHooks, "preToolUse", &preToolUse) - parseCursorHookType(rawHooks, "postToolUse", &postToolUse) + parseCursorHookType(rawHooks, "preCompact", &preCompact) + parseCursorHookType(rawHooks, "subagentStart", &subagentStart) + parseCursorHookType(rawHooks, "subagentStop", &subagentStop) // If force is true, remove all existing Entire hooks first if force { @@ -103,8 +109,9 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { sessionEnd = removeEntireHooks(sessionEnd) beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) stop = removeEntireHooks(stop) - preToolUse = removeEntireHooks(preToolUse) - postToolUse = removeEntireHooks(postToolUse) + preCompact = removeEntireHooks(preCompact) + subagentStart = removeEntireHooks(subagentStart) + subagentStop = removeEntireHooks(subagentStop) } // Define hook commands @@ -118,9 +125,10 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { sessionStartCmd := cmdPrefix + "session-start" sessionEndCmd := cmdPrefix + "session-end" beforeSubmitPromptCmd := cmdPrefix + "before-submit-prompt" - stopCmd := cmdPrefix + "stop" - preTaskCmd := cmdPrefix + HookNamePreTool - postTaskCmd := cmdPrefix + HookNamePostTool + stopCmd := cmdPrefix + HookNameStop + preCompactCmd := cmdPrefix + HookNamePreCompact + subagentStartCmd := cmdPrefix + HookNameSubagentStart + subagentEndCmd := cmdPrefix + HookNameSubagentStop count := 0 @@ -141,12 +149,16 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { stop = append(stop, CursorHookEntry{Command: stopCmd}) count++ } - if !hookCommandExistsWithMatcher(preToolUse, "Subagent", preTaskCmd) { - preToolUse = append(preToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Subagent"}) + if !hookCommandExists(preCompact, preCompactCmd) { + preCompact = append(preCompact, CursorHookEntry{Command: preCompactCmd}) + count++ + } + if !hookCommandExists(subagentStart, subagentStartCmd) { + subagentStart = append(subagentStart, CursorHookEntry{Command: subagentStartCmd}) count++ } - if !hookCommandExistsWithMatcher(postToolUse, "Subagent", postTaskCmd) { - postToolUse = append(postToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Subagent"}) + if !hookCommandExists(subagentStop, subagentEndCmd) { + subagentStop = append(subagentStop, CursorHookEntry{Command: subagentEndCmd}) count++ } @@ -159,8 +171,9 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd) marshalCursorHookType(rawHooks, "beforeSubmitPrompt", beforeSubmitPrompt) marshalCursorHookType(rawHooks, "stop", stop) - marshalCursorHookType(rawHooks, "preToolUse", preToolUse) - marshalCursorHookType(rawHooks, "postToolUse", postToolUse) + marshalCursorHookType(rawHooks, "preCompact", preCompact) + marshalCursorHookType(rawHooks, "subagentStart", subagentStart) + marshalCursorHookType(rawHooks, "subagentStop", subagentStop) // Marshal hooks and update raw file hooksJSON, err := json.Marshal(rawHooks) @@ -215,29 +228,32 @@ func (c *CursorAgent) UninstallHooks() error { } // Parse only the hook types we manage - var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preToolUse, postToolUse []CursorHookEntry + var sessionStart, sessionEnd, beforeSubmitPrompt, stop, preCompact, subagentStart, subagentStop []CursorHookEntry parseCursorHookType(rawHooks, "sessionStart", &sessionStart) parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) parseCursorHookType(rawHooks, "stop", &stop) - parseCursorHookType(rawHooks, "preToolUse", &preToolUse) - parseCursorHookType(rawHooks, "postToolUse", &postToolUse) + parseCursorHookType(rawHooks, "preCompact", &preCompact) + parseCursorHookType(rawHooks, "subagentStart", &subagentStart) + parseCursorHookType(rawHooks, "subagentStop", &subagentStop) // Remove Entire hooks from all hook types sessionStart = removeEntireHooks(sessionStart) sessionEnd = removeEntireHooks(sessionEnd) beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) stop = removeEntireHooks(stop) - preToolUse = removeEntireHooks(preToolUse) - postToolUse = removeEntireHooks(postToolUse) + preCompact = removeEntireHooks(preCompact) + subagentStart = removeEntireHooks(subagentStart) + subagentStop = removeEntireHooks(subagentStop) // Marshal modified hook types back into rawHooks marshalCursorHookType(rawHooks, "sessionStart", sessionStart) marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd) marshalCursorHookType(rawHooks, "beforeSubmitPrompt", beforeSubmitPrompt) marshalCursorHookType(rawHooks, "stop", stop) - marshalCursorHookType(rawHooks, "preToolUse", preToolUse) - marshalCursorHookType(rawHooks, "postToolUse", postToolUse) + marshalCursorHookType(rawHooks, "preCompact", preCompact) + marshalCursorHookType(rawHooks, "subagentStart", subagentStart) + marshalCursorHookType(rawHooks, "subagentStop", subagentStop) // Marshal hooks back (preserving unknown hook types) if len(rawHooks) > 0 { @@ -283,8 +299,9 @@ func (c *CursorAgent) AreHooksInstalled() bool { hasEntireHook(hooksFile.Hooks.SessionEnd) || hasEntireHook(hooksFile.Hooks.BeforeSubmitPrompt) || hasEntireHook(hooksFile.Hooks.Stop) || - hasEntireHook(hooksFile.Hooks.PreToolUse) || - hasEntireHook(hooksFile.Hooks.PostToolUse) + hasEntireHook(hooksFile.Hooks.PreCompact) || + hasEntireHook(hooksFile.Hooks.SubagentStart) || + hasEntireHook(hooksFile.Hooks.SubagentStop) } // GetSupportedHooks returns the hook types Cursor supports. @@ -333,15 +350,6 @@ func hookCommandExists(entries []CursorHookEntry, command string) bool { return false } -func hookCommandExistsWithMatcher(entries []CursorHookEntry, matcher, command string) bool { - for _, entry := range entries { - if entry.Matcher == matcher && entry.Command == command { - return true - } - } - return false -} - func isEntireHook(command string) bool { for _, prefix := range entireHookPrefixes { if strings.HasPrefix(command, prefix) { diff --git a/cmd/entire/cli/agent/cursor/hooks_test.go b/cmd/entire/cli/agent/cursor/hooks_test.go index 3516aa70fe..de34f82a61 100644 --- a/cmd/entire/cli/agent/cursor/hooks_test.go +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -36,13 +36,13 @@ func TestInstallHooks_FreshInstall(t *testing.T) { if len(hooksFile.Hooks.Stop) != 1 { t.Errorf("Stop hooks = %d, want 1", len(hooksFile.Hooks.Stop)) } - // PreToolUse has 1 (Task) - if len(hooksFile.Hooks.PreToolUse) != 1 { - t.Errorf("PreToolUse hooks = %d, want 1", len(hooksFile.Hooks.PreToolUse)) + // SubagentStart has 1 (Task) + if len(hooksFile.Hooks.SubagentStart) != 1 { + t.Errorf("SubagentStart hooks = %d, want 1", len(hooksFile.Hooks.SubagentStart)) } - // PostToolUse has 1 (Task) - if len(hooksFile.Hooks.PostToolUse) != 1 { - t.Errorf("PostToolUse hooks = %d, want 1", len(hooksFile.Hooks.PostToolUse)) + // SubagentStop has 1 (Task) + if len(hooksFile.Hooks.SubagentStop) != 1 { + t.Errorf("SubagentStop hooks = %d, want 1", len(hooksFile.Hooks.SubagentStop)) } // Verify version @@ -56,8 +56,8 @@ func TestInstallHooks_FreshInstall(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.BeforeSubmitPrompt, "entire hooks cursor before-submit-prompt") // Verify matchers on tool hooks - assertEntryWithMatcher(t, hooksFile.Hooks.PreToolUse, "Subagent", "entire hooks cursor pre-tool") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Subagent", "entire hooks cursor post-tool") + assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStart, "Subagent", "entire hooks cursor pre-tool") + assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Subagent", "entire hooks cursor post-tool") } func TestInstallHooks_Idempotent(t *testing.T) { @@ -195,7 +195,7 @@ func TestInstallHooks_PreservesExistingHooks(t *testing.T) { Stop: []CursorHookEntry{ {Command: "echo user hook"}, }, - PostToolUse: []CursorHookEntry{ + SubagentStop: []CursorHookEntry{ {Command: "echo file written", Matcher: "Write"}, }, }, @@ -216,12 +216,12 @@ func TestInstallHooks_PreservesExistingHooks(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.Stop, "echo user hook") assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") - // PostToolUse should have user Write hook + Task hook + TodoWrite hook - if len(hooksFile.Hooks.PostToolUse) != 2 { - t.Errorf("PostToolUse hooks = %d, want 2 (user Write + Task)", len(hooksFile.Hooks.PostToolUse)) + // SubagentStop should have user Write hook + Task hook + TodoWrite hook + if len(hooksFile.Hooks.SubagentStop) != 2 { + t.Errorf("SubagentStop hooks = %d, want 2 (user Write + Task)", len(hooksFile.Hooks.SubagentStop)) } - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Write", "echo file written") - assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Subagent", "entire hooks cursor post-tool") + assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Write", "echo file written") + assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Subagent", "entire hooks cursor post-tool") } func TestInstallHooks_LocalDev(t *testing.T) { diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index c2656f3f98..b1b6a83f15 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -27,10 +27,12 @@ func (c *CursorAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.E return c.parseTurnEnd(stdin) case HookNameSessionEnd: return c.parseSessionEnd(stdin) - case HookNamePreTool: - return c.parsePreToolUse(stdin) - case HookNamePostTool: - return c.parsePostToolUse(stdin) + case HookNamePreCompact: + return c.parsePreCompact(stdin) + case HookNameSubagentStart: + return c.parseSubagentStart(stdin) + case HookNameSubagentStop: + return c.parseSubagentStop(stdin) default: return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action } @@ -104,34 +106,55 @@ func (c *CursorAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { }, nil } -func (c *CursorAgent) parsePreToolUse(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[preToolUseHookInputRaw](stdin) +func (c *CursorAgent) parsePreCompact(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[preCompactHookInputRaw](stdin) if err != nil { return nil, err } return &agent.Event{ - Type: agent.SubagentStart, + Type: agent.Compaction, SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, - ToolUseID: raw.ToolUseID, - ToolInput: raw.ToolInput, Timestamp: time.Now(), }, nil } -func (c *CursorAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[postToolUseHookInputRaw](stdin) +func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[subagentStartHookInputRaw](stdin) if err != nil { return nil, err } + if raw.Task == "" { + return nil, nil + } + return &agent.Event{ + Type: agent.SubagentStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.SubagentId, + //TODO ToolInput: raw.Task, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[subagentStopHookInputRaw](stdin) + if err != nil { + return nil, err + } + if raw.Task == "" { + return nil, nil + } event := &agent.Event{ Type: agent.SubagentEnd, SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, - ToolUseID: raw.ToolUseID, - ToolInput: raw.ToolInput, - Timestamp: time.Now(), + ToolUseID: raw.SubagentId, + //TODO ToolInput: raw.Task, + Timestamp: time.Now(), + } + if raw.SubagentId != "" { + event.SubagentID = raw.SubagentId } - // TODO "tool_output": "{\"status\":\"success\",\"agentId\":\"3211cc34-8d8f-42de-9dcf-c19625b17566\",\"durationMs\":7901,\"messageCount\":1,\"toolCallCount\":1}", return event, nil } diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index ccec9badab..4eeed10773 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -122,7 +122,7 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { t.Fatalf("failed to marshal test input: %v", marshalErr) } - event, err := ag.ParseHookEvent(HookNamePreTool, strings.NewReader(string(inputBytes))) + event, err := ag.ParseHookEvent(HookNameSubagentStart, strings.NewReader(string(inputBytes))) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -159,7 +159,7 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) { t.Fatalf("failed to marshal test input: %v", marshalErr) } - event, err := ag.ParseHookEvent(HookNamePostTool, strings.NewReader(string(inputBytes))) + event, err := ag.ParseHookEvent(HookNameSubagentStop, strings.NewReader(string(inputBytes))) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -241,7 +241,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Parallel() input := `{"conversation_id": "conv-sub", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t1", "tool_input": {}}` - event, err := ag.ParseHookEvent(HookNamePreTool, strings.NewReader(input)) + event, err := ag.ParseHookEvent(HookNameSubagentStart, strings.NewReader(input)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -254,7 +254,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Parallel() input := `{"conversation_id": "conv-end", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}` - event, err := ag.ParseHookEvent(HookNamePostTool, strings.NewReader(input)) + event, err := ag.ParseHookEvent(HookNameSubagentStop, strings.NewReader(input)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -310,12 +310,12 @@ func TestParseHookEvent_AllHookTypes(t *testing.T) { inputTemplate: `{"session_id": "s4", "transcript_path": "/t"}`, }, { - hookName: HookNamePreTool, + hookName: HookNameSubagentStart, expectedType: agent.SubagentStart, inputTemplate: `{"session_id": "s5", "transcript_path": "/t", "tool_use_id": "t1", "tool_input": {}}`, }, { - hookName: HookNamePostTool, + hookName: HookNameSubagentStop, expectedType: agent.SubagentEnd, inputTemplate: `{"session_id": "s6", "transcript_path": "/t", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}`, }, diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index 16563edbb2..bdeddc9104 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -19,8 +19,9 @@ type CursorHooks struct { SessionEnd []CursorHookEntry `json:"sessionEnd,omitempty"` BeforeSubmitPrompt []CursorHookEntry `json:"beforeSubmitPrompt,omitempty"` Stop []CursorHookEntry `json:"stop,omitempty"` - PreToolUse []CursorHookEntry `json:"preToolUse,omitempty"` - PostToolUse []CursorHookEntry `json:"postToolUse,omitempty"` + PreCompact []CursorHookEntry `json:"preCompact,omitempty"` + SubagentStart []CursorHookEntry `json:"subagentStart,omitempty"` + SubagentStop []CursorHookEntry `json:"subagentStop,omitempty"` } // CursorHookEntry represents a single hook command. @@ -62,8 +63,8 @@ type beforeSubmitPromptInputRaw struct { Prompt string `json:"prompt"` } -// preToolUseHookInputRaw is the JSON structure from PreToolUse[Task] hook. -type preToolUseHookInputRaw struct { +// preCompactHookInputRaw is the JSON structure from PreCompact hook. +type preCompactHookInputRaw struct { // common ConversationID string `json:"conversation_id"` GenerationID string `json:"generation_id"` @@ -75,13 +76,17 @@ type preToolUseHookInputRaw struct { TranscriptPath string `json:"transcript_path"` // hook specific - ToolUseID string `json:"tool_use_id"` - ToolInput json.RawMessage `json:"tool_input"` - ToolName string `json:"tool_name"` + Trigger string `json:"trigger"` // "auto" | "manual", + ContextUsagePercent json.Number `json:"context_usage_percent"` //: 85, + ContextTokens json.Number `json:"context_tokens"` // 120000, + ContextWindowSize json.Number `json:"context_window_size"` //: 128000, + MessageCount json.Number `json:"message_count"` // 45, + MessagesToCompact json.Number `json:"messages_to_compact"` //: 30, + IsFirstCompaction bool `json:"is_first_compaction"` // true | false } -// postToolUseHookInputRaw is the JSON structure from PostToolUse hooks. -type postToolUseHookInputRaw struct { +// subagentStartHookInputRaw is the JSON structure from SubagentStart[Task] hook. +type subagentStartHookInputRaw struct { // common ConversationID string `json:"conversation_id"` GenerationID string `json:"generation_id"` @@ -93,9 +98,39 @@ type postToolUseHookInputRaw struct { TranscriptPath string `json:"transcript_path"` // hook specific - ToolName string `json:"tool_name"` - ToolInput json.RawMessage `json:"tool_input"` - ToolOutput string `json:"tool_output"` - ToolUseID string `json:"tool_use_id"` - Cwd string `json:"cwd"` + SubagentId string `json:"subagent_id"` + SubagentType string `json:"subagent_type"` + SubagentModel string `json:"subagent_model"` + Task string `json:"task"` + ParentConversationID string `json:"parent_conversation_id"` + ToolCallID string `json:"tool_call_id"` + IsParallelWorker bool `json:"is_parallel_worker"` +} + +// subagentStopHookInputRaw is the JSON structure from SubagentStop hooks. +type subagentStopHookInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` + + // hook specific + SubagentId string `json:"subagent_id"` + SubagentType string `json:"subagent_type"` + Status string `json:"status"` + Duration json.Number `json:"duration_ms"` + Summary string `json:"summary"` + ParentConversationID string `json:"parent_conversation_id"` + MessageCount json.Number `json:"message_count"` + ToolCallCount json.Number `json:"tool_call_count"` + ModifiedFiles []string `json:"modified_files"` + LoopCount json.Number `json:"loop_count"` + Task string `json:"task"` + Description string `json:"description"` + AgentTranscriptPath string `json:"agent_transcript_path"` } From a0c9056dc5a4e67f06268bab294e8b9d628fcb25 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 12:52:15 +0100 Subject: [PATCH 12/21] cursor: fix tests Entire-Checkpoint: 6ad0ff14a0da --- cmd/entire/cli/agent/cursor/hooks_test.go | 34 +++++++++---------- cmd/entire/cli/agent/cursor/lifecycle_test.go | 28 +++++++-------- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/hooks_test.go b/cmd/entire/cli/agent/cursor/hooks_test.go index de34f82a61..22a6d295e1 100644 --- a/cmd/entire/cli/agent/cursor/hooks_test.go +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -17,8 +17,8 @@ func TestInstallHooks_FreshInstall(t *testing.T) { t.Fatalf("InstallHooks() error = %v", err) } - if count != 6 { - t.Errorf("InstallHooks() count = %d, want 6", count) + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) } hooksFile := readHooksFile(t, tempDir) @@ -36,11 +36,12 @@ func TestInstallHooks_FreshInstall(t *testing.T) { if len(hooksFile.Hooks.Stop) != 1 { t.Errorf("Stop hooks = %d, want 1", len(hooksFile.Hooks.Stop)) } - // SubagentStart has 1 (Task) + if len(hooksFile.Hooks.PreCompact) != 1 { + t.Errorf("PreCompact hooks = %d, want 1", len(hooksFile.Hooks.PreCompact)) + } if len(hooksFile.Hooks.SubagentStart) != 1 { t.Errorf("SubagentStart hooks = %d, want 1", len(hooksFile.Hooks.SubagentStart)) } - // SubagentStop has 1 (Task) if len(hooksFile.Hooks.SubagentStop) != 1 { t.Errorf("SubagentStop hooks = %d, want 1", len(hooksFile.Hooks.SubagentStop)) } @@ -54,10 +55,9 @@ func TestInstallHooks_FreshInstall(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") assertEntryCommand(t, hooksFile.Hooks.SessionStart, "entire hooks cursor session-start") assertEntryCommand(t, hooksFile.Hooks.BeforeSubmitPrompt, "entire hooks cursor before-submit-prompt") - - // Verify matchers on tool hooks - assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStart, "Subagent", "entire hooks cursor pre-tool") - assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Subagent", "entire hooks cursor post-tool") + assertEntryCommand(t, hooksFile.Hooks.PreCompact, "entire hooks cursor pre-compact") + assertEntryCommand(t, hooksFile.Hooks.SubagentStart, "entire hooks cursor subagent-start") + assertEntryCommand(t, hooksFile.Hooks.SubagentStop, "entire hooks cursor subagent-stop") } func TestInstallHooks_Idempotent(t *testing.T) { @@ -71,8 +71,8 @@ func TestInstallHooks_Idempotent(t *testing.T) { if err != nil { t.Fatalf("first InstallHooks() error = %v", err) } - if count1 != 6 { - t.Errorf("first InstallHooks() count = %d, want 6", count1) + if count1 != 7 { + t.Errorf("first InstallHooks() count = %d, want 7", count1) } // Second install @@ -173,8 +173,8 @@ func TestInstallHooks_ForceReinstall(t *testing.T) { if err != nil { t.Fatalf("force InstallHooks() error = %v", err) } - if count != 6 { - t.Errorf("force InstallHooks() count = %d, want 6", count) + if count != 7 { + t.Errorf("force InstallHooks() count = %d, want 7", count) } // Verify no duplicates @@ -216,12 +216,12 @@ func TestInstallHooks_PreservesExistingHooks(t *testing.T) { assertEntryCommand(t, hooksFile.Hooks.Stop, "echo user hook") assertEntryCommand(t, hooksFile.Hooks.Stop, "entire hooks cursor stop") - // SubagentStop should have user Write hook + Task hook + TodoWrite hook + // SubagentStop should have user Write hook + Entire hook if len(hooksFile.Hooks.SubagentStop) != 2 { - t.Errorf("SubagentStop hooks = %d, want 2 (user Write + Task)", len(hooksFile.Hooks.SubagentStop)) + t.Errorf("SubagentStop hooks = %d, want 2 (user Write + Entire)", len(hooksFile.Hooks.SubagentStop)) } assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Write", "echo file written") - assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Subagent", "entire hooks cursor post-tool") + assertEntryCommand(t, hooksFile.Hooks.SubagentStop, "entire hooks cursor subagent-stop") } func TestInstallHooks_LocalDev(t *testing.T) { @@ -265,8 +265,8 @@ func TestInstallHooks_PreservesUnknownFields(t *testing.T) { if err != nil { t.Fatalf("InstallHooks() error = %v", err) } - if count != 6 { - t.Errorf("InstallHooks() count = %d, want 6", count) + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) } // Read the raw JSON to verify unknown fields are preserved diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index 4eeed10773..07c6464894 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -110,12 +110,11 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { t.Parallel() ag := &CursorAgent{} - toolInput := json.RawMessage(`{"description": "test task", "prompt": "do something"}`) inputData := map[string]any{ "conversation_id": "main-session", "transcript_path": "/tmp/main.jsonl", - "tool_use_id": "toolu_abc123", - "tool_input": toolInput, + "subagent_id": "sub_abc123", + "task": "do something", } inputBytes, marshalErr := json.Marshal(inputData) if marshalErr != nil { @@ -136,11 +135,8 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { if event.SessionID != "main-session" { t.Errorf("expected session_id 'main-session', got %q", event.SessionID) } - if event.ToolUseID != "toolu_abc123" { - t.Errorf("expected tool_use_id 'toolu_abc123', got %q", event.ToolUseID) - } - if event.ToolInput == nil { - t.Error("expected tool_input to be set") + if event.ToolUseID != "sub_abc123" { + t.Errorf("expected tool_use_id 'sub_abc123', got %q", event.ToolUseID) } } @@ -151,8 +147,8 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) { inputData := map[string]any{ "conversation_id": "main-session", "transcript_path": "/tmp/main.jsonl", - "tool_use_id": "toolu_xyz789", - "tool_input": json.RawMessage(`{"prompt": "task done"}`), + "subagent_id": "sub_xyz789", + "task": "task done", } inputBytes, marshalErr := json.Marshal(inputData) if marshalErr != nil { @@ -170,8 +166,8 @@ func TestParseHookEvent_SubagentEnd(t *testing.T) { if event.Type != agent.SubagentEnd { t.Errorf("expected event type %v, got %v", agent.SubagentEnd, event.Type) } - if event.ToolUseID != "toolu_xyz789" { - t.Errorf("expected tool_use_id 'toolu_xyz789', got %q", event.ToolUseID) + if event.ToolUseID != "sub_xyz789" { + t.Errorf("expected tool_use_id 'sub_xyz789', got %q", event.ToolUseID) } } @@ -239,7 +235,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Run("conversation_id fallback for subagent start", func(t *testing.T) { t.Parallel() - input := `{"conversation_id": "conv-sub", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t1", "tool_input": {}}` + input := `{"conversation_id": "conv-sub", "transcript_path": "/tmp/t.jsonl", "subagent_id": "s1", "task": "do something"}` event, err := ag.ParseHookEvent(HookNameSubagentStart, strings.NewReader(input)) if err != nil { @@ -252,7 +248,7 @@ func TestParseHookEvent_ConversationIDFallback(t *testing.T) { t.Run("conversation_id fallback for subagent end", func(t *testing.T) { t.Parallel() - input := `{"conversation_id": "conv-end", "transcript_path": "/tmp/t.jsonl", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}` + input := `{"conversation_id": "conv-end", "transcript_path": "/tmp/t.jsonl", "subagent_id": "s2", "task": "do something"}` event, err := ag.ParseHookEvent(HookNameSubagentStop, strings.NewReader(input)) if err != nil { @@ -312,12 +308,12 @@ func TestParseHookEvent_AllHookTypes(t *testing.T) { { hookName: HookNameSubagentStart, expectedType: agent.SubagentStart, - inputTemplate: `{"session_id": "s5", "transcript_path": "/t", "tool_use_id": "t1", "tool_input": {}}`, + inputTemplate: `{"conversation_id": "s5", "transcript_path": "/t", "subagent_id": "sub1", "task": "do something"}`, }, { hookName: HookNameSubagentStop, expectedType: agent.SubagentEnd, - inputTemplate: `{"session_id": "s6", "transcript_path": "/t", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}`, + inputTemplate: `{"conversation_id": "s6", "transcript_path": "/t", "subagent_id": "sub2", "task": "do something"}`, }, } From 3fc9e466f311b65562fecfcd47b92013287ba565 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 13:03:28 +0100 Subject: [PATCH 13/21] cursor: fix lint --- cmd/entire/cli/agent/cursor/lifecycle.go | 16 ++++++++-------- cmd/entire/cli/agent/cursor/types.go | 10 +++++----- .../cli/strategy/manual_commit_condensation.go | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index b1b6a83f15..9d5adcd311 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -125,14 +125,14 @@ func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) return nil, err } if raw.Task == "" { - return nil, nil + return nil, nil //nolint:nilnil // nil event = no lifecycle action } return &agent.Event{ Type: agent.SubagentStart, SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, - ToolUseID: raw.SubagentId, - //TODO ToolInput: raw.Task, + ToolUseID: raw.SubagentID, + // TODO ToolInput: raw.Task, Timestamp: time.Now(), }, nil } @@ -143,18 +143,18 @@ func (c *CursorAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { return nil, err } if raw.Task == "" { - return nil, nil + return nil, nil //nolint:nilnil // nil event = no lifecycle action } event := &agent.Event{ Type: agent.SubagentEnd, SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, - ToolUseID: raw.SubagentId, - //TODO ToolInput: raw.Task, + ToolUseID: raw.SubagentID, + // TODO ToolInput: raw.Task, Timestamp: time.Now(), } - if raw.SubagentId != "" { - event.SubagentID = raw.SubagentId + if raw.SubagentID != "" { + event.SubagentID = raw.SubagentID } return event, nil } diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index bdeddc9104..5148233a2e 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -77,11 +77,11 @@ type preCompactHookInputRaw struct { // hook specific Trigger string `json:"trigger"` // "auto" | "manual", - ContextUsagePercent json.Number `json:"context_usage_percent"` //: 85, + ContextUsagePercent json.Number `json:"context_usage_percent"` // : 85, ContextTokens json.Number `json:"context_tokens"` // 120000, - ContextWindowSize json.Number `json:"context_window_size"` //: 128000, + ContextWindowSize json.Number `json:"context_window_size"` // : 128000, MessageCount json.Number `json:"message_count"` // 45, - MessagesToCompact json.Number `json:"messages_to_compact"` //: 30, + MessagesToCompact json.Number `json:"messages_to_compact"` // : 30, IsFirstCompaction bool `json:"is_first_compaction"` // true | false } @@ -98,7 +98,7 @@ type subagentStartHookInputRaw struct { TranscriptPath string `json:"transcript_path"` // hook specific - SubagentId string `json:"subagent_id"` + SubagentID string `json:"subagent_id"` SubagentType string `json:"subagent_type"` SubagentModel string `json:"subagent_model"` Task string `json:"task"` @@ -120,7 +120,7 @@ type subagentStopHookInputRaw struct { TranscriptPath string `json:"transcript_path"` // hook specific - SubagentId string `json:"subagent_id"` + SubagentID string `json:"subagent_id"` SubagentType string `json:"subagent_type"` Status string `json:"status"` Duration json.Number `json:"duration_ms"` diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 00aad7b01a..52c2f5b8f3 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -221,7 +221,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI slog.String("error", sliceErr.Error())) } scopedTranscript = scoped - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart) } if len(scopedTranscript) > 0 { From 0597c5308ba0f47447aa3145da941667dfb215fd Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 14:09:26 +0100 Subject: [PATCH 14/21] cursor: return nil token usage and add calculateTokenUsage tests Cursor transcripts don't include token usage data, so return nil instead of an empty struct. Adds tests covering Cursor nil return, empty data, Claude Code basic parsing, and offset slicing. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: a2ac14809312 --- .../strategy/manual_commit_condensation.go | 5 ++ .../manual_commit_condensation_test.go | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 52c2f5b8f3..2416868e24 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -587,6 +587,11 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int return &agent.TokenUsage{} } + // No token usage information from Cursor yet + if agentType == agent.AgentTypeCursor { + return nil + } + // OpenCode uses JSONL with token info on assistant messages (different schema from Claude Code) if agentType == agent.AgentTypeOpenCode { return opencode.CalculateTokenUsageFromBytes(data, startOffset) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index ad150973b2..c5209aa4fb 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -4,8 +4,83 @@ import ( "strings" "testing" "unicode/utf8" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) +func TestCalculateTokenUsage_CursorReturnsNil(t *testing.T) { + t.Parallel() + + // Cursor transcripts don't contain token usage data, so calculateTokenUsage + // should return nil (not an empty struct) to signal "no data available". + transcript := []byte(`{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}`) + + result := calculateTokenUsage(agent.AgentTypeCursor, transcript, 0) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor) = %+v, want nil", result) + } +} + +func TestCalculateTokenUsage_EmptyData(t *testing.T) { + t.Parallel() + + result := calculateTokenUsage(agent.AgentTypeClaudeCode, nil, 0) + if result == nil { + t.Fatal("calculateTokenUsage(empty) = nil, want non-nil empty struct") + } + if result.InputTokens != 0 || result.OutputTokens != 0 { + t.Errorf("expected zero tokens for empty data, got %+v", result) + } +} + +func TestCalculateTokenUsage_ClaudeCodeBasic(t *testing.T) { + t.Parallel() + + // Claude Code JSONL: "usage" with "id" lives inside the "message" JSON object + lines := []string{ + `{"type":"human","uuid":"u1","message":{"content":"hello"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":10,"output_tokens":5}}}`, + } + data := []byte(strings.Join(lines, "\n") + "\n") + + result := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 0) + if result == nil { + t.Fatal("calculateTokenUsage(ClaudeCode) = nil, want non-nil") + } + if result.OutputTokens != 5 { + t.Errorf("OutputTokens = %d, want 5", result.OutputTokens) + } + if result.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", result.APICallCount) + } +} + +func TestCalculateTokenUsage_ClaudeCodeWithOffset(t *testing.T) { + t.Parallel() + + // 4-line transcript; start at offset 2 to only count the second pair + lines := []string{ + `{"type":"human","uuid":"u1","message":{"content":"first"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":10,"output_tokens":5}}}`, + `{"type":"human","uuid":"u3","message":{"content":"second"}}`, + `{"type":"assistant","uuid":"u4","message":{"id":"msg_002","usage":{"input_tokens":20,"output_tokens":15}}}`, + } + data := []byte(strings.Join(lines, "\n") + "\n") + + full := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 0) + sliced := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 2) + + if full == nil || sliced == nil { + t.Fatal("expected non-nil results") + } + if full.OutputTokens != 20 { + t.Errorf("full OutputTokens = %d, want 20", full.OutputTokens) + } + if sliced.OutputTokens != 15 { + t.Errorf("sliced OutputTokens = %d, want 15", sliced.OutputTokens) + } +} + func TestGenerateContextFromPrompts_CJKTruncation(t *testing.T) { t.Parallel() From b57881d07b37020e8eb21591ac8a1bf434e0d26f Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 15:12:48 +0100 Subject: [PATCH 15/21] cursor: extract subagent type and task for state --- cmd/entire/cli/agent/cursor/hooks.go | 6 ++--- cmd/entire/cli/agent/cursor/lifecycle.go | 30 ++++++++++++------------ cmd/entire/cli/agent/event.go | 6 +++++ cmd/entire/cli/lifecycle.go | 10 ++++---- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go index 204832d122..c080852f2c 100644 --- a/cmd/entire/cli/agent/cursor/hooks.go +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -122,9 +122,9 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { cmdPrefix = "entire hooks cursor " } - sessionStartCmd := cmdPrefix + "session-start" - sessionEndCmd := cmdPrefix + "session-end" - beforeSubmitPromptCmd := cmdPrefix + "before-submit-prompt" + sessionStartCmd := cmdPrefix + HookNameSessionStart + sessionEndCmd := cmdPrefix + HookNameSessionEnd + beforeSubmitPromptCmd := cmdPrefix + HookNameBeforeSubmitPrompt stopCmd := cmdPrefix + HookNameStop preCompactCmd := cmdPrefix + HookNamePreCompact subagentStartCmd := cmdPrefix + HookNameSubagentStart diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 9d5adcd311..3070751d67 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -128,12 +128,13 @@ func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) return nil, nil //nolint:nilnil // nil event = no lifecycle action } return &agent.Event{ - Type: agent.SubagentStart, - SessionID: raw.ConversationID, - SessionRef: raw.TranscriptPath, - ToolUseID: raw.SubagentID, - // TODO ToolInput: raw.Task, - Timestamp: time.Now(), + Type: agent.SubagentStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.SubagentID, + SubagentType: raw.SubagentType, + TaskDescription: raw.Task, + Timestamp: time.Now(), }, nil } @@ -146,15 +147,14 @@ func (c *CursorAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { return nil, nil //nolint:nilnil // nil event = no lifecycle action } event := &agent.Event{ - Type: agent.SubagentEnd, - SessionID: raw.ConversationID, - SessionRef: raw.TranscriptPath, - ToolUseID: raw.SubagentID, - // TODO ToolInput: raw.Task, - Timestamp: time.Now(), - } - if raw.SubagentID != "" { - event.SubagentID = raw.SubagentID + Type: agent.SubagentEnd, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.SubagentID, + SubagentType: raw.SubagentType, + TaskDescription: raw.Task, + Timestamp: time.Now(), + SubagentID: raw.SubagentID, } return event, nil } diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 67fde698d4..84481a3711 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -87,8 +87,14 @@ type Event struct { SubagentID string // ToolInput is the raw tool input JSON (for subagent type/description extraction). + // Used when either SubagentType or TaskDescription is not available ToolInput json.RawMessage + // SubagentType is the kind of subagent (for SubagentStart/SubagentEnd events). + // Used with TaskDescription instead of ToolInput + SubagentType string + TaskDescription string + // ResponseMessage is an optional message to display to the user via the agent. ResponseMessage string diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index b68ef9a3c3..363db5115a 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -501,8 +501,10 @@ func handleLifecycleSubagentEnd(ag agent.Agent, event *agent.Event) error { slog.String("subagent_id", event.SubagentID), ) - // Extract subagent type and description from tool input - subagentType, taskDescription := ParseSubagentTypeAndDescription(event.ToolInput) + if event.SubagentType == "" && event.TaskDescription == "" { + // Extract subagent type and description from tool input + event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput) + } // Determine subagent transcript path transcriptDir := filepath.Dir(event.SessionRef) @@ -603,8 +605,8 @@ func handleLifecycleSubagentEnd(ag agent.Agent, event *agent.Event) error { CheckpointUUID: checkpointUUID, AuthorName: author.Name, AuthorEmail: author.Email, - SubagentType: subagentType, - TaskDescription: taskDescription, + SubagentType: event.SubagentType, + TaskDescription: event.TaskDescription, AgentType: agentType, } From c79e8dfbf881448e771f4f0cadb1701c0e39f147 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 15:45:28 +0100 Subject: [PATCH 16/21] cursor: pass subagent id for start event --- cmd/entire/cli/agent/cursor/lifecycle.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 3070751d67..70fe31f16b 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -131,6 +131,7 @@ func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) Type: agent.SubagentStart, SessionID: raw.ConversationID, SessionRef: raw.TranscriptPath, + SubagentID: raw.SubagentID, ToolUseID: raw.SubagentID, SubagentType: raw.SubagentType, TaskDescription: raw.Task, From f538007dd3aefa91b816dda57c1bae0f57ab28b8 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Tue, 24 Feb 2026 16:05:22 +0100 Subject: [PATCH 17/21] cursor: review comments --- cmd/entire/cli/agent/event.go | 3 ++- cmd/entire/cli/strategy/manual_commit_condensation.go | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 84481a3711..30426a87c6 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -87,7 +87,8 @@ type Event struct { SubagentID string // ToolInput is the raw tool input JSON (for subagent type/description extraction). - // Used when either SubagentType or TaskDescription is not available + // Used when both SubagentType and TaskDescription are empty (agents that don't provide + // these fields directly parse them from ToolInput). ToolInput json.RawMessage // SubagentType is the kind of subagent (for SubagentStart/SubagentEnd events). diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 2416868e24..03595ee36f 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -583,15 +583,15 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { // where the current checkpoint began, allowing calculation for only the portion // of the transcript since the last checkpoint. func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int) *agent.TokenUsage { - if len(data) == 0 { - return &agent.TokenUsage{} - } - // No token usage information from Cursor yet if agentType == agent.AgentTypeCursor { return nil } + if len(data) == 0 { + return &agent.TokenUsage{} + } + // OpenCode uses JSONL with token info on assistant messages (different schema from Claude Code) if agentType == agent.AgentTypeOpenCode { return opencode.CalculateTokenUsageFromBytes(data, startOffset) From 3f4e0f07bb960e57f2d9c9ee5c47dc53d67534c7 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Wed, 25 Feb 2026 09:37:55 +0100 Subject: [PATCH 18/21] cursor: use worktreeRoot() --- cmd/entire/cli/agent/cursor/cursor.go | 6 +++--- cmd/entire/cli/agent/cursor/hooks.go | 27 +++++++++++------------- cmd/entire/cli/agent/cursor/lifecycle.go | 6 ------ 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 3f5439c838..625063cba5 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -47,12 +47,12 @@ func (c *CursorAgent) IsPreview() bool { return true } // DetectPresence checks if Cursor is configured in the repository. func (c *CursorAgent) DetectPresence() (bool, error) { - repoRoot, err := paths.RepoRoot() + worktreeRoot, err := paths.WorktreeRoot() if err != nil { - repoRoot = "." + worktreeRoot = "." } - cursorDir := filepath.Join(repoRoot, ".cursor") + cursorDir := filepath.Join(worktreeRoot, ".cursor") if _, err := os.Stat(cursorDir); err == nil { return true, nil } diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go index c080852f2c..fb1b4e8912 100644 --- a/cmd/entire/cli/agent/cursor/hooks.go +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -12,7 +12,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// Ensure CursorAgent implements HookSupport and HookHandler +// Ensure CursorAgent implements HookSupport var ( _ agent.HookSupport = (*CursorAgent)(nil) ) @@ -37,9 +37,9 @@ var entireHookPrefixes = []string{ "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go ", } -// GetHookNames returns the hook verbs Cursor supports. +// HookNames returns the hook verbs Cursor supports. // These become subcommands: entire hooks cursor -func (c *CursorAgent) GetHookNames() []string { +func (c *CursorAgent) HookNames() []string { return []string{ HookNameSessionStart, HookNameSessionEnd, @@ -56,15 +56,12 @@ func (c *CursorAgent) GetHookNames() []string { // Returns the number of hooks installed. // Unknown top-level fields and hook types are preserved on round-trip. func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { - repoRoot, err := paths.RepoRoot() + worktreeRoot, err := paths.WorktreeRoot() if err != nil { - repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) - if err != nil { - return 0, fmt.Errorf("failed to get current directory: %w", err) - } + worktreeRoot = "." } - hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) // Use raw maps to preserve unknown fields on round-trip var rawFile map[string]json.RawMessage @@ -202,11 +199,11 @@ func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { // UninstallHooks removes Entire hooks from Cursor HooksFileName. // Unknown top-level fields and hook types are preserved on round-trip. func (c *CursorAgent) UninstallHooks() error { - repoRoot, err := paths.RepoRoot() + worktreeRoot, err := paths.WorktreeRoot() if err != nil { - repoRoot = "." + worktreeRoot = "." } - hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return nil //nolint:nilerr // No hooks file means nothing to uninstall @@ -280,11 +277,11 @@ func (c *CursorAgent) UninstallHooks() error { // AreHooksInstalled checks if Entire hooks are installed. func (c *CursorAgent) AreHooksInstalled() bool { - repoRoot, err := paths.RepoRoot() + worktreeRoot, err := paths.WorktreeRoot() if err != nil { - repoRoot = "." + worktreeRoot = "." } - hooksPath := filepath.Join(repoRoot, ".cursor", HooksFileName) + hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path if err != nil { return false diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 70fe31f16b..5fcdbdfa58 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -9,12 +9,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) -// HookNames returns the hook verbs Cursor supports. -// Delegates to GetHookNames for backward compatibility. -func (c *CursorAgent) HookNames() []string { - return c.GetHookNames() -} - // ParseHookEvent translates a Cursor hook into a normalized lifecycle Event. // Returns nil if the hook has no lifecycle significance. func (c *CursorAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { From b8332734770ddfd2fd7cb90360d1afc70fde042a Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Wed, 25 Feb 2026 09:49:24 +0100 Subject: [PATCH 19/21] manual_commit_condensation: handle cursor Entire-Checkpoint: dbde5889994f --- .../strategy/manual_commit_condensation.go | 10 ++- .../manual_commit_condensation_test.go | 84 +++++++++++++++++++ cmd/entire/cli/textutil/ide_tags.go | 1 + cmd/entire/cli/textutil/ide_tags_test.go | 10 +++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 21822502d7..e80c08b767 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -646,9 +646,13 @@ func extractUserPromptsFromLines(lines []string) []string { continue } - // Check for user message (supports both "human" and "user" types) - msgType, ok := entry["type"].(string) - if !ok || (msgType != "human" && msgType != "user") { + // Check for user message: + // - Claude Code uses "type": "human" or "type": "user" + // - Cursor uses "role": "user" + msgType, _ := entry["type"].(string) //nolint:errcheck // type assertion on interface{} from JSON + msgRole, _ := entry["role"].(string) //nolint:errcheck // type assertion on interface{} from JSON + isUser := msgType == "human" || msgType == "user" || msgRole == "user" + if !isUser { continue } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index c5209aa4fb..5233425b1c 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -81,6 +81,90 @@ func TestCalculateTokenUsage_ClaudeCodeWithOffset(t *testing.T) { } } +// cursorSampleTranscript is a subset of a real Cursor session transcript. +// Cursor uses "role" (not "type") and wraps user text in tags. +var cursorSampleTranscript = strings.Join([]string{ + `{"role":"user","message":{"content":[{"type":"text","text":"\ncreate a file with contents 'a' and commit, then create another file with contents 'b' and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Creating two files (contents 'a' and 'b') and committing each."}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Both files are tracked and the working tree is clean."}]}}`, + `{"role":"user","message":{"content":[{"type":"text","text":"\ncreate a file with contents 'c' and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Created c.txt with contents c and committed it."}]}}`, + `{"role":"user","message":{"content":[{"type":"text","text":"\nadd a file called bingo and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Created bingo and committed it."}]}}`, +}, "\n") + "\n" + +func TestCountTranscriptItems_Cursor(t *testing.T) { + t.Parallel() + + count := countTranscriptItems(agent.AgentTypeCursor, cursorSampleTranscript) + if count != 7 { + t.Errorf("countTranscriptItems(Cursor) = %d, want 7", count) + } +} + +func TestCountTranscriptItems_CursorEmpty(t *testing.T) { + t.Parallel() + + count := countTranscriptItems(agent.AgentTypeCursor, "") + if count != 0 { + t.Errorf("countTranscriptItems(Cursor, empty) = %d, want 0", count) + } +} + +func TestExtractUserPrompts_Cursor(t *testing.T) { + t.Parallel() + + // Cursor uses "role":"user" instead of "type":"human". extractUserPromptsFromLines + // handles both via the "role" fallback. + prompts := extractUserPrompts(agent.AgentTypeCursor, cursorSampleTranscript) + if len(prompts) != 3 { + t.Fatalf("extractUserPrompts(Cursor) returned %d prompts, want 3", len(prompts)) + } + + if !strings.Contains(prompts[0], "create a file with contents 'a'") { + t.Errorf("prompt[0] = %q, expected to contain file creation request", prompts[0]) + } + if !strings.Contains(prompts[2], "bingo") { + t.Errorf("prompt[2] = %q, expected to contain 'bingo'", prompts[2]) + } + + // Verify tags are stripped + for i, p := range prompts { + if strings.Contains(p, "") || strings.Contains(p, "") { + t.Errorf("prompt[%d] still contains tags: %q", i, p) + } + } +} + +func TestExtractUserPrompts_CursorEmpty(t *testing.T) { + t.Parallel() + + prompts := extractUserPrompts(agent.AgentTypeCursor, "") + if len(prompts) != 0 { + t.Errorf("extractUserPrompts(Cursor, empty) = %v, want empty", prompts) + } +} + +func TestCalculateTokenUsage_CursorRealTranscript(t *testing.T) { + t.Parallel() + + // Even with a multi-line real transcript, Cursor should return nil + result := calculateTokenUsage(agent.AgentTypeCursor, []byte(cursorSampleTranscript), 0) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor, real transcript) = %+v, want nil", result) + } +} + +func TestCalculateTokenUsage_CursorWithOffset(t *testing.T) { + t.Parallel() + + // Offset should not matter — Cursor always returns nil + result := calculateTokenUsage(agent.AgentTypeCursor, []byte(cursorSampleTranscript), 3) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor, offset=3) = %+v, want nil", result) + } +} + func TestGenerateContextFromPrompts_CJKTruncation(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/textutil/ide_tags.go b/cmd/entire/cli/textutil/ide_tags.go index d8f1dec7d5..3549e5139e 100644 --- a/cmd/entire/cli/textutil/ide_tags.go +++ b/cmd/entire/cli/textutil/ide_tags.go @@ -18,6 +18,7 @@ var systemTagRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?s)]*>.*?`), regexp.MustCompile(`(?s)]*>.*?`), regexp.MustCompile(`(?s)]*>.*?`), + regexp.MustCompile(``), // Cursor wraps user text in tags; strip tags but keep content } // StripIDEContextTags removes IDE-injected context tags from prompt text. diff --git a/cmd/entire/cli/textutil/ide_tags_test.go b/cmd/entire/cli/textutil/ide_tags_test.go index d79c92d35e..5c9ffc185f 100644 --- a/cmd/entire/cli/textutil/ide_tags_test.go +++ b/cmd/entire/cli/textutil/ide_tags_test.go @@ -88,6 +88,16 @@ func TestStripIDEContextTags(t *testing.T) { input: "file.goreminder\n\nactual prompt", expected: "actual prompt", }, + { + name: "cursor user_query tags stripped keeping content", + input: "\ncreate a file with contents 'a'\n", + expected: "create a file with contents 'a'", + }, + { + name: "cursor user_query with surrounding text", + input: "\nhello world\n", + expected: "hello world", + }, } for _, tt := range tests { From dc7a54701b7fb8e7b606eb3c8101f2334dec27f9 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Wed, 25 Feb 2026 10:00:56 +0100 Subject: [PATCH 20/21] cursor: clarify session vs conversation id --- cmd/entire/cli/agent/cursor/types.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index 5148233a2e..2c5389c81d 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -34,7 +34,9 @@ type CursorHookEntry struct { } // sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop hooks. -// Cursor may provide session_id or conversation_id (fallback). +// Cursor occasionally provides session_id, so we ignore it. +// Cursor always provides conversation_id. +// session_id and conversation_id are identical and interchangeable. type sessionInfoRaw struct { // common ConversationID string `json:"conversation_id"` From 00898574697831a063cbc37999659b8f1cba72fe Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Wed, 25 Feb 2026 14:25:44 +0100 Subject: [PATCH 21/21] s/Cursor/Cursor IDE --- cmd/entire/cli/agent/agent.go | 4 ++-- cmd/entire/cli/agent/cursor/cursor.go | 4 ++-- cmd/entire/cli/agent/cursor/cursor_test.go | 2 +- cmd/entire/cli/agent/registry.go | 2 +- cmd/entire/cli/checkpoint/checkpoint.go | 4 ++-- cmd/entire/cli/session/state.go | 2 +- cmd/entire/cli/status_test.go | 6 +++--- cmd/entire/cli/strategy/strategy.go | 6 +++--- cmd/entire/cli/trailers/trailers.go | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 94c74f6d74..c1ba4bf470 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -8,7 +8,7 @@ import ( ) // Agent defines the interface for interacting with a coding agent. -// Each agent implementation (Claude Code, Cursor, Aider, etc.) converts its +// Each agent implementation (Claude Code, Cursor IDE, Aider, etc.) converts its // native format to the normalized types defined in this package. // // The interface is organized into three groups: @@ -75,7 +75,7 @@ type Agent interface { } // HookSupport is implemented by agents with lifecycle hooks. -// This optional interface allows agents like Claude Code and Cursor to +// This optional interface allows agents like Claude Code and Cursor IDE to // install and manage hooks that notify Entire of agent events. // // The interface is organized into two groups: diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go index 625063cba5..113d6d3c74 100644 --- a/cmd/entire/cli/agent/cursor/cursor.go +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -18,7 +18,7 @@ func init() { agent.Register(agent.AgentNameCursor, NewCursorAgent) } -// CursorAgent implements the Agent interface for Cursor. +// CursorAgent implements the Agent interface for Cursor IDE. // //nolint:revive // CursorAgent is clearer than Agent in this context type CursorAgent struct{} @@ -138,7 +138,7 @@ func (c *CursorAgent) WriteSession(session *agent.AgentSession) error { // FormatResumeCommand returns an instruction to resume a Cursor session. // Cursor is a GUI IDE, so there's no CLI command to resume a session directly. func (c *CursorAgent) FormatResumeCommand(_ string) string { - return "Open this project in Cursor to continue the session." + return "Open this project in Cursor IDE to continue the session." } // sanitizePathForCursor converts a path to Cursor's project directory format. diff --git a/cmd/entire/cli/agent/cursor/cursor_test.go b/cmd/entire/cli/agent/cursor/cursor_test.go index b536131d8c..5f42a5d8e3 100644 --- a/cmd/entire/cli/agent/cursor/cursor_test.go +++ b/cmd/entire/cli/agent/cursor/cursor_test.go @@ -79,7 +79,7 @@ func TestCursorAgent_FormatResumeCommand(t *testing.T) { t.Parallel() ag := &CursorAgent{} cmd := ag.FormatResumeCommand("some-session-id") - if !strings.Contains(cmd, "Cursor") { + if !strings.Contains(cmd, "Cursor IDE") { t.Errorf("FormatResumeCommand() = %q, expected mention of Cursor", cmd) } } diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 84021153f1..0621703c01 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -100,7 +100,7 @@ const ( // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" - AgentTypeCursor AgentType = "Cursor" + AgentTypeCursor AgentType = "Cursor IDE" AgentTypeGemini AgentType = "Gemini CLI" AgentTypeOpenCode AgentType = "OpenCode" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 187ed1b9ef..66725e3e0b 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -258,7 +258,7 @@ type WriteCommittedOptions struct { // Commit message fields (used for task checkpoints) CommitSubject string // Subject line for the metadata commit (overrides default) - // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") + // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType // TurnID correlates checkpoints from the same agent turn. @@ -370,7 +370,7 @@ type CommittedMetadata struct { CheckpointsCount int `json:"checkpoints_count"` FilesTouched []string `json:"files_touched"` - // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") + // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType `json:"agent,omitempty"` // TurnID correlates checkpoints from the same agent turn. diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 35f287b225..a258f49936 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -113,7 +113,7 @@ type State struct { // sessions that have been condensed at least once. Cleared on new prompt. LastCheckpointID id.CheckpointID `json:"last_checkpoint_id,omitempty"` - // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor") + // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor IDE") AgentType agent.AgentType `json:"agent_type,omitempty"` // Token usage tracking (accumulated across all checkpoints in this session) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index f9b709f68a..10c85ead09 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -447,7 +447,7 @@ func TestWriteActiveSessions(t *testing.T) { WorktreePath: "/Users/test/repo", StartedAt: now.Add(-15 * time.Minute), FirstPrompt: "Add dark mode support for the entire application and all components", - AgentType: agent.AgentType("Cursor"), + AgentType: agent.AgentTypeCursor, TokenUsage: &agent.TokenUsage{ InputTokens: 500, OutputTokens: 300, @@ -481,7 +481,7 @@ func TestWriteActiveSessions(t *testing.T) { if !strings.Contains(output, "Claude Code") { t.Errorf("Expected agent label 'Claude Code', got: %s", output) } - if !strings.Contains(output, "Cursor") { + if !strings.Contains(output, "Cursor IDE") { t.Errorf("Expected agent label 'Cursor', got: %s", output) } // Session without AgentType should show unknown placeholder @@ -516,7 +516,7 @@ func TestWriteActiveSessions(t *testing.T) { // Session started 15m ago with no LastInteractionTime should NOT show "active" in stats for _, line := range lines { - if strings.Contains(line, "Cursor") { + if strings.Contains(line, "Cursor IDE") { if strings.Contains(line, "active") { t.Errorf("Session without LastInteractionTime should not show 'active', got: %s", line) } diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 8d00fc1c9c..27b9cad646 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -74,7 +74,7 @@ type RewindPoint struct { CheckpointID id.CheckpointID // Agent is the human-readable name of the agent that created this checkpoint - // (e.g., "Claude Code", "Cursor") + // (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType // SessionID is the session identifier for this checkpoint. @@ -151,7 +151,7 @@ type StepContext struct { // AuthorEmail is the email to use for commits AuthorEmail string - // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor") + // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor IDE") AgentType agent.AgentType // Transcript position at step/turn start - tracks what was added during this step @@ -236,7 +236,7 @@ type TaskStepContext struct { // Used for descriptive incremental checkpoint messages TodoContent string - // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor") + // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor IDE") AgentType agent.AgentType } diff --git a/cmd/entire/cli/trailers/trailers.go b/cmd/entire/cli/trailers/trailers.go index ba56e24b60..c31534fdb5 100644 --- a/cmd/entire/cli/trailers/trailers.go +++ b/cmd/entire/cli/trailers/trailers.go @@ -46,7 +46,7 @@ const ( EphemeralBranchTrailerKey = "Ephemeral-branch" // AgentTrailerKey identifies the agent that created a checkpoint. - // Format: human-readable agent name e.g. "Claude Code", "Cursor" + // Format: human-readable agent name e.g. "Claude Code", "Cursor IDE" AgentTrailerKey = "Entire-Agent" )