diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go new file mode 100644 index 000000000..9ae12d976 --- /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].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"` }