Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/entire/cli/agent/cursor/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ func (c *CursorAgent) GetSessionDir(repoPath string) (string, error) {
}

// ReadSession reads a session from Cursor's storage (JSONL transcript file).
// Note: ModifiedFiles is left empty because Cursor's transcript format does not
// contain tool_use blocks. File detection relies on git status instead.
// Note: ModifiedFiles is left empty because Cursor's transcript does not contain
// tool_use blocks for file detection. TranscriptAnalyzer extracts prompts and
// summaries; file detection relies on git status.
func (c *CursorAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) {
if input.SessionRef == "" {
return nil, errors.New("session reference (transcript path) is required")
Expand Down
4 changes: 0 additions & 4 deletions cmd/entire/cli/agent/cursor/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ func (c *CursorAgent) ReadTranscript(sessionRef string) ([]byte, error) {
return data, nil
}

// Note: CursorAgent does NOT implement TranscriptAnalyzer. Cursor's transcript
// format does not contain tool_use blocks that would allow extracting modified
// files. File detection relies on git status instead.

// --- Internal hook parsing functions ---

// resolveTranscriptRef returns the transcript path from the hook input, or computes
Expand Down
113 changes: 113 additions & 0 deletions cmd/entire/cli/agent/cursor/transcript.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cursor

import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/textutil"
"github.com/entireio/cli/cmd/entire/cli/transcript"
)

// Compile-time interface assertion.
var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil)

// GetTranscriptPosition returns the current line count of a Cursor transcript.
// Cursor uses the same JSONL format as Claude Code, so position is the number of lines.
// Uses bufio.Reader to handle arbitrarily long lines (no size limit).
// Returns 0 if the file doesn't exist or is empty.
func (c *CursorAgent) GetTranscriptPosition(path string) (int, error) {
if path == "" {
return 0, nil
}

file, err := os.Open(path) //nolint:gosec // Path comes from Cursor transcript location
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("failed to open transcript file: %w", err)
}
defer file.Close()

reader := bufio.NewReader(file)
lineCount := 0

for {
line, err := reader.ReadBytes('\n')
if err != nil {
if err == io.EOF {
if len(line) > 0 {
lineCount++ // Count final line without trailing newline
}
break
}
return 0, fmt.Errorf("failed to read transcript: %w", err)
}
lineCount++
}

return lineCount, nil
}

// ExtractPrompts extracts user prompts from the transcript starting at the given line offset.
// Cursor uses the same JSONL format as Claude Code; the shared transcript package normalizes
// "role" → "type" and strips <user_query> tags.
func (c *CursorAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) {
lines, err := transcript.ParseFromFileAtLine(sessionRef, fromOffset)
if err != nil {
return nil, fmt.Errorf("failed to parse transcript: %w", err)
}

var prompts []string
for i := range lines {
if lines[i].Type != transcript.TypeUser {
continue
}
content := transcript.ExtractUserContent(lines[i].Message)
if content != "" {
prompts = append(prompts, textutil.StripIDEContextTags(content))
}
}
return prompts, nil
}

// ExtractSummary extracts the last assistant message as a session summary.
func (c *CursorAgent) ExtractSummary(sessionRef string) (string, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input
if err != nil {
return "", fmt.Errorf("failed to read transcript: %w", err)
}

lines, parseErr := transcript.ParseFromBytes(data)
if parseErr != nil {
return "", fmt.Errorf("failed to parse transcript: %w", parseErr)
}

// Walk backward to find last assistant text block
for i := len(lines) - 1; i >= 0; i-- {
if lines[i].Type != transcript.TypeAssistant {
continue
}
var msg transcript.AssistantMessage
if err := json.Unmarshal(lines[i].Message, &msg); err != nil {
continue
}
for _, block := range msg.Content {
if block.Type == transcript.ContentTypeText && block.Text != "" {
return block.Text, nil
}
}
}
return "", nil
}

// ExtractModifiedFilesFromOffset returns nil, 0, nil because Cursor transcripts
// do not contain tool_use blocks. File detection relies on git status instead.
// All call sites are expected to also use git-status-based detection.
func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]string, int, error) {
return nil, 0, nil
}
194 changes: 194 additions & 0 deletions cmd/entire/cli/agent/cursor/transcript_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package cursor

import (
"os"
"path/filepath"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// Compile-time interface check.
var _ agent.TranscriptAnalyzer = (*CursorAgent)(nil)

// --- GetTranscriptPosition ---

func TestCursorAgent_GetTranscriptPosition(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := writeSampleTranscript(t, tmpDir)

pos, err := ag.GetTranscriptPosition(path)
if err != nil {
t.Fatalf("GetTranscriptPosition() error = %v", err)
}
if pos != 4 {
t.Errorf("GetTranscriptPosition() = %d, want 4", pos)
}
}

func TestCursorAgent_GetTranscriptPosition_EmptyPath(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

pos, err := ag.GetTranscriptPosition("")
if err != nil {
t.Fatalf("GetTranscriptPosition() error = %v", err)
}
if pos != 0 {
t.Errorf("GetTranscriptPosition() = %d, want 0", pos)
}
}

func TestCursorAgent_GetTranscriptPosition_NonexistentFile(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

pos, err := ag.GetTranscriptPosition("/nonexistent/path/transcript.jsonl")
if err != nil {
t.Fatalf("GetTranscriptPosition() error = %v", err)
}
if pos != 0 {
t.Errorf("GetTranscriptPosition() = %d, want 0", pos)
}
}

// --- ExtractPrompts ---

func TestCursorAgent_ExtractPrompts(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := writeSampleTranscript(t, tmpDir)

prompts, err := ag.ExtractPrompts(path, 0)
if err != nil {
t.Fatalf("ExtractPrompts() error = %v", err)
}
if len(prompts) != 2 {
t.Fatalf("ExtractPrompts() returned %d prompts, want 2", len(prompts))
}
// Verify <user_query> tags are stripped
if prompts[0] != "hello" {
t.Errorf("prompts[0] = %q, want %q", prompts[0], "hello")
}
if prompts[1] != "add 'one' to a file and commit" {
t.Errorf("prompts[1] = %q, want %q", prompts[1], "add 'one' to a file and commit")
}
}

func TestCursorAgent_ExtractPrompts_WithOffset(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := writeSampleTranscript(t, tmpDir)

// Offset 2 skips the first user+assistant pair, leaving 1 user prompt
prompts, err := ag.ExtractPrompts(path, 2)
if err != nil {
t.Fatalf("ExtractPrompts() error = %v", err)
}
if len(prompts) != 1 {
t.Fatalf("ExtractPrompts() returned %d prompts, want 1", len(prompts))
}
if prompts[0] != "add 'one' to a file and commit" {
t.Errorf("prompts[0] = %q, want %q", prompts[0], "add 'one' to a file and commit")
}
}

func TestCursorAgent_ExtractPrompts_EmptyFile(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "empty.jsonl")
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
t.Fatalf("failed to write empty file: %v", err)
}

prompts, err := ag.ExtractPrompts(path, 0)
if err != nil {
t.Fatalf("ExtractPrompts() error = %v", err)
}
if len(prompts) != 0 {
t.Errorf("ExtractPrompts() returned %d prompts, want 0", len(prompts))
}
}

// --- ExtractSummary ---

func TestCursorAgent_ExtractSummary(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := writeSampleTranscript(t, tmpDir)

summary, err := ag.ExtractSummary(path)
if err != nil {
t.Fatalf("ExtractSummary() error = %v", err)
}
if summary != "Created one.txt with one and committed." {
t.Errorf("ExtractSummary() = %q, want %q", summary, "Created one.txt with one and committed.")
}
}

func TestCursorAgent_ExtractSummary_EmptyFile(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "empty.jsonl")
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
t.Fatalf("failed to write empty file: %v", err)
}

summary, err := ag.ExtractSummary(path)
if err != nil {
t.Fatalf("ExtractSummary() error = %v", err)
}
if summary != "" {
t.Errorf("ExtractSummary() = %q, want empty string", summary)
}
}

// --- ExtractModifiedFilesFromOffset ---

func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

tmpDir := t.TempDir()
path := writeSampleTranscript(t, tmpDir)

files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0)
if err != nil {
t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err)
}
if files != nil {
t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files)
}
if pos != 0 {
t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos)
}
}

func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) {
t.Parallel()
ag := &CursorAgent{}

files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0)
if err != nil {
t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err)
}
if files != nil {
t.Errorf("ExtractModifiedFilesFromOffset() files = %v, want nil", files)
}
if pos != 0 {
t.Errorf("ExtractModifiedFilesFromOffset() pos = %d, want 0", pos)
}
}
13 changes: 10 additions & 3 deletions cmd/entire/cli/commit_message.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
package cli

import (
"fmt"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/stringutil"
)

// generateCommitMessage creates a commit message from the user's original prompt
func generateCommitMessage(originalPrompt string) string {
// generateCommitMessage creates a commit message from the user's original prompt.
// If the prompt is empty or cleans to empty, falls back to "<agentType> session updates".
func generateCommitMessage(originalPrompt string, agentType types.AgentType) string {
if originalPrompt != "" {
cleaned := cleanPromptForCommit(originalPrompt)
if cleaned != "" {
return cleaned
}
}

return "Claude Code session updates"
if agentType == "" {
agentType = agent.AgentTypeUnknown
}
return fmt.Sprintf("%s session updates", agentType)
}

// cleanPromptForCommit cleans up a user prompt to make it suitable as a commit message
Expand Down
Loading