diff --git a/CLAUDE.md b/CLAUDE.md index eea040e9e..00ca6faba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,6 +247,18 @@ relPath := paths.ToRelativePath("/repo/api/file.ts", repoRoot) // returns "api/ Test case in `state_test.go`: `TestFilterAndNormalizePaths_SiblingDirectories` documents this bug pattern. +### Agents (`cmd/entire/cli/agent/`) + +Supported agents: **claude-code** (Claude Code), **cursor** (Cursor IDE), **gemini** (Gemini CLI). Each can register hooks and parse hook payloads; strategies use agent type for transcript handling (e.g. JSONL for Claude/Cursor, JSON for Gemini). + +| Agent | Hook config | Notes | +|-------------|---------------------------|--------| +| claude-code | `.claude/settings.json` | Default agent | +| cursor | `.cursor/hooks.json` | Uses `.cursor/entire.json` for Entire settings; does **not** use `.claude/settings.json` | +| gemini | `.gemini/settings.json` | Preview | + +Enable with `entire enable --agent cursor`; Cursor hooks and settings are written only under `.cursor/`. + ### Session Strategies (`cmd/entire/cli/strategy/`) The CLI uses a strategy pattern for managing session data and checkpoints. Each strategy implements the `Strategy` interface defined in `strategy.go`. diff --git a/cmd/entire/cli/agent/cursor/cursor.go b/cmd/entire/cli/agent/cursor/cursor.go new file mode 100644 index 000000000..d606beb6a --- /dev/null +++ b/cmd/entire/cli/agent/cursor/cursor.go @@ -0,0 +1,231 @@ +// Package cursor implements the Agent interface for Cursor IDE. +// Cursor uses .cursor/hooks.json for hooks and does not use .claude/settings.json. +package cursor + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/sessionid" +) + +//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" +} + +// DetectPresence checks if Cursor is configured in the repository. +// Only checks for .cursor/ directory or .cursor/hooks.json; does not touch .claude/*. +func (c *CursorAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + cursorDir := filepath.Join(repoRoot, ".cursor") + if _, err := os.Stat(cursorDir); err == nil { + return true, nil + } + hooksPath := filepath.Join(repoRoot, ".cursor", "hooks.json") + if _, err := os.Stat(hooksPath); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to Cursor's hook config file. +func (c *CursorAgent) GetHookConfigPath() string { + return ".cursor/hooks.json" +} + +// SupportsHooks returns true as Cursor supports lifecycle hooks. +func (c *CursorAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Cursor hook input from stdin. +func (c *CursorAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + sessionID := func(sid, cid string) string { + if sid != "" { + return sid + } + return cid + } + + switch hookType { + case agent.HookUserPromptSubmit: + var raw userPromptSubmitRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse beforeSubmitPrompt: %w", err) + } + input.SessionID = sessionID(raw.SessionID, raw.ConversationID) + input.SessionRef = raw.TranscriptPath + input.UserPrompt = raw.Prompt + + case agent.HookSessionStart, agent.HookSessionEnd, agent.HookStop: + var raw sessionInfoRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse session info: %w", err) + } + input.SessionID = sessionID(raw.SessionID, raw.ConversationID) + input.SessionRef = raw.TranscriptPath + + case agent.HookPreToolUse: + var raw taskHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse preToolUse: %w", err) + } + input.SessionID = sessionID(raw.SessionID, raw.ConversationID) + input.SessionRef = raw.TranscriptPath + input.ToolUseID = raw.ToolUseID + input.ToolInput = raw.ToolInput + + case agent.HookPostToolUse: + var raw postToolHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse postToolUse: %w", err) + } + input.SessionID = sessionID(raw.SessionID, raw.ConversationID) + input.SessionRef = raw.TranscriptPath + input.ToolUseID = raw.ToolUseID + input.ToolInput = raw.ToolInput + if raw.ToolResponse.AgentID != "" { + input.RawData["agent_id"] = raw.ToolResponse.AgentID + } + if raw.ToolName != "" { + input.RawData["tool_name"] = raw.ToolName + } + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (c *CursorAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// TransformSessionID converts a Cursor session ID to an Entire session ID. +func (c *CursorAgent) TransformSessionID(agentSessionID string) string { + return agentSessionID +} + +// ExtractAgentSessionID extracts the Cursor session ID from an Entire session ID. +func (c *CursorAgent) ExtractAgentSessionID(entireSessionID string) string { + return sessionid.ModelSessionID(entireSessionID) +} + +// ProtectedDirs returns directories that Cursor uses; does not include .claude. +func (c *CursorAgent) ProtectedDirs() []string { + return []string{".cursor"} +} + +// GetSessionDir returns where Cursor stores session data for this repo. +// Uses ENTIRE_TEST_CURSOR_PROJECT_DIR in tests. Otherwise uses a placeholder path +// until Cursor transcript storage is confirmed (e.g. macOS: ~/Library/Application Support/Cursor/...). +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) + } + // Placeholder: Cursor storage path TBD from discovery. Use a sanitized repo path. + projectDir := SanitizePathForCursor(repoPath) + return filepath.Join(homeDir, ".cursor", "projects", projectDir), nil +} + +// ResolveSessionFile returns the path to the session transcript file. +// Cursor format TBD; default to /.jsonl for JSONL-style transcripts. +func (c *CursorAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ReadSession reads session data from Cursor storage. +// Stub: returns error until Cursor transcript path/format is confirmed. +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, + ModifiedFiles: nil, // Could parse transcript when format is known + }, nil +} + +// WriteSession writes session data for resumption. +// Stub: not implemented until Cursor supports resume. +func (c *CursorAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + // Cursor resume not yet supported + return errors.New("Cursor WriteSession not implemented") +} + +// FormatResumeCommand returns the command to resume a Cursor session. +func (c *CursorAgent) FormatResumeCommand(sessionID string) string { + return "cursor --resume " + sessionID +} + +// SanitizePathForCursor converts a path to a safe directory name for Cursor project storage. +var cursorNonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +func SanitizePathForCursor(path string) string { + return cursorNonAlphanumericRegex.ReplaceAllString(path, "-") +} diff --git a/cmd/entire/cli/agent/cursor/hooks.go b/cmd/entire/cli/agent/cursor/hooks.go new file mode 100644 index 000000000..00c39e5dc --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks.go @@ -0,0 +1,246 @@ +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" +) + +// CursorHooksFileName is the hooks config filename under .cursor/ +const CursorHooksFileName = "hooks.json" + +// Ensure CursorAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*CursorAgent)(nil) + _ agent.HookHandler = (*CursorAgent)(nil) +) + +// Hook names - subcommands under `entire hooks cursor` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameBeforeSubmitPrompt = "before-submit-prompt" + HookNameStop = "stop" + HookNamePreTask = "pre-task" + HookNamePostTask = "post-task" + HookNamePostTodo = "post-todo" +) + +// entireHookPrefixes identify Entire hook commands in .cursor/hooks.json +var entireHookPrefixes = []string{ + "entire ", + "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go ", +} + +// GetHookNames returns the hook verbs Cursor supports. +func (c *CursorAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeSubmitPrompt, + HookNameStop, + HookNamePreTask, + HookNamePostTask, + HookNamePostTodo, + } +} + +// InstallHooks installs Cursor hooks in .cursor/hooks.json only. Does not touch .claude/*. +func (c *CursorAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Fallback when not in git repo (e.g. tests) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + settingsPath := filepath.Join(repoRoot, ".cursor", CursorHooksFileName) + + var file CursorHooksFile + file.Version = 1 + file.Hooks = CursorHooks{} + + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path from repo root + constant + if readErr == nil { + if err := json.Unmarshal(existingData, &file); err != nil { + return 0, fmt.Errorf("failed to parse existing .cursor/hooks.json: %w", err) + } + } + + if file.Hooks.SessionStart == nil { + file.Hooks.SessionStart = []CursorHookEntry{} + } + if file.Hooks.SessionEnd == nil { + file.Hooks.SessionEnd = []CursorHookEntry{} + } + if file.Hooks.BeforeSubmitPrompt == nil { + file.Hooks.BeforeSubmitPrompt = []CursorHookEntry{} + } + if file.Hooks.Stop == nil { + file.Hooks.Stop = []CursorHookEntry{} + } + if file.Hooks.PreToolUse == nil { + file.Hooks.PreToolUse = []CursorHookEntry{} + } + if file.Hooks.PostToolUse == nil { + file.Hooks.PostToolUse = []CursorHookEntry{} + } + + if force { + file.Hooks.SessionStart = removeEntireHooksCursor(file.Hooks.SessionStart) + file.Hooks.SessionEnd = removeEntireHooksCursor(file.Hooks.SessionEnd) + file.Hooks.BeforeSubmitPrompt = removeEntireHooksCursor(file.Hooks.BeforeSubmitPrompt) + file.Hooks.Stop = removeEntireHooksCursor(file.Hooks.Stop) + file.Hooks.PreToolUse = removeEntireHooksCursor(file.Hooks.PreToolUse) + file.Hooks.PostToolUse = removeEntireHooksCursor(file.Hooks.PostToolUse) + } + + cmdPrefix := "entire hooks cursor " + if localDev { + cmdPrefix = "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go hooks cursor " + } + + count := 0 + if !cursorHookExists(file.Hooks.SessionStart, cmdPrefix+HookNameSessionStart) { + file.Hooks.SessionStart = append(file.Hooks.SessionStart, CursorHookEntry{Command: cmdPrefix + HookNameSessionStart}) + count++ + } + if !cursorHookExists(file.Hooks.SessionEnd, cmdPrefix+HookNameSessionEnd) { + file.Hooks.SessionEnd = append(file.Hooks.SessionEnd, CursorHookEntry{Command: cmdPrefix + HookNameSessionEnd}) + count++ + } + if !cursorHookExists(file.Hooks.BeforeSubmitPrompt, cmdPrefix+HookNameBeforeSubmitPrompt) { + file.Hooks.BeforeSubmitPrompt = append(file.Hooks.BeforeSubmitPrompt, CursorHookEntry{Command: cmdPrefix + HookNameBeforeSubmitPrompt}) + count++ + } + if !cursorHookExists(file.Hooks.Stop, cmdPrefix+HookNameStop) { + file.Hooks.Stop = append(file.Hooks.Stop, CursorHookEntry{Command: cmdPrefix + HookNameStop}) + count++ + } + if !cursorHookExists(file.Hooks.PreToolUse, cmdPrefix+HookNamePreTask) { + file.Hooks.PreToolUse = append(file.Hooks.PreToolUse, CursorHookEntry{Command: cmdPrefix + HookNamePreTask}) + count++ + } + if !cursorHookExists(file.Hooks.PostToolUse, cmdPrefix+HookNamePostTask) { + file.Hooks.PostToolUse = append(file.Hooks.PostToolUse, CursorHookEntry{Command: cmdPrefix + HookNamePostTask}) + count++ + } + if !cursorHookExists(file.Hooks.PostToolUse, cmdPrefix+HookNamePostTodo) { + file.Hooks.PostToolUse = append(file.Hooks.PostToolUse, CursorHookEntry{Command: cmdPrefix + HookNamePostTodo}) + count++ + } + + if count == 0 { + return 0, nil + } + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .cursor directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(file, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write .cursor/hooks.json: %w", err) + } + return count, nil +} + +// UninstallHooks removes Entire hooks from .cursor/hooks.json. +func (c *CursorAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + settingsPath := filepath.Join(repoRoot, ".cursor", CursorHooksFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path from repo root + constant + if err != nil { + return nil //nolint:nilerr // No file means nothing to uninstall + } + + var file CursorHooksFile + if err := json.Unmarshal(data, &file); err != nil { + return fmt.Errorf("failed to parse .cursor/hooks.json: %w", err) + } + + file.Hooks.SessionStart = removeEntireHooksCursor(file.Hooks.SessionStart) + file.Hooks.SessionEnd = removeEntireHooksCursor(file.Hooks.SessionEnd) + file.Hooks.BeforeSubmitPrompt = removeEntireHooksCursor(file.Hooks.BeforeSubmitPrompt) + file.Hooks.Stop = removeEntireHooksCursor(file.Hooks.Stop) + file.Hooks.PreToolUse = removeEntireHooksCursor(file.Hooks.PreToolUse) + file.Hooks.PostToolUse = removeEntireHooksCursor(file.Hooks.PostToolUse) + + output, err := jsonutil.MarshalIndentWithNewline(file, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + return os.WriteFile(settingsPath, output, 0o600) +} + +// AreHooksInstalled checks if any Entire hook is present in .cursor/hooks.json. +func (c *CursorAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + settingsPath := filepath.Join(repoRoot, ".cursor", CursorHooksFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec // path from repo root + constant + if err != nil { + return false + } + var file CursorHooksFile + if err := json.Unmarshal(data, &file); err != nil { + return false + } + return cursorHookExists(file.Hooks.Stop, "entire hooks cursor "+HookNameStop) || + cursorHookExists(file.Hooks.Stop, "go run ${CURSOR_PROJECT_DIR}/cmd/entire/main.go hooks cursor "+HookNameStop) +} + +// 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, + } +} + +func cursorHookExists(entries []CursorHookEntry, command string) bool { + for _, e := range entries { + if e.Command == command { + return true + } + } + return false +} + +func isEntireHookCursor(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +func removeEntireHooksCursor(entries []CursorHookEntry) []CursorHookEntry { + out := make([]CursorHookEntry, 0, len(entries)) + for _, e := range entries { + if !isEntireHookCursor(e.Command) { + out = append(out, e) + } + } + return out +} 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..98c5296cf --- /dev/null +++ b/cmd/entire/cli/agent/cursor/hooks_test.go @@ -0,0 +1,83 @@ +package cursor + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create .cursor so DetectPresence can find it; InstallHooks will create .cursor/hooks.json + if err := os.MkdirAll(filepath.Join(tempDir, ".cursor"), 0o750); err != nil { + t.Fatal(err) + } + + c := &CursorAgent{} + count, err := c.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 7 { + t.Errorf("InstallHooks() count = %d, want 7", count) + } + + data, err := os.ReadFile(filepath.Join(tempDir, ".cursor", CursorHooksFileName)) + if err != nil { + t.Fatalf("read hooks.json: %v", err) + } + var file CursorHooksFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + if file.Version != 1 { + t.Errorf("version = %d, want 1", file.Version) + } + if len(file.Hooks.SessionStart) != 1 || len(file.Hooks.Stop) != 1 { + t.Errorf("SessionStart=%d Stop=%d, want 1 each", len(file.Hooks.SessionStart), len(file.Hooks.Stop)) + } +} + +func TestAreHooksInstalled_NotInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + c := &CursorAgent{} + if c.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true, want false when no hooks") + } +} + +func TestAreHooksInstalled_AfterInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := os.MkdirAll(filepath.Join(tempDir, ".cursor"), 0o750); err != nil { + t.Fatal(err) + } + c := &CursorAgent{} + _, err := c.InstallHooks(false, false) + if err != nil { + t.Fatal(err) + } + if !c.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false, want true after install") + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + if err := os.MkdirAll(filepath.Join(tempDir, ".cursor"), 0o750); err != nil { + t.Fatal(err) + } + c := &CursorAgent{} + _, _ = c.InstallHooks(false, false) + if err := c.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + if c.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true after uninstall, want false") + } +} diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go new file mode 100644 index 000000000..3c6b5774c --- /dev/null +++ b/cmd/entire/cli/agent/cursor/types.go @@ -0,0 +1,68 @@ +package cursor + +import "encoding/json" + +// Cursor hooks.json format uses camelCase keys. +// See: .cursor/hooks.json - {"version":1,"hooks":{"sessionStart":[{"command":"..."}],...}} + +// CursorHooksFile represents the root structure of .cursor/hooks.json +type CursorHooksFile struct { + Version int `json:"version"` + Hooks CursorHooks `json:"hooks"` +} + +// CursorHooks contains hook arrays keyed by camelCase event names +type CursorHooks struct { + SessionStart []CursorHookEntry `json:"sessionStart,omitempty"` + SessionEnd []CursorHookEntry `json:"sessionEnd,omitempty"` + BeforeSubmitPrompt []CursorHookEntry `json:"beforeSubmitPrompt,omitempty"` + Stop []CursorHookEntry `json:"stop,omitempty"` + PreToolUse []CursorHookEntry `json:"preToolUse,omitempty"` + PostToolUse []CursorHookEntry `json:"postToolUse,omitempty"` +} + +// CursorHookEntry represents a single hook command in Cursor's format +type CursorHookEntry struct { + Command string `json:"command"` +} + +// Raw payload types for hook stdin (Cursor-specific field names). +// Cursor may send conversation_id, transcript_path, etc.; we use minimal structs +// that can be extended when discovery provides full schema. + +// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop hooks +type sessionInfoRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` +} + +// userPromptSubmitRaw is the JSON structure from beforeSubmitPrompt hooks +type userPromptSubmitRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt"` +} + +// taskHookInputRaw is the JSON structure from preToolUse[Task] / postToolUse +type taskHookInputRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolInput json.RawMessage `json:"tool_input"` +} + +// postToolHookInputRaw is the JSON structure from postToolUse hooks +type postToolHookInputRaw struct { + SessionID string `json:"session_id"` + ConversationID string `json:"conversation_id"` + TranscriptPath string `json:"transcript_path"` + ToolUseID string `json:"tool_use_id"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse struct { + AgentID string `json:"agentId"` + } `json:"tool_response"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..6f263245b 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -79,12 +79,14 @@ type AgentType string // Agent name constants (registry keys) const ( AgentNameClaudeCode AgentName = "claude-code" + AgentNameCursor AgentName = "cursor" AgentNameGemini AgentName = "gemini" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeCursor AgentType = "Cursor" AgentTypeGemini AgentType = "Gemini CLI" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 8505c9be5..fb2552737 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "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/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -102,6 +103,57 @@ func init() { return handleClaudeCodePostTodo() }) + // Register Cursor handlers + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNameSessionStart, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorSessionStart() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNameSessionEnd, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorSessionEnd() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNameBeforeSubmitPrompt, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorBeforeSubmitPrompt() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNameStop, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorStop() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNamePreTask, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorPreTask() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNamePostTask, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorPostTask() + }) + RegisterHookHandler(agent.AgentNameCursor, cursor.HookNamePostTodo, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCursorPostTodo() + }) + // Register Gemini CLI handlers RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameSessionStart, func() error { enabled, err := IsEnabled() @@ -252,6 +304,7 @@ func newAgentHooksCmd(agentName agent.AgentName, handler agent.HookHandler) *cob func getHookType(hookName string) string { switch hookName { case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo: + // Cursor uses same hook names (pre-task, post-task, post-todo) return "subagent" case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool: return "tool" diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index 8189eb142..fd6aa9b48 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -27,93 +28,78 @@ type hookInputData struct { sessionID string } -// parseAndLogHookInput parses the hook input and sets up logging context. -func parseAndLogHookInput() (*hookInputData, error) { - // Get the agent from the hook command context (e.g., "entire hooks claude-code ...") +// parseHookInputWithType parses hook input from reader using the current hook agent and given hook type. +// Used by both Claude Code and Cursor handlers so each agent can parse its own payload format. +func parseHookInputWithType(hookType agent.HookType, reader io.Reader, logName string) (*hookInputData, error) { ag, err := GetCurrentHookAgent() if err != nil { return nil, fmt.Errorf("failed to get agent: %w", err) } - - // Parse hook input using agent interface - input, err := ag.ParseHookInput(agent.HookUserPromptSubmit, os.Stdin) + input, err := ag.ParseHookInput(hookType, reader) if err != nil { return nil, fmt.Errorf("failed to parse hook input: %w", err) } - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) - logging.Info(logCtx, "user-prompt-submit", - slog.String("hook", "user-prompt-submit"), + logging.Info(logCtx, logName, + slog.String("hook", logName), slog.String("hook_type", "agent"), slog.String("model_session_id", input.SessionID), slog.String("transcript_path", input.SessionRef), ) - sessionID := input.SessionID if sessionID == "" { sessionID = unknownSessionID } + return &hookInputData{agent: ag, input: input, sessionID: sessionID}, nil +} - // Get the Entire session ID, preferring the persisted value to handle midnight boundary - return &hookInputData{ - agent: ag, - input: input, - sessionID: sessionID, - }, nil +// parseAndLogHookInput parses the hook input and sets up logging context (user-prompt-submit). +func parseAndLogHookInput() (*hookInputData, error) { + return parseHookInputWithType(agent.HookUserPromptSubmit, os.Stdin, "user-prompt-submit") } -// captureInitialState captures the initial state on user prompt submit. -func captureInitialState() error { - // Parse hook input and setup logging - hookData, err := parseAndLogHookInput() - if err != nil { - return err +// captureInitialStateFromInput runs capture-initial-state logic given already-parsed hook input. +func captureInitialStateFromInput(ag agent.Agent, input *agent.HookInput) error { + sessionID := input.SessionID + if sessionID == "" { + sessionID = unknownSessionID } - - // CLI captures state directly (including transcript position) - if err := CapturePrePromptState(hookData.sessionID, hookData.input.SessionRef); err != nil { + if err := CapturePrePromptState(sessionID, input.SessionRef); err != nil { return err } - - // If strategy implements SessionInitializer, call it to initialize session state strat := GetStrategy() if initializer, ok := strat.(strategy.SessionInitializer); ok { - agentType := hookData.agent.Type() - if err := initializer.InitializeSession(hookData.sessionID, agentType, hookData.input.SessionRef, hookData.input.UserPrompt); err != nil { + if err := initializer.InitializeSession(sessionID, ag.Type(), input.SessionRef, input.UserPrompt); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", err) } } - return nil } -// commitWithMetadata commits the session changes with metadata. -func commitWithMetadata() error { //nolint:maintidx // already present in codebase - // Get the agent for hook input parsing and session ID transformation - ag, err := GetCurrentHookAgent() +// captureInitialState captures the initial state on user prompt submit. +func captureInitialState() error { + hookData, err := parseAndLogHookInput() if err != nil { - return fmt.Errorf("failed to get agent: %w", err) + return err } + return captureInitialStateFromInput(hookData.agent, hookData.input) +} - // Parse hook input using agent interface - input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) +// commitWithMetadata commits the session changes with metadata. +func commitWithMetadata() error { + hookData, err := parseHookInputWithType(agent.HookStop, os.Stdin, "stop") if err != nil { - return fmt.Errorf("failed to parse hook input: %w", err) + return err } + return commitWithMetadataFromInput(hookData.agent, hookData.input) +} - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) - logging.Info(logCtx, "stop", - slog.String("hook", "stop"), - slog.String("hook_type", "agent"), - slog.String("model_session_id", input.SessionID), - slog.String("transcript_path", input.SessionRef), - ) - +// commitWithMetadataFromInput runs commit/checkpoint logic given already-parsed stop hook input. +func commitWithMetadataFromInput(ag agent.Agent, input *agent.HookInput) error { //nolint:maintidx // large shared stop logic sessionID := input.SessionID if sessionID == "" { sessionID = unknownSessionID } - transcriptPath := input.SessionRef if transcriptPath == "" || !fileExists(transcriptPath) { return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) @@ -130,11 +116,10 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba return fmt.Errorf("failed to create session directory: %w", err) } - // Wait for Claude Code to flush the transcript file. - // The stop hook fires before the transcript is fully written to disk. - // We poll for our own hook_progress sentinel entry in the file tail, - // which guarantees all prior entries have been flushed. - waitForTranscriptFlush(transcriptPath, time.Now()) + // Wait for agent to flush the transcript file (Claude Code writes a sentinel; other agents may not). + if ag.Type() == agent.AgentTypeClaudeCode { + waitForTranscriptFlush(transcriptPath, time.Now()) + } // Copy transcript logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) @@ -294,11 +279,7 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) } - // Get agent type from the currently executing hook agent (authoritative source) - var agentType agent.AgentType - if hookAgent, agentErr := GetCurrentHookAgent(); agentErr == nil { - agentType = hookAgent.Type() - } + agentType := ag.Type() // Get transcript position from pre-prompt state (captured at step/turn start) var transcriptIdentifierAtStart string @@ -308,10 +289,9 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba transcriptLinesAtStart = preState.StepTranscriptStart } - // Calculate token usage for this checkpoint (Claude Code specific) + // Calculate token usage for this checkpoint (Claude Code specific; Cursor/others can be added later) var tokenUsage *agent.TokenUsage - if transcriptPath != "" { - // Subagents are stored in a subagents/ directory next to the main transcript + if ag.Type() == agent.AgentTypeClaudeCode && transcriptPath != "" { subagentsDir := filepath.Join(filepath.Dir(transcriptPath), sessionID, "subagents") usage, err := claudecode.CalculateTotalTokenUsage(transcriptPath, transcriptLinesAtStart, subagentsDir) if err != nil { @@ -385,211 +365,163 @@ func commitWithMetadata() error { //nolint:maintidx // already present in codeba return nil } -// handleClaudeCodePostTodo handles the PostToolUse[TodoWrite] hook for subagent checkpoints. -// Creates a checkpoint if we're in a subagent context (active pre-task file exists). -// Skips silently if not in subagent context (main agent). -func handleClaudeCodePostTodo() error { - input, err := parseSubagentCheckpointHookInput(os.Stdin) - if err != nil { - return fmt.Errorf("failed to parse PostToolUse[TodoWrite] input: %w", err) - } - - // Get agent for logging context - ag, err := GetCurrentHookAgent() - if err != nil { - return fmt.Errorf("failed to get agent: %w", err) - } - - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) - logging.Info(logCtx, "post-todo", - slog.String("hook", "post-todo"), - slog.String("hook_type", "subagent"), - slog.String("model_session_id", input.SessionID), - slog.String("transcript_path", input.TranscriptPath), - slog.String("tool_use_id", input.ToolUseID), - ) - - // Check if we're in a subagent context by looking for an active pre-task file +// runPostTodoLogic runs the post-todo incremental checkpoint logic (shared by Claude Code and Cursor). +func runPostTodoLogic(ag agent.Agent, sessionID, transcriptPath, toolName, toolUseID string, toolInput []byte) { taskToolUseID, found := FindActivePreTaskFile() if !found { - // Not in subagent context - this is a main agent TodoWrite, skip - return nil + return } - - // Skip on default branch to avoid polluting main/master history if skip, branchName := ShouldSkipOnDefaultBranch(); skip { fmt.Fprintf(os.Stderr, "Entire: skipping incremental checkpoint on branch '%s'\n", branchName) - return nil + return } - - // Detect file changes since last checkpoint changes, err := DetectFileChanges(nil) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to detect changed files: %v\n", err) - return nil + return } - - // If no file changes, skip creating a checkpoint if len(changes.Modified) == 0 && len(changes.New) == 0 && len(changes.Deleted) == 0 { fmt.Fprintf(os.Stderr, "[entire] No file changes detected, skipping incremental checkpoint\n") - return nil + return } - - // Get git author author, err := GetGitAuthor() if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to get git author: %v\n", err) - return nil + return } - - // Get the active strategy strat := GetStrategy() - - // Ensure strategy setup is complete if err := strat.EnsureSetup(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) - return nil + return } - - // Get the session ID from the transcript path or input, then transform to Entire session ID - sessionID := input.SessionID if sessionID == "" { - sessionID = paths.ExtractSessionIDFromTranscriptPath(input.TranscriptPath) + sessionID = paths.ExtractSessionIDFromTranscriptPath(transcriptPath) } - - // Get next checkpoint sequence seq := GetNextCheckpointSequence(sessionID, taskToolUseID) - - // Extract the todo content from the tool_input. - // PostToolUse receives the NEW todo list where the just-completed work is - // marked as "completed". The last completed item is the work that was just done. - todoContent := ExtractLastCompletedTodoFromToolInput(input.ToolInput) + todoContent := ExtractLastCompletedTodoFromToolInput(toolInput) if todoContent == "" { - // No completed items - this is likely the first TodoWrite (planning phase). - // Check if there are any todos at all to avoid duplicate messages. - todoCount := CountTodosFromToolInput(input.ToolInput) + todoCount := CountTodosFromToolInput(toolInput) if todoCount > 0 { - // Use "Planning: N todos" format for the first TodoWrite todoContent = fmt.Sprintf("Planning: %d todos", todoCount) } - // If todoCount == 0, todoContent remains empty and FormatIncrementalMessage - // will fall back to "Checkpoint #N" format - } - - // Get agent type from the currently executing hook agent (authoritative source) - var agentType agent.AgentType - if hookAgent, agentErr := GetCurrentHookAgent(); agentErr == nil { - agentType = hookAgent.Type() } - - // Build incremental checkpoint context ctx := strategy.TaskCheckpointContext{ SessionID: sessionID, ToolUseID: taskToolUseID, ModifiedFiles: changes.Modified, NewFiles: changes.New, DeletedFiles: changes.Deleted, - TranscriptPath: input.TranscriptPath, + TranscriptPath: transcriptPath, AuthorName: author.Name, AuthorEmail: author.Email, IsIncremental: true, IncrementalSequence: seq, - IncrementalType: input.ToolName, - IncrementalData: input.ToolInput, + IncrementalType: toolName, + IncrementalData: toolInput, TodoContent: todoContent, - AgentType: agentType, + AgentType: ag.Type(), } - - // Save incremental checkpoint if err := strat.SaveTaskCheckpoint(ctx); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to save incremental checkpoint: %v\n", err) - return nil + return } - fmt.Fprintf(os.Stderr, "[entire] Created incremental checkpoint #%d for %s (task: %s)\n", - seq, input.ToolName, taskToolUseID[:min(12, len(taskToolUseID))]) - return nil + seq, toolName, taskToolUseID[:min(12, len(taskToolUseID))]) } -// handleClaudeCodePreTask handles the PreToolUse[Task] hook -func handleClaudeCodePreTask() error { - input, err := parseTaskHookInput(os.Stdin) - if err != nil { - return fmt.Errorf("failed to parse PreToolUse[Task] input: %w", err) +// handlePostTodoFromInput runs post-todo incremental checkpoint logic given already-parsed hook input. +func handlePostTodoFromInput(ag agent.Agent, input *agent.HookInput) { + toolName := "TodoWrite" + if name, ok := input.RawData["tool_name"].(string); ok && name != "" { + toolName = name + } + sessionID := input.SessionID + if sessionID == "" { + sessionID = paths.ExtractSessionIDFromTranscriptPath(input.SessionRef) } + runPostTodoLogic(ag, sessionID, input.SessionRef, toolName, input.ToolUseID, input.ToolInput) +} - // Get agent for logging context +// handleClaudeCodePostTodo handles the PostToolUse[TodoWrite] hook for subagent checkpoints. +func handleClaudeCodePostTodo() error { + input, err := parseSubagentCheckpointHookInput(os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse PostToolUse[TodoWrite] input: %w", err) + } ag, err := GetCurrentHookAgent() if err != nil { return fmt.Errorf("failed to get agent: %w", err) } - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) - logging.Info(logCtx, "pre-task", - slog.String("hook", "pre-task"), + logging.Info(logCtx, "post-todo", + slog.String("hook", "post-todo"), slog.String("hook_type", "subagent"), slog.String("model_session_id", input.SessionID), slog.String("transcript_path", input.TranscriptPath), slog.String("tool_use_id", input.ToolUseID), ) - - // Log context to stdout - logPreTaskHookContext(os.Stdout, input) - - // Capture pre-task state locally (for computing new files when task completes). - // We don't create a shadow branch commit here. Commits are created during - // task completion (handleClaudeCodePostTask/handleClaudeCodePostTodo) only if the task resulted - // in file changes. - if err := CapturePreTaskState(input.ToolUseID); err != nil { - return fmt.Errorf("failed to capture pre-task state: %w", err) - } - + runPostTodoLogic(ag, input.SessionID, input.TranscriptPath, input.ToolName, input.ToolUseID, input.ToolInput) return nil } -// handleClaudeCodePostTask handles the PostToolUse[Task] hook -func handleClaudeCodePostTask() error { - input, err := parsePostTaskHookInput(os.Stdin) - if err != nil { - return fmt.Errorf("failed to parse PostToolUse[Task] input: %w", err) +// handlePreTaskFromInput runs pre-task logic given already-parsed hook input. +func handlePreTaskFromInput(ag agent.Agent, input *agent.HookInput) error { + taskInput := &TaskHookInput{ + SessionID: input.SessionID, + TranscriptPath: input.SessionRef, + ToolUseID: input.ToolUseID, + ToolInput: input.ToolInput, } + logPreTaskHookContext(os.Stdout, taskInput) + return CapturePreTaskState(input.ToolUseID) +} - // Extract subagent type from tool_input for logging - subagentType, taskDescription := ParseSubagentTypeAndDescription(input.ToolInput) - - // Get agent for logging context - ag, err := GetCurrentHookAgent() +// handleClaudeCodePreTask handles the PreToolUse[Task] hook +func handleClaudeCodePreTask() error { + hookData, err := parseHookInputWithType(agent.HookPreToolUse, os.Stdin, "pre-task") if err != nil { - return fmt.Errorf("failed to get agent: %w", err) + return err } - - // Log parsed input context - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) - logging.Info(logCtx, "post-task", - slog.String("hook", "post-task"), + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()) + logging.Info(logCtx, "pre-task", + slog.String("hook", "pre-task"), slog.String("hook_type", "subagent"), - slog.String("tool_use_id", input.ToolUseID), - slog.String("agent_id", input.AgentID), - slog.String("subagent_type", subagentType), + slog.String("model_session_id", hookData.input.SessionID), + slog.String("transcript_path", hookData.input.SessionRef), + slog.String("tool_use_id", hookData.input.ToolUseID), ) + return handlePreTaskFromInput(hookData.agent, hookData.input) +} - // Determine subagent transcript path - transcriptDir := filepath.Dir(input.TranscriptPath) +// postTaskParams holds the inputs needed to run post-task checkpoint logic. +type postTaskParams struct { + SessionID string + TranscriptPath string + ToolUseID string + AgentID string + ToolInput []byte +} + +// runPostTaskLogic runs the post-task checkpoint logic (shared by Claude Code and Cursor). +func runPostTaskLogic(ag agent.Agent, p postTaskParams) error { + subagentType, taskDescription := ParseSubagentTypeAndDescription(p.ToolInput) + transcriptDir := filepath.Dir(p.TranscriptPath) var subagentTranscriptPath string - if input.AgentID != "" { - subagentTranscriptPath = AgentTranscriptPath(transcriptDir, input.AgentID) + if p.AgentID != "" { + subagentTranscriptPath = AgentTranscriptPath(transcriptDir, p.AgentID) if !fileExists(subagentTranscriptPath) { subagentTranscriptPath = "" } } + postInput := &PostTaskHookInput{ + TaskHookInput: TaskHookInput{SessionID: p.SessionID, TranscriptPath: p.TranscriptPath, ToolUseID: p.ToolUseID, ToolInput: p.ToolInput}, + AgentID: p.AgentID, + ToolInput: p.ToolInput, + } + logPostTaskHookContext(os.Stdout, postInput, subagentTranscriptPath) - // Log context to stdout - logPostTaskHookContext(os.Stdout, input, subagentTranscriptPath) - - // Parse transcript to extract modified files var modifiedFiles []string if subagentTranscriptPath != "" { - // Use subagent transcript if available transcript, err := parseTranscript(subagentTranscriptPath) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to parse subagent transcript: %v\n", err) @@ -597,8 +529,7 @@ func handleClaudeCodePostTask() error { modifiedFiles = extractModifiedFiles(transcript) } } else { - // Fall back to main transcript - transcript, err := parseTranscript(input.TranscriptPath) + transcript, err := parseTranscript(p.TranscriptPath) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to parse transcript: %v\n", err) } else { @@ -606,94 +537,103 @@ func handleClaudeCodePostTask() error { } } - // Load pre-task state and compute new files - preState, err := LoadPreTaskState(input.ToolUseID) + preState, err := LoadPreTaskState(p.ToolUseID) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to load pre-task state: %v\n", err) } - - // Compute new and deleted files (single git status call) changes, err := DetectFileChanges(preState.PreUntrackedFiles()) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to compute file changes: %v\n", err) } - - // Get repo root for path conversion (not cwd, since Claude may be in a subdirectory) - // Using cwd would filter out files in sibling directories (paths starting with ..) repoRoot, err := paths.RepoRoot() if err != nil { return fmt.Errorf("failed to get repo root: %w", err) } - - // Filter and normalize paths relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot) var relNewFiles, relDeletedFiles []string if changes != nil { relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) } - - // If no file changes, skip creating a checkpoint if len(relModifiedFiles) == 0 && len(relNewFiles) == 0 && len(relDeletedFiles) == 0 { fmt.Fprintf(os.Stderr, "[entire] No file changes detected, skipping task checkpoint\n") - // Cleanup pre-task state (ignore error - cleanup is best-effort) - _ = CleanupPreTaskState(input.ToolUseID) //nolint:errcheck // best-effort cleanup + _ = CleanupPreTaskState(p.ToolUseID) //nolint:errcheck // best-effort return nil } - - // Find checkpoint UUID from main transcript (best-effort, ignore errors) - transcript, _ := parseTranscript(input.TranscriptPath) //nolint:errcheck // best-effort extraction - checkpointUUID, _ := FindCheckpointUUID(transcript, input.ToolUseID) - - // Get git author + transcript, _ := parseTranscript(p.TranscriptPath) //nolint:errcheck // best-effort + checkpointUUID, _ := FindCheckpointUUID(transcript, p.ToolUseID) author, err := GetGitAuthor() if err != nil { return fmt.Errorf("failed to get git author: %w", err) } - - // Get the configured strategy strat := GetStrategy() - - // Ensure strategy setup is in place if err := strat.EnsureSetup(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) } - - // Get agent type from the currently executing hook agent (authoritative source) - var agentType agent.AgentType - if hookAgent, agentErr := GetCurrentHookAgent(); agentErr == nil { - agentType = hookAgent.Type() - } - - // Build task checkpoint context - strategy handles metadata creation - // Note: Incremental checkpoints are now created during task execution via handleClaudeCodePostTodo, - // so we don't need to collect/cleanup staging area here. ctx := strategy.TaskCheckpointContext{ - SessionID: input.SessionID, - ToolUseID: input.ToolUseID, - AgentID: input.AgentID, + SessionID: p.SessionID, + ToolUseID: p.ToolUseID, + AgentID: p.AgentID, ModifiedFiles: relModifiedFiles, NewFiles: relNewFiles, DeletedFiles: relDeletedFiles, - TranscriptPath: input.TranscriptPath, + TranscriptPath: p.TranscriptPath, SubagentTranscriptPath: subagentTranscriptPath, CheckpointUUID: checkpointUUID, AuthorName: author.Name, AuthorEmail: author.Email, SubagentType: subagentType, TaskDescription: taskDescription, - AgentType: agentType, + AgentType: ag.Type(), } - - // Call strategy to save task checkpoint - strategy handles all metadata creation if err := strat.SaveTaskCheckpoint(ctx); err != nil { return fmt.Errorf("failed to save task checkpoint: %w", err) } + _ = CleanupPreTaskState(p.ToolUseID) //nolint:errcheck // best-effort + return nil +} - // Cleanup pre-task state (ignore error - cleanup is best-effort) - _ = CleanupPreTaskState(input.ToolUseID) //nolint:errcheck // best-effort cleanup +// handlePostTaskFromInput runs post-task checkpoint logic given already-parsed hook input. +func handlePostTaskFromInput(ag agent.Agent, input *agent.HookInput) error { + agentID := "" + if id, ok := input.RawData["agent_id"].(string); ok { + agentID = id + } + return runPostTaskLogic(ag, postTaskParams{ + SessionID: input.SessionID, + TranscriptPath: input.SessionRef, + ToolUseID: input.ToolUseID, + AgentID: agentID, + ToolInput: input.ToolInput, + }) +} - return nil +// handleClaudeCodePostTask handles the PostToolUse[Task] hook +func handleClaudeCodePostTask() error { + input, err := parsePostTaskHookInput(os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse PostToolUse[Task] input: %w", err) + } + ag, err := GetCurrentHookAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + subagentType, _ := ParseSubagentTypeAndDescription(input.ToolInput) + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "post-task", + slog.String("hook", "post-task"), + slog.String("hook_type", "subagent"), + slog.String("tool_use_id", input.ToolUseID), + slog.String("agent_id", input.AgentID), + slog.String("subagent_type", subagentType), + ) + return runPostTaskLogic(ag, postTaskParams{ + SessionID: input.SessionID, + TranscriptPath: input.TranscriptPath, + ToolUseID: input.ToolUseID, + AgentID: input.AgentID, + ToolInput: input.ToolInput, + }) } // handleClaudeCodeSessionStart handles the SessionStart hook for Claude Code. @@ -701,36 +641,32 @@ func handleClaudeCodeSessionStart() error { return handleSessionStartCommon() } +// handleSessionEndFromInput runs session-end logic given already-parsed hook input. +func handleSessionEndFromInput(ag agent.Agent, input *agent.HookInput) error { + if input.SessionID == "" { + return nil + } + if err := markSessionEnded(input.SessionID); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to mark session ended: %v\n", err) + } + return nil +} + // handleClaudeCodeSessionEnd handles the SessionEnd hook for Claude Code. // This fires when the user explicitly closes the session. // Updates the session state with EndedAt timestamp. func handleClaudeCodeSessionEnd() error { - ag, err := GetCurrentHookAgent() - if err != nil { - return fmt.Errorf("failed to get agent: %w", err) - } - - input, err := ag.ParseHookInput(agent.HookSessionEnd, os.Stdin) + hookData, err := parseHookInputWithType(agent.HookSessionEnd, os.Stdin, "session-end") if err != nil { - return fmt.Errorf("failed to parse hook input: %w", err) + return err } - - logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()) logging.Info(logCtx, "session-end", slog.String("hook", "session-end"), slog.String("hook_type", "agent"), - slog.String("model_session_id", input.SessionID), + slog.String("model_session_id", hookData.input.SessionID), ) - - if input.SessionID == "" { - return nil // No session to update - } - - // Best-effort cleanup - don't block session closure on failure - if err := markSessionEnded(input.SessionID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to mark session ended: %v\n", err) - } - return nil + return handleSessionEndFromInput(hookData.agent, hookData.input) } // transitionSessionTurnEnd fires EventTurnEnd to move the session from diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..ad7f0e271 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,6 +4,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" diff --git a/cmd/entire/cli/hooks_cursor_handlers.go b/cmd/entire/cli/hooks_cursor_handlers.go new file mode 100644 index 000000000..57f6db004 --- /dev/null +++ b/cmd/entire/cli/hooks_cursor_handlers.go @@ -0,0 +1,92 @@ +// hooks_cursor_handlers.go contains Cursor-specific hook handler implementations. +// Cursor uses .cursor/hooks.json and sends payloads that are parsed by agent/cursor. +package cli + +import ( + "context" + "log/slog" + "os" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" +) + +func handleCursorSessionStart() error { + return handleSessionStartCommon() +} + +func handleCursorSessionEnd() error { + hookData, err := parseHookInputWithType(agent.HookSessionEnd, os.Stdin, "session-end") + if err != nil { + return err + } + logging.Info(logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()), "session-end", + slog.String("hook", "session-end"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", hookData.input.SessionID), + ) + return handleSessionEndFromInput(hookData.agent, hookData.input) +} + +func handleCursorBeforeSubmitPrompt() error { + hookData, err := parseHookInputWithType(agent.HookUserPromptSubmit, os.Stdin, "before-submit-prompt") + if err != nil { + return err + } + return captureInitialStateFromInput(hookData.agent, hookData.input) +} + +func handleCursorStop() error { + hookData, err := parseHookInputWithType(agent.HookStop, os.Stdin, "stop") + if err != nil { + return err + } + return commitWithMetadataFromInput(hookData.agent, hookData.input) +} + +func handleCursorPreTask() error { + hookData, err := parseHookInputWithType(agent.HookPreToolUse, os.Stdin, "pre-task") + if err != nil { + return err + } + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()) + logging.Info(logCtx, "pre-task", + slog.String("hook", "pre-task"), + slog.String("hook_type", "subagent"), + slog.String("model_session_id", hookData.input.SessionID), + slog.String("transcript_path", hookData.input.SessionRef), + slog.String("tool_use_id", hookData.input.ToolUseID), + ) + return handlePreTaskFromInput(hookData.agent, hookData.input) +} + +func handleCursorPostTask() error { + hookData, err := parseHookInputWithType(agent.HookPostToolUse, os.Stdin, "post-task") + if err != nil { + return err + } + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()) + logging.Info(logCtx, "post-task", + slog.String("hook", "post-task"), + slog.String("hook_type", "subagent"), + slog.String("tool_use_id", hookData.input.ToolUseID), + ) + return handlePostTaskFromInput(hookData.agent, hookData.input) +} + +func handleCursorPostTodo() error { + hookData, err := parseHookInputWithType(agent.HookPostToolUse, os.Stdin, "post-todo") + if err != nil { + return err + } + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), hookData.agent.Name()) + logging.Info(logCtx, "post-todo", + slog.String("hook", "post-todo"), + slog.String("hook_type", "subagent"), + slog.String("model_session_id", hookData.input.SessionID), + slog.String("transcript_path", hookData.input.SessionRef), + slog.String("tool_use_id", hookData.input.ToolUseID), + ) + handlePostTodoFromInput(hookData.agent, hookData.input) + return nil +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 381c9993a..60c118f1a 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -23,6 +23,8 @@ const ( EntireSettingsFile = ".entire/settings.json" // EntireSettingsLocalFile is the path to the local settings override file (not committed) EntireSettingsLocalFile = ".entire/settings.local.json" + // CursorEntireSettingsFile is the path to Cursor-specific Entire settings (used when agent is Cursor) + CursorEntireSettingsFile = ".cursor/entire.json" ) // EntireSettings represents the .entire/settings.json configuration @@ -87,9 +89,27 @@ func Load() (*EntireSettings, error) { applyDefaults(settings) + // Apply Cursor-specific overrides if .cursor/entire.json exists (when using Cursor agent) + cursorData, err := os.ReadFile(cursorEntireSettingsAbs()) + if err == nil { + if err := mergeJSON(settings, cursorData); err != nil { + return nil, fmt.Errorf("merging Cursor settings: %w", err) + } + } + // Ignore os.IsNotExist; missing file means no Cursor overrides + return settings, nil } +// cursorEntireSettingsAbs returns the absolute path to .cursor/entire.json (repo-root relative). +func cursorEntireSettingsAbs() string { + abs, err := paths.AbsPath(CursorEntireSettingsFile) + if err != nil { + return CursorEntireSettingsFile + } + return abs +} + // LoadFromFile loads settings from a specific file path without merging local overrides. // Returns default settings if the file doesn't exist. // Use this when you need to display individual settings files separately. @@ -255,6 +275,51 @@ func (s *EntireSettings) IsPushSessionsDisabled() bool { return false } +// LoadCursorEntireSettings loads Cursor-specific settings from .cursor/entire.json. +// Returns (nil, nil) if the file does not exist. Caller can merge over base settings when agent is Cursor. +func LoadCursorEntireSettings() (*EntireSettings, error) { + path := cursorEntireSettingsAbs() + data, err := os.ReadFile(path) //nolint:gosec // path from paths.AbsPath + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", CursorEntireSettingsFile, err) + } + s := &EntireSettings{ + Strategy: DefaultStrategyName, + Enabled: true, + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(s); err != nil { + return nil, fmt.Errorf("parsing %s: %w", CursorEntireSettingsFile, err) + } + applyDefaults(s) + return s, nil +} + +// SaveCursorEntireSettings saves settings to .cursor/entire.json (for Cursor agent only). +func SaveCursorEntireSettings(settings *EntireSettings) error { + pathAbs, err := paths.AbsPath(CursorEntireSettingsFile) + if err != nil { + pathAbs = CursorEntireSettingsFile + } + dir := filepath.Dir(pathAbs) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("creating .cursor directory: %w", err) + } + data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ") + if err != nil { + return fmt.Errorf("marshaling settings: %w", err) + } + //nolint:gosec // G306: config file, not secrets + if err := os.WriteFile(pathAbs, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", CursorEntireSettingsFile, err) + } + return nil +} + // Save saves the settings to .entire/settings.json. func Save(settings *EntireSettings) error { return saveToFile(settings, EntireSettingsFile) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 435881ac9..d4f3e1459 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -12,6 +12,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" + setpkg "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/charmbracelet/huh" @@ -602,6 +603,13 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str return fmt.Errorf("failed to save settings: %w", err) } + // When enabling Cursor, also write .cursor/entire.json (Cursor does not use .claude/settings.json) + if agentName == agent.AgentNameCursor { + if err := setpkg.SaveCursorEntireSettings(settings); err != nil { + return fmt.Errorf("failed to save Cursor settings: %w", err) + } + } + // Install git hooks AFTER saving settings (InstallGitHook reads local_dev from settings) if _, err := strategy.InstallGitHook(true); err != nil { return fmt.Errorf("failed to install git hooks: %w", err) @@ -609,13 +617,13 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str if installedHooks == 0 { msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) - if agentName == agent.AgentNameGemini { + if agentName == agent.AgentNameGemini || agentName == agent.AgentNameCursor { msg += " (Preview)" } fmt.Fprintf(w, "%s\n", msg) } else { msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description()) - if agentName == agent.AgentNameGemini { + if agentName == agent.AgentNameGemini || agentName == agent.AgentNameCursor { msg += " (Preview)" } fmt.Fprintf(w, "%s\n", msg) @@ -1073,33 +1081,22 @@ func checkEntireDirExists() bool { // removeAgentHooks removes hooks from all agents that support hooks. func removeAgentHooks(w io.Writer) error { var errs []error - - // Remove Claude Code hooks - claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) - if err == nil { - if hookAgent, ok := claudeAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Claude Code hooks") - } + for _, name := range agent.List() { + ag, err := agent.Get(name) + if err != nil { + continue } - } - - // Remove Gemini CLI hooks - geminiAgent, err := agent.Get(agent.AgentNameGemini) - if err == nil { - if hookAgent, ok := geminiAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Gemini CLI hooks") - } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + continue + } + wasInstalled := hookAgent.AreHooksInstalled() + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, err) + } else if wasInstalled { + fmt.Fprintf(w, " Removed %s hooks\n", ag.Type()) } } - return errors.Join(errs...) } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 6f7475694..0666cb1fc 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -386,7 +386,7 @@ func countTranscriptItems(agentType agent.AgentType, content string) int { // Otherwise fall through to JSONL parsing for Unknown type } - // Claude Code and other JSONL-based agents + // Claude Code, Cursor, and other JSONL-based agents allLines := strings.Split(content, "\n") // Trim trailing empty lines (from final \n in JSONL) for len(allLines) > 0 && strings.TrimSpace(allLines[len(allLines)-1]) == "" { @@ -422,7 +422,7 @@ func extractUserPrompts(agentType agent.AgentType, content string) []string { // Otherwise fall through to JSONL parsing for Unknown type } - // Claude Code and other JSONL-based agents + // Claude Code, Cursor, and other JSONL-based agents return extractUserPromptsFromLines(strings.Split(content, "\n")) } @@ -449,7 +449,7 @@ func calculateTokenUsage(agentType agent.AgentType, data []byte, startOffset int // Otherwise fall through to JSONL parsing for Unknown type } - // Claude Code and other JSONL-based agents + // Claude Code, Cursor, and other JSONL-based agents lines, err := claudecode.ParseTranscript(data) if err != nil || len(lines) == 0 { return &agent.TokenUsage{}