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/10] 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 000000000..37c8d4731 --- /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 000000000..fab242b3b --- /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 000000000..ed05a7041 --- /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 000000000..f72e4f78b --- /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 000000000..1e76259f4 --- /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 000000000..a7ad3c02d --- /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 0be89d8b6..116f458e4 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 d12922523..ad7f0e271 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 80fb2932d..6313918de 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 3aefde7e4..1770b4892 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 c86399294..3ebd2cf16 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/10] 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 37c8d4731..9ae12d976 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/10] 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 97aea8d65..d36b4286c 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/10] 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 9ae12d976..3f5439c83 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 f72e4f78b..cbfcdd850 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 a7ad3c02d..298bc3e99 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 154529c96..1c0b18cf4 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 3ebd2cf16..55134d44b 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/10] 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 1c0b18cf4..154529c96 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/10] 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 000000000..76551e993 --- /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/10] 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 76551e993..b536131d8 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 1e76259f4..8f40806a9 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/10] 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 55134d44b..3c0e284e3 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/10] 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 5891aa32c..87df98aef 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 154529c96..15a95ec85 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 cd4ade913..dd4ebd667 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/10] 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 206c1be38..4c9ab9260 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 ed05a7041..3516aa70f 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 cbfcdd850..c2656f3f9 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 8f40806a9..ccec9bada 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 298bc3e99..16563edbb 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"` }