diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go new file mode 100644 index 000000000..3f5439c83 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -0,0 +1,163 @@ +// Package cursor implements the Agent interface for Cursor. +package cursor + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//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 +} + +// 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). +// 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") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: c.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + }, 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 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. +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 +} 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..b536131d8 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/cursor_test.go @@ -0,0 +1,608 @@ +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") + } +} + +// --- 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) + } +} diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go new file mode 100644 index 000000000..4c9ab9260 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -0,0 +1,371 @@ +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) +) + +// Cursor hook names - these become subcommands under `entire hooks cursor` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameBeforeSubmitPrompt = "before-submit-prompt" + HookNameStop = "stop" + HookNamePreTool = "pre-tool" + HookNamePostTool = "post-tool" +) + +// 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, + HookNamePreTool, + HookNamePostTool, + } +} + +// 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 + HookNamePreTool + postTaskCmd := cmdPrefix + HookNamePostTool + + 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, "Subagent", preTaskCmd) { + preToolUse = append(preToolUse, CursorHookEntry{Command: preTaskCmd, Matcher: "Subagent"}) + count++ + } + if !hookCommandExistsWithMatcher(postToolUse, "Subagent", postTaskCmd) { + postToolUse = append(postToolUse, CursorHookEntry{Command: postTaskCmd, Matcher: "Subagent"}) + 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..3516aa70f --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -0,0 +1,440 @@ +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 != 6 { + t.Errorf("InstallHooks() count = %d, want 6", 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 1 (Task) + if len(hooksFile.Hooks.PostToolUse) != 1 { + t.Errorf("PostToolUse hooks = %d, want 1", 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, "Subagent", "entire hooks cursor pre-tool") + assertEntryWithMatcher(t, hooksFile.Hooks.PostToolUse, "Subagent", "entire hooks cursor post-tool") +} + +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 != 6 { + t.Errorf("first InstallHooks() count = %d, want 6", 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 != 6 { + t.Errorf("force InstallHooks() count = %d, want 6", 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) != 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, "Subagent", "entire hooks cursor post-tool") +} + +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 != 6 { + t.Errorf("InstallHooks() count = %d, want 6", 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..c2656f3f9 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -0,0 +1,137 @@ +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 HookNamePreTool: + return c.parsePreToolUse(stdin) + case HookNamePostTool: + return c.parsePostToolUse(stdin) + 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 +} + +// 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) { + raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[beforeSubmitPromptInputRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.ConversationID, + 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.ConversationID, + 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.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +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.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parsePostToolUse(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[postToolUseHookInputRaw](stdin) + if err != nil { + return nil, err + } + event := &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.ToolUseID, + ToolInput: raw.ToolInput, + Timestamp: time.Now(), + } + // 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 new file mode 100644 index 000000000..ccec9bada --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -0,0 +1,412 @@ +package cursor + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseHookEvent_SessionStart(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"conversation_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 := `{"conversation_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 := `{"conversation_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 conversation_id 'sess-789', got %q", event.SessionID) + } +} + +func TestParseHookEvent_SessionEnd(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"conversation_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 conversation_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{ + "conversation_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(HookNamePreTool, 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{ + "conversation_id": "main-session", + "transcript_path": "/tmp/main.jsonl", + "tool_use_id": "toolu_xyz789", + "tool_input": json.RawMessage(`{"prompt": "task done"}`), + } + inputBytes, marshalErr := json.Marshal(inputData) + if marshalErr != nil { + t.Fatalf("failed to marshal test input: %v", marshalErr) + } + + event, err := ag.ParseHookEvent(HookNamePostTool, 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) + } +} + +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 conversation_id", func(t *testing.T) { + t.Parallel() + 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 != "bingo-id" { + t.Errorf("expected session_id 'bingo-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(HookNamePreTool, 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(HookNamePostTool, 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: HookNamePreTool, + expectedType: agent.SubagentStart, + inputTemplate: `{"session_id": "s5", "transcript_path": "/t", "tool_use_id": "t1", "tool_input": {}}`, + }, + { + hookName: HookNamePostTool, + expectedType: agent.SubagentEnd, + inputTemplate: `{"session_id": "s6", "transcript_path": "/t", "tool_use_id": "t2", "tool_input": {}, "tool_response": {}}`, + }, + } + + 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) + } + }) + } +} + +// --- 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") + } +} diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go new file mode 100644 index 000000000..16563edbb --- /dev/null +++ b/cmd/entire/cli/agent/cursor/types.go @@ -0,0 +1,101 @@ +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 { + // 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"` +} + +// 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"` + + // hook specific + Prompt string `json:"prompt"` +} + +// 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"` + + // hook specific + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` + ToolName string `json:"tool_name"` +} + +// 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"` + + // 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"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 37f7a2905..84021153f 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -92,6 +92,7 @@ type AgentType string // Agent name constants (registry keys) const ( AgentNameClaudeCode AgentName = "claude-code" + AgentNameCursor AgentName = "cursor" AgentNameGemini AgentName = "gemini" AgentNameOpenCode AgentName = "opencode" ) @@ -99,6 +100,7 @@ const ( // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeCursor AgentType = "Cursor" AgentTypeGemini AgentType = "Gemini CLI" AgentTypeOpenCode AgentType = "OpenCode" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility 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) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 215954a56..da011a1c5 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/entireio/cli/cmd/entire/cli/agent/opencode" diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index c112d454d..9f592356e 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -119,8 +119,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType return buildCondensedTranscriptFromGemini(content) case agent.AgentTypeOpenCode: return buildCondensedTranscriptFromOpenCode(content) - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: - // Claude format - fall through to shared logic below + case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: + // Claude/cursor format - 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/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) + } +} diff --git a/cmd/entire/cli/transcript/types.go b/cmd/entire/cli/transcript/types.go index c86399294..3c0e284e3 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" @@ -16,9 +16,12 @@ 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. +// 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,omitempty"` UUID string `json:"uuid"` Message json.RawMessage `json:"message"` }