diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 94c74f6d7..c1ba4bf47 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -8,7 +8,7 @@ import ( ) // Agent defines the interface for interacting with a coding agent. -// Each agent implementation (Claude Code, Cursor, Aider, etc.) converts its +// Each agent implementation (Claude Code, Cursor IDE, Aider, etc.) converts its // native format to the normalized types defined in this package. // // The interface is organized into three groups: @@ -75,7 +75,7 @@ type Agent interface { } // HookSupport is implemented by agents with lifecycle hooks. -// This optional interface allows agents like Claude Code and Cursor to +// This optional interface allows agents like Claude Code and Cursor IDE to // install and manage hooks that notify Entire of agent events. // // The interface is organized into two groups: diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go new file mode 100644 index 000000000..113d6d3c7 --- /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 IDE. +// +//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) { + worktreeRoot, err := paths.WorktreeRoot() + if err != nil { + worktreeRoot = "." + } + + cursorDir := filepath.Join(worktreeRoot, ".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 IDE 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..5f42a5d8e --- /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 IDE") { + 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..fb1b4e891 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -0,0 +1,376 @@ +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 +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" + HookNamePreCompact = "pre-compact" + HookNameSubagentStart = "subagent-start" + HookNameSubagentStop = "subagent-stop" +) + +// 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 ", +} + +// HookNames returns the hook verbs Cursor supports. +// These become subcommands: entire hooks cursor +func (c *CursorAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeSubmitPrompt, + HookNameStop, + HookNamePreCompact, + HookNameSubagentStart, + HookNameSubagentStop, + } +} + +// 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) { + worktreeRoot, err := paths.WorktreeRoot() + if err != nil { + worktreeRoot = "." + } + + hooksPath := filepath.Join(worktreeRoot, ".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) + } + } + if _, ok := rawFile["version"]; !ok { + rawFile["version"] = json.RawMessage(`1`) + } + } 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, preCompact, subagentStart, subagentStop []CursorHookEntry + parseCursorHookType(rawHooks, "sessionStart", &sessionStart) + parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) + parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) + parseCursorHookType(rawHooks, "stop", &stop) + parseCursorHookType(rawHooks, "preCompact", &preCompact) + parseCursorHookType(rawHooks, "subagentStart", &subagentStart) + parseCursorHookType(rawHooks, "subagentStop", &subagentStop) + + // If force is true, remove all existing Entire hooks first + if force { + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) + stop = removeEntireHooks(stop) + preCompact = removeEntireHooks(preCompact) + subagentStart = removeEntireHooks(subagentStart) + subagentStop = removeEntireHooks(subagentStop) + } + + // 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 + HookNameSessionStart + sessionEndCmd := cmdPrefix + HookNameSessionEnd + beforeSubmitPromptCmd := cmdPrefix + HookNameBeforeSubmitPrompt + stopCmd := cmdPrefix + HookNameStop + preCompactCmd := cmdPrefix + HookNamePreCompact + subagentStartCmd := cmdPrefix + HookNameSubagentStart + subagentEndCmd := cmdPrefix + HookNameSubagentStop + + 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 !hookCommandExists(preCompact, preCompactCmd) { + preCompact = append(preCompact, CursorHookEntry{Command: preCompactCmd}) + count++ + } + if !hookCommandExists(subagentStart, subagentStartCmd) { + subagentStart = append(subagentStart, CursorHookEntry{Command: subagentStartCmd}) + count++ + } + if !hookCommandExists(subagentStop, subagentEndCmd) { + subagentStop = append(subagentStop, CursorHookEntry{Command: subagentEndCmd}) + 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, "preCompact", preCompact) + marshalCursorHookType(rawHooks, "subagentStart", subagentStart) + marshalCursorHookType(rawHooks, "subagentStop", subagentStop) + + // 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 { + worktreeRoot, err := paths.WorktreeRoot() + if err != nil { + worktreeRoot = "." + } + hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No hooks file means nothing to uninstall + } + + 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, preCompact, subagentStart, subagentStop []CursorHookEntry + parseCursorHookType(rawHooks, "sessionStart", &sessionStart) + parseCursorHookType(rawHooks, "sessionEnd", &sessionEnd) + parseCursorHookType(rawHooks, "beforeSubmitPrompt", &beforeSubmitPrompt) + parseCursorHookType(rawHooks, "stop", &stop) + parseCursorHookType(rawHooks, "preCompact", &preCompact) + parseCursorHookType(rawHooks, "subagentStart", &subagentStart) + parseCursorHookType(rawHooks, "subagentStop", &subagentStop) + + // Remove Entire hooks from all hook types + sessionStart = removeEntireHooks(sessionStart) + sessionEnd = removeEntireHooks(sessionEnd) + beforeSubmitPrompt = removeEntireHooks(beforeSubmitPrompt) + stop = removeEntireHooks(stop) + preCompact = removeEntireHooks(preCompact) + subagentStart = removeEntireHooks(subagentStart) + subagentStop = removeEntireHooks(subagentStop) + + // Marshal modified hook types back into rawHooks + marshalCursorHookType(rawHooks, "sessionStart", sessionStart) + marshalCursorHookType(rawHooks, "sessionEnd", sessionEnd) + marshalCursorHookType(rawHooks, "beforeSubmitPrompt", beforeSubmitPrompt) + marshalCursorHookType(rawHooks, "stop", stop) + marshalCursorHookType(rawHooks, "preCompact", preCompact) + marshalCursorHookType(rawHooks, "subagentStart", subagentStart) + marshalCursorHookType(rawHooks, "subagentStop", subagentStop) + + // 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 { + worktreeRoot, err := paths.WorktreeRoot() + if err != nil { + worktreeRoot = "." + } + hooksPath := filepath.Join(worktreeRoot, ".cursor", HooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + 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.PreCompact) || + hasEntireHook(hooksFile.Hooks.SubagentStart) || + hasEntireHook(hooksFile.Hooks.SubagentStop) +} + +// 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 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..22a6d295e --- /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 != 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)) + } + if len(hooksFile.Hooks.PreCompact) != 1 { + t.Errorf("PreCompact hooks = %d, want 1", len(hooksFile.Hooks.PreCompact)) + } + if len(hooksFile.Hooks.SubagentStart) != 1 { + t.Errorf("SubagentStart hooks = %d, want 1", len(hooksFile.Hooks.SubagentStart)) + } + if len(hooksFile.Hooks.SubagentStop) != 1 { + t.Errorf("SubagentStop hooks = %d, want 1", len(hooksFile.Hooks.SubagentStop)) + } + + // 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") + assertEntryCommand(t, hooksFile.Hooks.PreCompact, "entire hooks cursor pre-compact") + assertEntryCommand(t, hooksFile.Hooks.SubagentStart, "entire hooks cursor subagent-start") + assertEntryCommand(t, hooksFile.Hooks.SubagentStop, "entire hooks cursor subagent-stop") +} + +func TestInstallHooks_Idempotent(t *testing.T) { + 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"}, + }, + SubagentStop: []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") + + // SubagentStop should have user Write hook + Entire hook + if len(hooksFile.Hooks.SubagentStop) != 2 { + t.Errorf("SubagentStop hooks = %d, want 2 (user Write + Entire)", len(hooksFile.Hooks.SubagentStop)) + } + assertEntryWithMatcher(t, hooksFile.Hooks.SubagentStop, "Write", "echo file written") + assertEntryCommand(t, hooksFile.Hooks.SubagentStop, "entire hooks cursor subagent-stop") +} + +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..5fcdbdfa5 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -0,0 +1,155 @@ +package cursor + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// 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 HookNamePreCompact: + return c.parsePreCompact(stdin) + case HookNameSubagentStart: + return c.parseSubagentStart(stdin) + case HookNameSubagentStop: + return c.parseSubagentStop(stdin) + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// 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) parsePreCompact(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[preCompactHookInputRaw](stdin) + if err != nil { + return nil, err + } + return &agent.Event{ + Type: agent.Compaction, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[subagentStartHookInputRaw](stdin) + if err != nil { + return nil, err + } + if raw.Task == "" { + return nil, nil //nolint:nilnil // nil event = no lifecycle action + } + return &agent.Event{ + Type: agent.SubagentStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + SubagentID: raw.SubagentID, + ToolUseID: raw.SubagentID, + SubagentType: raw.SubagentType, + TaskDescription: raw.Task, + Timestamp: time.Now(), + }, nil +} + +func (c *CursorAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[subagentStopHookInputRaw](stdin) + if err != nil { + return nil, err + } + if raw.Task == "" { + return nil, nil //nolint:nilnil // nil event = no lifecycle action + } + event := &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ToolUseID: raw.SubagentID, + SubagentType: raw.SubagentType, + TaskDescription: raw.Task, + Timestamp: time.Now(), + SubagentID: raw.SubagentID, + } + return event, nil +} diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go new file mode 100644 index 000000000..07c646489 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -0,0 +1,408 @@ +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{} + inputData := map[string]any{ + "conversation_id": "main-session", + "transcript_path": "/tmp/main.jsonl", + "subagent_id": "sub_abc123", + "task": "do something", + } + inputBytes, marshalErr := json.Marshal(inputData) + if marshalErr != nil { + t.Fatalf("failed to marshal test input: %v", marshalErr) + } + + event, err := ag.ParseHookEvent(HookNameSubagentStart, 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 != "sub_abc123" { + t.Errorf("expected tool_use_id 'sub_abc123', got %q", event.ToolUseID) + } +} + +func TestParseHookEvent_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + inputData := map[string]any{ + "conversation_id": "main-session", + "transcript_path": "/tmp/main.jsonl", + "subagent_id": "sub_xyz789", + "task": "task done", + } + inputBytes, marshalErr := json.Marshal(inputData) + if marshalErr != nil { + t.Fatalf("failed to marshal test input: %v", marshalErr) + } + + event, err := ag.ParseHookEvent(HookNameSubagentStop, 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 != "sub_xyz789" { + t.Errorf("expected tool_use_id 'sub_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", "subagent_id": "s1", "task": "do something"}` + + event, err := ag.ParseHookEvent(HookNameSubagentStart, 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", "subagent_id": "s2", "task": "do something"}` + + event, err := ag.ParseHookEvent(HookNameSubagentStop, 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: HookNameSubagentStart, + expectedType: agent.SubagentStart, + inputTemplate: `{"conversation_id": "s5", "transcript_path": "/t", "subagent_id": "sub1", "task": "do something"}`, + }, + { + hookName: HookNameSubagentStop, + expectedType: agent.SubagentEnd, + inputTemplate: `{"conversation_id": "s6", "transcript_path": "/t", "subagent_id": "sub2", "task": "do something"}`, + }, + } + + 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..2c5389c81 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/types.go @@ -0,0 +1,138 @@ +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"` + PreCompact []CursorHookEntry `json:"preCompact,omitempty"` + SubagentStart []CursorHookEntry `json:"subagentStart,omitempty"` + SubagentStop []CursorHookEntry `json:"subagentStop,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 occasionally provides session_id, so we ignore it. +// Cursor always provides conversation_id. +// session_id and conversation_id are identical and interchangeable. +type sessionInfoRaw struct { + // common + ConversationID string `json:"conversation_id"` + 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"` +} + +// preCompactHookInputRaw is the JSON structure from PreCompact hook. +type preCompactHookInputRaw 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 + Trigger string `json:"trigger"` // "auto" | "manual", + ContextUsagePercent json.Number `json:"context_usage_percent"` // : 85, + ContextTokens json.Number `json:"context_tokens"` // 120000, + ContextWindowSize json.Number `json:"context_window_size"` // : 128000, + MessageCount json.Number `json:"message_count"` // 45, + MessagesToCompact json.Number `json:"messages_to_compact"` // : 30, + IsFirstCompaction bool `json:"is_first_compaction"` // true | false +} + +// subagentStartHookInputRaw is the JSON structure from SubagentStart[Task] hook. +type subagentStartHookInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` + + // hook specific + SubagentID string `json:"subagent_id"` + SubagentType string `json:"subagent_type"` + SubagentModel string `json:"subagent_model"` + Task string `json:"task"` + ParentConversationID string `json:"parent_conversation_id"` + ToolCallID string `json:"tool_call_id"` + IsParallelWorker bool `json:"is_parallel_worker"` +} + +// subagentStopHookInputRaw is the JSON structure from SubagentStop hooks. +type subagentStopHookInputRaw struct { + // common + ConversationID string `json:"conversation_id"` + GenerationID string `json:"generation_id"` + Model string `json:"model"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + WorkspaceRoots []string `json:"workspace_roots"` + UserEmail string `json:"user_email"` + TranscriptPath string `json:"transcript_path"` + + // hook specific + SubagentID string `json:"subagent_id"` + SubagentType string `json:"subagent_type"` + Status string `json:"status"` + Duration json.Number `json:"duration_ms"` + Summary string `json:"summary"` + ParentConversationID string `json:"parent_conversation_id"` + MessageCount json.Number `json:"message_count"` + ToolCallCount json.Number `json:"tool_call_count"` + ModifiedFiles []string `json:"modified_files"` + LoopCount json.Number `json:"loop_count"` + Task string `json:"task"` + Description string `json:"description"` + AgentTranscriptPath string `json:"agent_transcript_path"` +} diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 67fde698d..30426a87c 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -87,8 +87,15 @@ type Event struct { SubagentID string // ToolInput is the raw tool input JSON (for subagent type/description extraction). + // Used when both SubagentType and TaskDescription are empty (agents that don't provide + // these fields directly parse them from ToolInput). ToolInput json.RawMessage + // SubagentType is the kind of subagent (for SubagentStart/SubagentEnd events). + // Used with TaskDescription instead of ToolInput + SubagentType string + TaskDescription string + // ResponseMessage is an optional message to display to the user via the agent. ResponseMessage string diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 37f7a2905..0621703c0 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 IDE" AgentTypeGemini AgentType = "Gemini CLI" AgentTypeOpenCode AgentType = "OpenCode" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 187ed1b9e..66725e3e0 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -258,7 +258,7 @@ type WriteCommittedOptions struct { // Commit message fields (used for task checkpoints) CommitSubject string // Subject line for the metadata commit (overrides default) - // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") + // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType // TurnID correlates checkpoints from the same agent turn. @@ -370,7 +370,7 @@ type CommittedMetadata struct { CheckpointsCount int `json:"checkpoints_count"` FilesTouched []string `json:"files_touched"` - // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor") + // Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType `json:"agent,omitempty"` // TurnID correlates checkpoints from the same agent turn. diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index e622cd4da..b38848bb7 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -547,7 +547,7 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT return nil } return scoped - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) @@ -1547,7 +1547,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 fbec53e3b..1e7e6c20b 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/lifecycle.go b/cmd/entire/cli/lifecycle.go index b99e960cf..c7fca6dcf 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -484,8 +484,10 @@ func handleLifecycleSubagentEnd(ag agent.Agent, event *agent.Event) error { slog.String("subagent_id", event.SubagentID), ) - // Extract subagent type and description from tool input - subagentType, taskDescription := ParseSubagentTypeAndDescription(event.ToolInput) + if event.SubagentType == "" && event.TaskDescription == "" { + // Extract subagent type and description from tool input + event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput) + } // Determine subagent transcript path transcriptDir := filepath.Dir(event.SessionRef) @@ -586,8 +588,8 @@ func handleLifecycleSubagentEnd(ag agent.Agent, event *agent.Event) error { CheckpointUUID: checkpointUUID, AuthorName: author.Name, AuthorEmail: author.Email, - SubagentType: subagentType, - TaskDescription: taskDescription, + SubagentType: event.SubagentType, + TaskDescription: event.TaskDescription, AgentType: agentType, } diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 35f287b22..a258f4993 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -113,7 +113,7 @@ type State struct { // sessions that have been condensed at least once. Cleared on new prompt. LastCheckpointID id.CheckpointID `json:"last_checkpoint_id,omitempty"` - // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor") + // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor IDE") AgentType agent.AgentType `json:"agent_type,omitempty"` // Token usage tracking (accumulated across all checkpoints in this session) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index f9b709f68..10c85ead0 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -447,7 +447,7 @@ func TestWriteActiveSessions(t *testing.T) { WorktreePath: "/Users/test/repo", StartedAt: now.Add(-15 * time.Minute), FirstPrompt: "Add dark mode support for the entire application and all components", - AgentType: agent.AgentType("Cursor"), + AgentType: agent.AgentTypeCursor, TokenUsage: &agent.TokenUsage{ InputTokens: 500, OutputTokens: 300, @@ -481,7 +481,7 @@ func TestWriteActiveSessions(t *testing.T) { if !strings.Contains(output, "Claude Code") { t.Errorf("Expected agent label 'Claude Code', got: %s", output) } - if !strings.Contains(output, "Cursor") { + if !strings.Contains(output, "Cursor IDE") { t.Errorf("Expected agent label 'Cursor', got: %s", output) } // Session without AgentType should show unknown placeholder @@ -516,7 +516,7 @@ func TestWriteActiveSessions(t *testing.T) { // Session started 15m ago with no LastInteractionTime should NOT show "active" in stats for _, line := range lines { - if strings.Contains(line, "Cursor") { + if strings.Contains(line, "Cursor IDE") { if strings.Contains(line, "active") { t.Errorf("Session without LastInteractionTime should not show 'active', got: %s", line) } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 045adfdd6..e80c08b76 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -225,7 +225,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI slog.String("error", sliceErr.Error())) } scopedTranscript = scoped - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart) } if len(scopedTranscript) > 0 { @@ -591,6 +591,11 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { // where the current checkpoint began, allowing calculation for only the portion // of the transcript since the last checkpoint. func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int) *agent.TokenUsage { + // No token usage information from Cursor yet + if agentType == agent.AgentTypeCursor { + return nil + } + if len(data) == 0 { return &agent.TokenUsage{} } @@ -641,9 +646,13 @@ func extractUserPromptsFromLines(lines []string) []string { continue } - // Check for user message (supports both "human" and "user" types) - msgType, ok := entry["type"].(string) - if !ok || (msgType != "human" && msgType != "user") { + // Check for user message: + // - Claude Code uses "type": "human" or "type": "user" + // - Cursor uses "role": "user" + msgType, _ := entry["type"].(string) //nolint:errcheck // type assertion on interface{} from JSON + msgRole, _ := entry["role"].(string) //nolint:errcheck // type assertion on interface{} from JSON + isUser := msgType == "human" || msgType == "user" || msgRole == "user" + if !isUser { continue } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index ad150973b..5233425b1 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -4,8 +4,167 @@ import ( "strings" "testing" "unicode/utf8" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) +func TestCalculateTokenUsage_CursorReturnsNil(t *testing.T) { + t.Parallel() + + // Cursor transcripts don't contain token usage data, so calculateTokenUsage + // should return nil (not an empty struct) to signal "no data available". + transcript := []byte(`{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}}`) + + result := calculateTokenUsage(agent.AgentTypeCursor, transcript, 0) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor) = %+v, want nil", result) + } +} + +func TestCalculateTokenUsage_EmptyData(t *testing.T) { + t.Parallel() + + result := calculateTokenUsage(agent.AgentTypeClaudeCode, nil, 0) + if result == nil { + t.Fatal("calculateTokenUsage(empty) = nil, want non-nil empty struct") + } + if result.InputTokens != 0 || result.OutputTokens != 0 { + t.Errorf("expected zero tokens for empty data, got %+v", result) + } +} + +func TestCalculateTokenUsage_ClaudeCodeBasic(t *testing.T) { + t.Parallel() + + // Claude Code JSONL: "usage" with "id" lives inside the "message" JSON object + lines := []string{ + `{"type":"human","uuid":"u1","message":{"content":"hello"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":10,"output_tokens":5}}}`, + } + data := []byte(strings.Join(lines, "\n") + "\n") + + result := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 0) + if result == nil { + t.Fatal("calculateTokenUsage(ClaudeCode) = nil, want non-nil") + } + if result.OutputTokens != 5 { + t.Errorf("OutputTokens = %d, want 5", result.OutputTokens) + } + if result.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", result.APICallCount) + } +} + +func TestCalculateTokenUsage_ClaudeCodeWithOffset(t *testing.T) { + t.Parallel() + + // 4-line transcript; start at offset 2 to only count the second pair + lines := []string{ + `{"type":"human","uuid":"u1","message":{"content":"first"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":10,"output_tokens":5}}}`, + `{"type":"human","uuid":"u3","message":{"content":"second"}}`, + `{"type":"assistant","uuid":"u4","message":{"id":"msg_002","usage":{"input_tokens":20,"output_tokens":15}}}`, + } + data := []byte(strings.Join(lines, "\n") + "\n") + + full := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 0) + sliced := calculateTokenUsage(agent.AgentTypeClaudeCode, data, 2) + + if full == nil || sliced == nil { + t.Fatal("expected non-nil results") + } + if full.OutputTokens != 20 { + t.Errorf("full OutputTokens = %d, want 20", full.OutputTokens) + } + if sliced.OutputTokens != 15 { + t.Errorf("sliced OutputTokens = %d, want 15", sliced.OutputTokens) + } +} + +// cursorSampleTranscript is a subset of a real Cursor session transcript. +// Cursor uses "role" (not "type") and wraps user text in tags. +var cursorSampleTranscript = strings.Join([]string{ + `{"role":"user","message":{"content":[{"type":"text","text":"\ncreate a file with contents 'a' and commit, then create another file with contents 'b' and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Creating two files (contents 'a' and 'b') and committing each."}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Both files are tracked and the working tree is clean."}]}}`, + `{"role":"user","message":{"content":[{"type":"text","text":"\ncreate a file with contents 'c' and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Created c.txt with contents c and committed it."}]}}`, + `{"role":"user","message":{"content":[{"type":"text","text":"\nadd a file called bingo and commit\n"}]}}`, + `{"role":"assistant","message":{"content":[{"type":"text","text":"Created bingo and committed it."}]}}`, +}, "\n") + "\n" + +func TestCountTranscriptItems_Cursor(t *testing.T) { + t.Parallel() + + count := countTranscriptItems(agent.AgentTypeCursor, cursorSampleTranscript) + if count != 7 { + t.Errorf("countTranscriptItems(Cursor) = %d, want 7", count) + } +} + +func TestCountTranscriptItems_CursorEmpty(t *testing.T) { + t.Parallel() + + count := countTranscriptItems(agent.AgentTypeCursor, "") + if count != 0 { + t.Errorf("countTranscriptItems(Cursor, empty) = %d, want 0", count) + } +} + +func TestExtractUserPrompts_Cursor(t *testing.T) { + t.Parallel() + + // Cursor uses "role":"user" instead of "type":"human". extractUserPromptsFromLines + // handles both via the "role" fallback. + prompts := extractUserPrompts(agent.AgentTypeCursor, cursorSampleTranscript) + if len(prompts) != 3 { + t.Fatalf("extractUserPrompts(Cursor) returned %d prompts, want 3", len(prompts)) + } + + if !strings.Contains(prompts[0], "create a file with contents 'a'") { + t.Errorf("prompt[0] = %q, expected to contain file creation request", prompts[0]) + } + if !strings.Contains(prompts[2], "bingo") { + t.Errorf("prompt[2] = %q, expected to contain 'bingo'", prompts[2]) + } + + // Verify tags are stripped + for i, p := range prompts { + if strings.Contains(p, "") || strings.Contains(p, "") { + t.Errorf("prompt[%d] still contains tags: %q", i, p) + } + } +} + +func TestExtractUserPrompts_CursorEmpty(t *testing.T) { + t.Parallel() + + prompts := extractUserPrompts(agent.AgentTypeCursor, "") + if len(prompts) != 0 { + t.Errorf("extractUserPrompts(Cursor, empty) = %v, want empty", prompts) + } +} + +func TestCalculateTokenUsage_CursorRealTranscript(t *testing.T) { + t.Parallel() + + // Even with a multi-line real transcript, Cursor should return nil + result := calculateTokenUsage(agent.AgentTypeCursor, []byte(cursorSampleTranscript), 0) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor, real transcript) = %+v, want nil", result) + } +} + +func TestCalculateTokenUsage_CursorWithOffset(t *testing.T) { + t.Parallel() + + // Offset should not matter — Cursor always returns nil + result := calculateTokenUsage(agent.AgentTypeCursor, []byte(cursorSampleTranscript), 3) + if result != nil { + t.Errorf("calculateTokenUsage(Cursor, offset=3) = %+v, want nil", result) + } +} + func TestGenerateContextFromPrompts_CJKTruncation(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 8d00fc1c9..27b9cad64 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -74,7 +74,7 @@ type RewindPoint struct { CheckpointID id.CheckpointID // Agent is the human-readable name of the agent that created this checkpoint - // (e.g., "Claude Code", "Cursor") + // (e.g., "Claude Code", "Cursor IDE") Agent agent.AgentType // SessionID is the session identifier for this checkpoint. @@ -151,7 +151,7 @@ type StepContext struct { // AuthorEmail is the email to use for commits AuthorEmail string - // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor") + // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor IDE") AgentType agent.AgentType // Transcript position at step/turn start - tracks what was added during this step @@ -236,7 +236,7 @@ type TaskStepContext struct { // Used for descriptive incremental checkpoint messages TodoContent string - // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor") + // AgentType is the human-readable agent name (e.g., "Claude Code", "Cursor IDE") AgentType agent.AgentType } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index ab0842a57..b494b24a2 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 0db1118b7..52f6c15b2 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -803,6 +803,76 @@ func TestBuildCondensedTranscriptFromBytes_OpenCodeInvalidJSON(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/textutil/ide_tags.go b/cmd/entire/cli/textutil/ide_tags.go index d8f1dec7d..3549e5139 100644 --- a/cmd/entire/cli/textutil/ide_tags.go +++ b/cmd/entire/cli/textutil/ide_tags.go @@ -18,6 +18,7 @@ var systemTagRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?s)]*>.*?`), regexp.MustCompile(`(?s)]*>.*?`), regexp.MustCompile(`(?s)]*>.*?`), + regexp.MustCompile(``), // Cursor wraps user text in tags; strip tags but keep content } // StripIDEContextTags removes IDE-injected context tags from prompt text. diff --git a/cmd/entire/cli/textutil/ide_tags_test.go b/cmd/entire/cli/textutil/ide_tags_test.go index d79c92d35..5c9ffc185 100644 --- a/cmd/entire/cli/textutil/ide_tags_test.go +++ b/cmd/entire/cli/textutil/ide_tags_test.go @@ -88,6 +88,16 @@ func TestStripIDEContextTags(t *testing.T) { input: "file.goreminder\n\nactual prompt", expected: "actual prompt", }, + { + name: "cursor user_query tags stripped keeping content", + input: "\ncreate a file with contents 'a'\n", + expected: "create a file with contents 'a'", + }, + { + name: "cursor user_query with surrounding text", + input: "\nhello world\n", + expected: "hello world", + }, } for _, tt := range tests { diff --git a/cmd/entire/cli/trailers/trailers.go b/cmd/entire/cli/trailers/trailers.go index ba56e24b6..c31534fdb 100644 --- a/cmd/entire/cli/trailers/trailers.go +++ b/cmd/entire/cli/trailers/trailers.go @@ -46,7 +46,7 @@ const ( EphemeralBranchTrailerKey = "Ephemeral-branch" // AgentTrailerKey identifies the agent that created a checkpoint. - // Format: human-readable agent name e.g. "Claude Code", "Cursor" + // Format: human-readable agent name e.g. "Claude Code", "Cursor IDE" AgentTrailerKey = "Entire-Agent" ) 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"` }