diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index 6b9c5f397..78199d821 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -24,10 +24,9 @@ func (m *mockAgent) SupportsHooks() bool { return false } func (m *mockAgent) ParseHookInput(_ HookType, _ io.Reader) (*HookInput, error) { return nil, nil } -func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" } -func (m *mockAgent) TransformSessionID(agentID string) string { return agentID } -func (m *mockAgent) ProtectedDirs() []string { return nil } -func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil } +func (m *mockAgent) GetSessionID(_ *HookInput) string { return "" } +func (m *mockAgent) ProtectedDirs() []string { return nil } +func (m *mockAgent) GetSessionDir(_ string) (string, error) { return "", nil } func (m *mockAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { return sessionDir + "/" + agentSessionID + ".jsonl" } diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go new file mode 100644 index 000000000..b9193be6d --- /dev/null +++ b/cmd/entire/cli/agent/codex/codex.go @@ -0,0 +1,486 @@ +// Package codex implements the Agent interface for OpenAI's Codex CLI. +package codex + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "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.AgentNameCodex, NewCodexAgent) +} + +// CodexAgent implements the Agent interface for Codex CLI. +// +//nolint:revive // CodexAgent is clearer than Agent in this context +type CodexAgent struct{} + +// NewCodexAgent creates a new Codex CLI agent instance. +func NewCodexAgent() agent.Agent { + return &CodexAgent{} +} + +// Name returns the agent registry key. +func (c *CodexAgent) Name() agent.AgentName { + return agent.AgentNameCodex +} + +// Type returns the agent type identifier. +func (c *CodexAgent) Type() agent.AgentType { + return agent.AgentTypeCodex +} + +// Description returns a human-readable description. +func (c *CodexAgent) Description() string { + return "Codex CLI - OpenAI's AI coding assistant" +} + +// DetectPresence checks if Codex CLI is configured in the repository. +func (c *CodexAgent) DetectPresence() (bool, error) { + // Get repo root to check for .codex directory + // This is needed because the CLI may be run from a subdirectory + repoRoot, err := paths.RepoRoot() + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + // Check for .codex directory (if it doesn't exist, config.toml inside it won't either) + codexDir := filepath.Join(repoRoot, ".codex") + if _, err := os.Stat(codexDir); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to Codex's hook config file. +func (c *CodexAgent) GetHookConfigPath() string { + return ".codex/config.toml" +} + +// SupportsHooks returns true as Codex CLI supports the notify hook. +func (c *CodexAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Codex CLI hook input from stdin. +// Codex sends a JSON payload to the notify command. +func (c *CodexAgent) 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{}), + } + + var payload notifyPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse notify payload: %w", err) + } + + input.SessionID = payload.ThreadID + input.RawData["type"] = payload.Type + input.RawData["turn_id"] = payload.TurnID + input.RawData["cwd"] = payload.Cwd + + // Extract user prompt from input-messages + if len(payload.InputMessages) > 0 { + input.UserPrompt = payload.InputMessages[len(payload.InputMessages)-1] + } + + // Resolve transcript path from session dir + sessionDir, dirErr := c.getCodexHome() + if dirErr == nil { + input.SessionRef = filepath.Join(sessionDir, "sessions") + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (c *CodexAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ProtectedDirs returns directories that Codex uses for config/state. +func (c *CodexAgent) ProtectedDirs() []string { return []string{".codex"} } + +// GetSessionDir returns the directory where Codex stores session transcripts. +// Codex stores sessions in ~/.codex/sessions/ (respects CODEX_HOME). +func (c *CodexAgent) GetSessionDir(_ string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_CODEX_PROJECT_DIR"); override != "" { + return override, nil + } + + codexHome, err := c.getCodexHome() + if err != nil { + return "", err + } + + return filepath.Join(codexHome, "sessions"), nil +} + +// getCodexHome returns the Codex home directory. +// Respects CODEX_HOME env var, defaults to ~/.codex. +func (c *CodexAgent) getCodexHome() (string, error) { + if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { + return codexHome, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".codex"), nil +} + +// ResolveSessionFile returns the path to a Codex session file. +// Codex stores sessions as rollout-*.jsonl in date-based directories (YYYY/MM/DD/). +// Searches recursively for an existing file matching the session ID, falls back to a default path. +func (c *CodexAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + // filepath.Glob does NOT support ** recursive matching. + // Walk the directory tree to find rollout files containing the session ID. + var found string + //nolint:errcheck // WalkDir errors are non-fatal; fallback path handles missing dirs + _ = filepath.WalkDir(sessionDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // Skip inaccessible directories + } + if d.IsDir() { + return nil + } + name := d.Name() + if strings.HasSuffix(name, ".jsonl") && strings.Contains(name, agentSessionID) { + found = path + // Don't stop early — keep walking to find the latest (deepest) match + } + return nil + }) + + if found != "" { + return found + } + + // Fallback: construct a default path + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ReadSession reads a session from Codex's storage (JSONL rollout file). +// The session data is stored in NativeData as raw JSONL bytes. +func (c *CodexAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (file path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: c.Name(), + SessionRef: input.SessionRef, + NativeData: data, + }, nil +} + +// WriteSession writes a session to Codex's storage (JSONL rollout file). +func (c *CodexAgent) 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 (file path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write session file: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Codex CLI session. +func (c *CodexAgent) FormatResumeCommand(sessionID string) string { + return "codex resume " + sessionID +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current line count of a Codex transcript. +// Codex uses JSONL format, so position is the number of lines. +// Returns 0 if the file doesn't exist or is empty. +func (c *CodexAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from Codex 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 { + lineData, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + // Count a final partial line (no trailing newline) + if len(lineData) > 0 { + lineCount++ + } + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +// For Codex (JSONL format), offset is the starting line number. +// Returns: +// - files: list of file paths modified by Codex (from file_change events) +// - currentPosition: total number of lines in the file +// - error: any error encountered during reading +func (c *CodexAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec // Path comes from Codex transcript location + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + fileSet := make(map[string]bool) + lineNum := 0 + + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(lineData) > 0 { + lineNum++ + if lineNum > startOffset { + for _, fp := range extractFilePathsFromLine(lineData) { + if !fileSet[fp] { + fileSet[fp] = true + files = append(files, fp) + } + } + } + } + + if readErr == io.EOF { + break + } + } + + return files, lineNum, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSONL transcript at line boundaries. +// Codex uses JSONL format, so chunking is done at newline boundaries. +func (c *CodexAgent) 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. +// +//nolint:unparam // error return is required by interface, kept for consistency +func (c *CodexAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// ExtractModifiedFiles extracts file paths from Codex transcript data. +// Parses JSONL events and collects file paths from file_change, function_call, +// and event_msg (patch_apply_begin) items. +// Uses bufio.NewReader instead of Scanner to avoid the 64KB default token limit. +func ExtractModifiedFiles(data []byte) []string { + fileSet := make(map[string]bool) + var files []string + + reader := bufio.NewReader(bytes.NewReader(data)) + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + break + } + + if len(lineData) > 0 { + for _, fp := range extractFilePathsFromLine(lineData) { + if !fileSet[fp] { + fileSet[fp] = true + files = append(files, fp) + } + } + } + + if readErr == io.EOF { + break + } + } + + return files +} + +// extractFilePathsFromLine extracts file paths from a single JSONL line. +// Handles multiple Codex event formats: +// - response_item with file_change or function_call items (via "item" field) +// - event_msg with patch_apply_begin payload (file paths are HashMap keys in "changes") +// +// Returns nil if no file paths are found. +func extractFilePathsFromLine(lineData []byte) []string { + var event rolloutEvent + if json.Unmarshal(lineData, &event) != nil { + return nil + } + + // Check for event_msg events (e.g., patch_apply_begin with changes HashMap) + // See codex-rs/exec/src/exec_events.rs: PatchApplyBeginEvent { changes: HashMap } + if event.Type == "event_msg" && len(event.Payload) > 0 { + return extractFilePathsFromEventMsg(event.Payload) + } + + // Check for response_item events with file-modifying items + if event.Item == nil { + return nil + } + + var item rolloutItem + if json.Unmarshal(event.Item, &item) != nil { + return nil + } + + // Check for file_change items + if item.Type == ItemTypeFileChange { + if fp := extractFilePathFromItem(event.Item); fp != "" { + return []string{fp} + } + } + + // Check for function_call items that modify files (e.g., write_file, apply_diff) + if item.Type == ItemTypeFunctionCall { + if fp := extractFilePathFromFunctionCall(event.Item); fp != "" { + return []string{fp} + } + } + + return nil +} + +// extractFilePathsFromEventMsg extracts file paths from an event_msg payload. +// For patch_apply_begin events, file paths are the keys of the changes HashMap. +func extractFilePathsFromEventMsg(payload json.RawMessage) []string { + var msg eventMsgPayload + if json.Unmarshal(payload, &msg) != nil { + return nil + } + + if msg.Type != EventMsgTypePatchApplyBegin || len(msg.Changes) == 0 { + return nil + } + + paths := make([]string, 0, len(msg.Changes)) + for filePath := range msg.Changes { + if filePath != "" { + paths = append(paths, filePath) + } + } + return paths +} + +// extractFilePathFromItem extracts a file path from an item's various path fields. +func extractFilePathFromItem(data json.RawMessage) string { + var fileItem struct { + FilePath string `json:"file_path"` + Path string `json:"path"` + Filename string `json:"filename"` + } + if json.Unmarshal(data, &fileItem) != nil { + return "" + } + + if fileItem.FilePath != "" { + return fileItem.FilePath + } + if fileItem.Path != "" { + return fileItem.Path + } + return fileItem.Filename +} + +// extractFilePathFromFunctionCall extracts a file path from a function call's input. +func extractFilePathFromFunctionCall(data json.RawMessage) string { + var fc struct { + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } + if json.Unmarshal(data, &fc) != nil { + return "" + } + + // Only extract from file-modifying functions + switch fc.Name { + case "write_file", "apply_diff", "edit_file", "create_file": + return extractFilePathFromItem(fc.Input) + default: + return "" + } +} diff --git a/cmd/entire/cli/agent/codex/codex_test.go b/cmd/entire/cli/agent/codex/codex_test.go new file mode 100644 index 000000000..ff84ab5ad --- /dev/null +++ b/cmd/entire/cli/agent/codex/codex_test.go @@ -0,0 +1,671 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +const testThreadID = "abc-123" + +func TestNewCodexAgent(t *testing.T) { + t.Parallel() + + ag := NewCodexAgent() + if ag == nil { + t.Fatal("NewCodexAgent() returned nil") + } + + cx, ok := ag.(*CodexAgent) + if !ok { + t.Fatal("NewCodexAgent() didn't return *CodexAgent") + } + if cx == nil { + t.Fatal("NewCodexAgent() returned nil agent") + } +} + +func TestName(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + if name := ag.Name(); name != agent.AgentNameCodex { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameCodex) + } +} + +func TestType(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + if agType := ag.Type(); agType != agent.AgentTypeCodex { + t.Errorf("Type() = %q, want %q", agType, agent.AgentTypeCodex) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } +} + +func TestDetectPresence(t *testing.T) { + t.Run("no .codex directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) + + t.Run("with .codex directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.Mkdir(".codex", 0o755); err != nil { + t.Fatalf("failed to create .codex: %v", err) + } + + ag := &CodexAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("with .codex/config.toml", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.MkdirAll(".codex", 0o755); err != nil { + t.Fatalf("failed to create .codex: %v", err) + } + if err := os.WriteFile(filepath.Join(".codex", "config.toml"), []byte(""), 0o600); err != nil { + t.Fatalf("failed to create config.toml: %v", err) + } + + ag := &CodexAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) +} + +func TestParseHookInput_AgentTurnComplete(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + input := `{"type":"agent-turn-complete","thread-id":"abc-123","turn-id":"turn-456","cwd":"/tmp","input-messages":["Fix the bug"],"last-assistant-message":"Done"}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != testThreadID { + t.Errorf("SessionID = %q, want %q", result.SessionID, testThreadID) + } + if result.UserPrompt != "Fix the bug" { + t.Errorf("UserPrompt = %q, want %q", result.UserPrompt, "Fix the bug") + } +} + +func TestParseHookInput_NullLastAssistantMessage(t *testing.T) { + t.Parallel() + + // Codex's last_assistant_message is Option in Rust, which serializes + // as null when None. Go's json.Unmarshal handles null → "" for string fields. + ag := &CodexAgent{} + input := `{"type":"agent-turn-complete","thread-id":"abc-123","turn-id":"turn-456","cwd":"/tmp","input-messages":["hello"],"last-assistant-message":null}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() should handle null last-assistant-message, got error: %v", err) + } + + if result.SessionID != testThreadID { + t.Errorf("SessionID = %q, want %q", result.SessionID, testThreadID) + } +} + +func TestParseHookInput_MissingLastAssistantMessage(t *testing.T) { + t.Parallel() + + // Field omitted entirely (also valid from Codex) + ag := &CodexAgent{} + input := `{"type":"agent-turn-complete","thread-id":"abc-123","turn-id":"turn-456","cwd":"/tmp","input-messages":["hello"]}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() should handle missing last-assistant-message, got error: %v", err) + } + + if result.SessionID != testThreadID { + t.Errorf("SessionID = %q, want %q", result.SessionID, testThreadID) + } +} + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + result := ag.ResolveSessionFile("/home/user/.codex/sessions", testThreadID) + expected := "/home/user/.codex/sessions/" + testThreadID + ".jsonl" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".codex" { + t.Errorf("ProtectedDirs() = %v, want [.codex]", dirs) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + cmd := ag.FormatResumeCommand(testThreadID) + expected := "codex resume " + testThreadID + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestGetHookNames(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + names := ag.GetHookNames() + if len(names) != 1 || names[0] != HookNameAgentTurnComplete { + t.Errorf("GetHookNames() = %v, want [%s]", names, HookNameAgentTurnComplete) + } +} + +func TestInstallHooks(t *testing.T) { + t.Run("installs notify hook", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + // Verify file contents + data, err := os.ReadFile(filepath.Join(".codex", "config.toml")) + if err != nil { + t.Fatalf("failed to read config.toml: %v", err) + } + content := string(data) + if !strings.Contains(content, `notify = ["entire", "hooks", "codex", "agent-turn-complete"]`) { + t.Errorf("config.toml missing notify line, got:\n%s", content) + } + }) + + t.Run("idempotent install", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + // First install + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install should be no-op + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("second InstallHooks() count = %d, want 0", count) + } + }) + + t.Run("local dev mode", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + count, err := ag.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + data, err := os.ReadFile(filepath.Join(".codex", "config.toml")) + if err != nil { + t.Fatalf("failed to read config.toml: %v", err) + } + content := string(data) + if !strings.Contains(content, `"go"`) || !strings.Contains(content, "entire") { + t.Errorf("config.toml missing local dev notify line, got:\n%s", content) + } + // Verify env var was resolved at write-time (no ${...} templates) + if strings.Contains(content, "${") { + t.Errorf("config.toml contains unresolved env var template, got:\n%s", content) + } + }) +} + +func TestInstallHooks_ForceRemovesExistingNotify(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Pre-create config with a user notify line + configPath := filepath.Join(".codex", "config.toml") + if err := os.MkdirAll(".codex", 0o750); err != nil { + t.Fatalf("failed to create .codex: %v", err) + } + if err := os.WriteFile(configPath, []byte("notify = [\"my-custom-tool\"]\n"), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + ag := &CodexAgent{} + count, err := ag.InstallHooks(false, true) // force=true + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 1 { + t.Errorf("InstallHooks() count = %d, want 1", count) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + content := string(data) + + // Should have exactly one notify line (Entire's), not two + if strings.Count(content, "notify = ") != 1 { + t.Errorf("expected exactly 1 notify line, got:\n%s", content) + } + if !strings.Contains(content, `"entire"`) { + t.Errorf("expected Entire notify line, got:\n%s", content) + } + if strings.Contains(content, "my-custom-tool") { + t.Error("force install should have removed the user's custom notify line") + } +} + +func TestAreHooksInstalled(t *testing.T) { + t.Run("not installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true, want false") + } + }) + + t.Run("installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + _, 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) { + t.Run("uninstalls notify hook", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true after uninstall") + } + }) + + t.Run("no config file", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CodexAgent{} + if err := ag.UninstallHooks(); err != nil { + t.Errorf("UninstallHooks() error = %v, want nil", err) + } + }) +} + +func TestGetSessionDir(t *testing.T) { + // Not parallel: subtests use t.Setenv which modifies process-global state + + t.Run("with test override", func(t *testing.T) { + t.Setenv("ENTIRE_TEST_CODEX_PROJECT_DIR", "/tmp/test-codex") + ag := &CodexAgent{} + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/tmp/test-codex" { + t.Errorf("GetSessionDir() = %q, want %q", dir, "/tmp/test-codex") + } + }) + + t.Run("with CODEX_HOME", func(t *testing.T) { + t.Setenv("CODEX_HOME", "/custom/codex") + t.Setenv("ENTIRE_TEST_CODEX_PROJECT_DIR", "") // Clear test override + ag := &CodexAgent{} + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + expected := "/custom/codex/sessions" + if dir != expected { + t.Errorf("GetSessionDir() = %q, want %q", dir, expected) + } + }) +} + +func TestIsEntireNotifyLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + want bool + }{ + { + name: "entire production hook", + line: `notify = ["entire", "hooks", "codex", "agent-turn-complete"]`, + want: true, + }, + { + name: "entire local dev hook with env var", + line: `notify = ["go", "run", "${CODEX_PROJECT_DIR}/cmd/entire/main.go", "hooks", "codex", "agent-turn-complete"]`, + want: true, + }, + { + name: "entire local dev hook with resolved path", + line: `notify = ["go", "run", "/home/user/projects/entire/cmd/entire/main.go", "hooks", "codex", "agent-turn-complete"]`, + want: true, + }, + { + name: "user custom hook", + line: `notify = ["python3", "/path/to/my/script.py"]`, + want: false, + }, + { + name: "empty notify", + line: `notify = []`, + want: false, + }, + { + name: "false positive - tool name containing entire substring", + line: `notify = ["my-entire-tool"]`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isEntireNotifyLine(tt.line); got != tt.want { + t.Errorf("isEntireNotifyLine(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + +func TestWriteSession_CreatesParentDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + sessionPath := filepath.Join(tempDir, "nested", "dir", "session.jsonl") + + ag := &CodexAgent{} + err := ag.WriteSession(&agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameCodex, + SessionRef: sessionPath, + NativeData: []byte(`{"type":"session_meta"}`), + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + if _, err := os.Stat(sessionPath); os.IsNotExist(err) { + t.Error("WriteSession() did not create file") + } +} + +func TestGetTranscriptPosition_NoTrailingNewline(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + // Create a JSONL file with 3 lines, no trailing newline on last line + filePath := filepath.Join(tempDir, "rollout.jsonl") + content := `{"type":"session_meta"} +{"type":"response_item"} +{"type":"turn_context"}` + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &CodexAgent{} + pos, err := ag.GetTranscriptPosition(filePath) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 3 { + t.Errorf("GetTranscriptPosition() = %d, want 3 (should count final line without trailing newline)", pos) + } +} + +func TestResolveSessionFile_RecursiveSearch(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + // Create a nested date-based directory structure like Codex uses + nestedDir := filepath.Join(tempDir, "2026", "02", "11") + if err := os.MkdirAll(nestedDir, 0o750); err != nil { + t.Fatalf("failed to create dirs: %v", err) + } + + // Create rollout file in nested dir + rolloutFile := filepath.Join(nestedDir, "rollout-abc123.jsonl") + if err := os.WriteFile(rolloutFile, []byte(`{"type":"session_meta"}`), 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + ag := &CodexAgent{} + result := ag.ResolveSessionFile(tempDir, "abc123") + if result != rolloutFile { + t.Errorf("ResolveSessionFile() = %q, want %q", result, rolloutFile) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + data := []byte(`{"type":"item.completed","item":{"type":"file_change","file_path":"src/main.go"}} +{"type":"item.completed","item":{"type":"agent_message","text":"Done"}} +{"type":"item.completed","item":{"type":"file_change","path":"README.md"}} +`) + + files := ExtractModifiedFiles(data) + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() returned %d files, want 2", len(files)) + } + if len(files) >= 1 && files[0] != "src/main.go" { + t.Errorf("files[0] = %q, want %q", files[0], "src/main.go") + } + if len(files) >= 2 && files[1] != "README.md" { + t.Errorf("files[1] = %q, want %q", files[1], "README.md") + } +} + +func TestExtractModifiedFiles_EventMsgPatchApplyBegin(t *testing.T) { + t.Parallel() + + // Actual Codex rollout format: event_msg with patch_apply_begin payload + // File paths are HashMap keys in the changes object + data := []byte(`{"type":"event_msg","payload":{"type":"patch_apply_begin","changes":{"src/main.rs":{"status":"modified"},"src/lib.rs":{"status":"added"}}}} +{"type":"session_meta","payload":{"session_id":"abc"}} +{"type":"event_msg","payload":{"type":"patch_apply_begin","changes":{"README.md":{"status":"modified"}}}} +`) + + files := ExtractModifiedFiles(data) + if len(files) != 3 { + t.Fatalf("ExtractModifiedFiles() returned %d files, want 3, got: %v", len(files), files) + } + + // Build a set for order-independent checking (HashMap iteration order is non-deterministic) + fileSet := make(map[string]bool) + for _, f := range files { + fileSet[f] = true + } + + for _, expected := range []string{"src/main.rs", "src/lib.rs", "README.md"} { + if !fileSet[expected] { + t.Errorf("expected file %q not found in result %v", expected, files) + } + } +} + +func TestExtractModifiedFiles_MixedFormats(t *testing.T) { + t.Parallel() + + // Mix of old-style item events and new event_msg format + data := []byte(`{"type":"item.completed","item":{"type":"file_change","file_path":"old-format.go"}} +{"type":"event_msg","payload":{"type":"patch_apply_begin","changes":{"new-format.rs":{"status":"modified"}}}} +{"type":"item.completed","item":{"type":"function_call","name":"write_file","input":{"file_path":"func-call.py"}}} +`) + + files := ExtractModifiedFiles(data) + if len(files) != 3 { + t.Fatalf("ExtractModifiedFiles() returned %d files, want 3, got: %v", len(files), files) + } + + fileSet := make(map[string]bool) + for _, f := range files { + fileSet[f] = true + } + + for _, expected := range []string{"old-format.go", "new-format.rs", "func-call.py"} { + if !fileSet[expected] { + t.Errorf("expected file %q not found in result %v", expected, files) + } + } +} + +func TestExtractModifiedFiles_LargeLine(t *testing.T) { + t.Parallel() + + // Verify that lines > 64KB are handled correctly (bufio.Scanner would fail) + longValue := strings.Repeat("a", 100_000) + data := []byte(`{"type":"event_msg","payload":{"type":"patch_apply_begin","changes":{"large-file.txt":{"status":"modified"}}}} +{"type":"response_item","payload":{"text":"` + longValue + `"}} +{"type":"event_msg","payload":{"type":"patch_apply_begin","changes":{"after-large.txt":{"status":"added"}}}} +`) + + files := ExtractModifiedFiles(data) + fileSet := make(map[string]bool) + for _, f := range files { + fileSet[f] = true + } + + if !fileSet["large-file.txt"] { + t.Error("expected large-file.txt in result") + } + if !fileSet["after-large.txt"] { + t.Error("expected after-large.txt in result (should not be lost after large line)") + } +} + +func TestInstallHooks_NoOverwriteUserNotify(t *testing.T) { + // Test that InstallHooks respects existing non-Entire notify lines + // even when an Entire notify line was previously found and removed. + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Pre-create config with both an Entire line and a user line + configPath := filepath.Join(".codex", "config.toml") + if err := os.MkdirAll(".codex", 0o750); err != nil { + t.Fatalf("failed to create .codex: %v", err) + } + content := "notify = [\"my-custom-tool\"]\n" + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + ag := &CodexAgent{} + // Without force, should not overwrite user's custom notify line + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("InstallHooks() count = %d, want 0 (should not overwrite user notify)", count) + } + + // Verify user's line is still there + data, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatalf("failed to read config: %v", readErr) + } + if !strings.Contains(string(data), "my-custom-tool") { + t.Error("user's custom notify line was removed") + } +} diff --git a/cmd/entire/cli/agent/codex/hooks.go b/cmd/entire/cli/agent/codex/hooks.go new file mode 100644 index 000000000..dea539683 --- /dev/null +++ b/cmd/entire/cli/agent/codex/hooks.go @@ -0,0 +1,271 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure CodexAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*CodexAgent)(nil) + _ agent.HookHandler = (*CodexAgent)(nil) +) + +// Codex CLI hook names - these become subcommands under `entire hooks codex` +const ( + HookNameAgentTurnComplete = "agent-turn-complete" +) + +// CodexConfigFileName is the config file used by Codex CLI. +const CodexConfigFileName = "config.toml" + +// entireNotifyCommand is the command Entire installs as the Codex notify handler. +const entireNotifyCommand = `["entire", "hooks", "codex", "agent-turn-complete"]` + +// entireNotifyLocalDevPrefix is the argv prefix for the local-dev notify handler. +// The project directory is resolved at write-time since Codex executes notify +// commands as a direct argv array without shell expansion. +var entireNotifyLocalDevPrefix = []string{"go", "run"} //nolint:gochecknoglobals // template for local-dev command construction + +// entireNotifyLocalDevSuffix is the binary-relative path and subcommand for local dev. +const entireNotifyLocalDevSuffix = "/cmd/entire/main.go" + +// GetHookNames returns the hook verbs Codex CLI supports. +// These become subcommands: entire hooks codex +func (c *CodexAgent) GetHookNames() []string { + return []string{ + HookNameAgentTurnComplete, + } +} + +// InstallHooks installs the Codex CLI notify hook in .codex/config.toml. +// Codex uses a TOML config with a `notify` field that specifies the command +// to run on agent-turn-complete events. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (c *CodexAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + configPath := filepath.Join(repoRoot, ".codex", CodexConfigFileName) + + // Read existing config if it exists + var lines []string + existingData, readErr := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if readErr == nil { + lines = strings.Split(string(existingData), "\n") + } + + // Determine which command to install + notifyValue := entireNotifyCommand + if localDev { + notifyValue = buildLocalDevNotifyCommand() + } + notifyLine := "notify = " + notifyValue + + // Check if already installed (idempotency) + if !force { + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == notifyLine { + return 0, nil // Already installed + } + } + } + + // Remove existing notify lines: + // - Always remove Entire notify lines (to update) + // - When force=true, also remove non-Entire notify lines (to avoid duplicate TOML keys) + var filteredLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") { + if isEntireNotifyLine(trimmed) { + continue // Skip existing Entire notify line + } + if force { + continue // force=true: remove non-Entire notify lines too + } + } + filteredLines = append(filteredLines, line) + } + + // If there's a non-Entire notify line, don't overwrite it (regardless of whether + // an Entire line was found/removed — we respect user-configured notify commands). + if !force { + for _, line := range filteredLines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") { + // There's a user-configured notify line; don't overwrite + return 0, nil + } + } + } + + // Add the notify line + filteredLines = appendNotifyLine(filteredLines, notifyLine) + + // Write back to file + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .codex directory: %w", err) + } + + output := strings.Join(filteredLines, "\n") + // Ensure file ends with a newline + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + if err := os.WriteFile(configPath, []byte(output), 0o600); err != nil { + return 0, fmt.Errorf("failed to write config.toml: %w", err) + } + + return 1, nil +} + +// UninstallHooks removes the Entire notify hook from Codex CLI config. +func (c *CodexAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".codex", CodexConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No config file means nothing to uninstall + } + + lines := strings.Split(string(data), "\n") + var filteredLines []string + changed := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") && isEntireNotifyLine(trimmed) { + changed = true + continue // Remove Entire notify line + } + filteredLines = append(filteredLines, line) + } + + if !changed { + return nil + } + + output := strings.Join(filteredLines, "\n") + if err := os.WriteFile(configPath, []byte(output), 0o600); err != nil { + return fmt.Errorf("failed to write config.toml: %w", err) + } + return nil +} + +// AreHooksInstalled checks if the Entire notify hook is installed. +func (c *CodexAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + configPath := filepath.Join(repoRoot, ".codex", CodexConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") && isEntireNotifyLine(trimmed) { + return true + } + } + + return false +} + +// GetSupportedHooks returns the hook types Codex CLI supports. +func (c *CodexAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookStop, // agent-turn-complete maps to Stop + } +} + +// isEntireNotifyLine checks if a TOML notify line contains an Entire command. +// Uses two detection patterns: +// - Production: contains "entire" as a standalone TOML array element (quoted) +// - Local dev: contains "go", "run" prefix with "entire" anywhere (path substring) +func isEntireNotifyLine(line string) bool { + // Production hook: notify = ["entire", "hooks", "codex", ...] + // Check for "entire" as a standalone quoted element to avoid false positives + // like notify = ["my-entire-tool"] + if strings.Contains(line, `"entire"`) { + return true + } + // Local dev hook: notify = ["go", "run", ".../entire/main.go", ...] + // The "go", "run" prefix combined with "entire" substring is specific enough + if strings.Contains(line, `"go", "run"`) && strings.Contains(line, "entire") { + return true + } + return false +} + +// buildLocalDevNotifyCommand constructs the local-dev notify command with the +// project directory resolved at write-time. Codex executes notify commands as +// a direct argv array without shell expansion, so env vars like ${CODEX_PROJECT_DIR} +// would not be expanded at runtime. +func buildLocalDevNotifyCommand() string { + projectDir := os.Getenv("CODEX_PROJECT_DIR") + if projectDir == "" { + // Fallback: use repo root (not CWD) to avoid baking in a subdir path + var err error + projectDir, err = paths.RepoRoot() + if err != nil { + // Last resort: use CWD if not in a git repo (e.g., tests) + projectDir, err = os.Getwd() //nolint:forbidigo // Intentional: need CWD for local dev path resolution + if err != nil { + projectDir = "." + } + } + } + mainGo := projectDir + entireNotifyLocalDevSuffix + // Build TOML array: ["go", "run", "/abs/path/cmd/entire/main.go", "hooks", "codex", "agent-turn-complete"] + parts := make([]string, 0, len(entireNotifyLocalDevPrefix)+4) + for _, p := range entireNotifyLocalDevPrefix { + parts = append(parts, `"`+p+`"`) + } + parts = append(parts, `"`+mainGo+`"`, `"hooks"`, `"codex"`, `"agent-turn-complete"`) + return "[" + strings.Join(parts, ", ") + "]" +} + +// appendNotifyLine adds a notify line to the config, placing it logically. +// If there's an existing commented-out notify line, places the new one near it. +// Otherwise, appends to the end. +func appendNotifyLine(lines []string, notifyLine string) []string { + // Look for a commented notify line to place near + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "notify") { + // Insert after the comment + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:i+1]...) + result = append(result, notifyLine) + result = append(result, lines[i+1:]...) + return result + } + } + + // Append to the end, with a blank line separator if the file isn't empty + if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" { + lines = append(lines, "") + } + return append(lines, notifyLine) +} diff --git a/cmd/entire/cli/agent/codex/types.go b/cmd/entire/cli/agent/codex/types.go new file mode 100644 index 000000000..6a1323e72 --- /dev/null +++ b/cmd/entire/cli/agent/codex/types.go @@ -0,0 +1,54 @@ +package codex + +import "encoding/json" + +// notifyPayload is the JSON structure sent to the notify command by Codex CLI. +// Codex sends this on agent-turn-complete events. +type notifyPayload struct { + Type string `json:"type"` + ThreadID string `json:"thread-id"` + TurnID string `json:"turn-id"` + Cwd string `json:"cwd"` + InputMessages []string `json:"input-messages"` + LastAssistantMessage string `json:"last-assistant-message"` +} + +// rolloutEvent represents a single event line in a Codex rollout JSONL file. +// Codex stores session transcripts as JSONL where each line is a typed event. +// Known types: session_meta, response_item, turn_context, compacted, event_msg. +type rolloutEvent struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` + Item json.RawMessage `json:"item,omitempty"` +} + +// rolloutItem represents the item/payload content in a rollout event. +type rolloutItem struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Name string `json:"name,omitempty"` +} + +// eventMsgPayload represents the payload of an "event_msg" rollout event. +// Codex emits event_msg events for various internal events. The "patch_apply_begin" +// subtype contains a changes map where keys are file paths being modified. +// See codex-rs/exec/src/exec_events.rs: PatchApplyBeginEvent { changes: HashMap } +type eventMsgPayload struct { + Type string `json:"type"` + Changes map[string]json.RawMessage `json:"changes,omitempty"` +} + +// Codex event item types +const ( + ItemTypeFileChange = "file_change" + ItemTypeCommandExecution = "command_execution" + ItemTypeAgentMessage = "agent_message" + ItemTypeFunctionCall = "function_call" + ItemTypeLocalShellCall = "local_shell_call" +) + +// Codex event_msg subtypes +const ( + EventMsgTypePatchApplyBegin = "patch_apply_begin" +) diff --git a/cmd/entire/cli/agent/opencode/hooks.go b/cmd/entire/cli/agent/opencode/hooks.go new file mode 100644 index 000000000..da0b5fb28 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/hooks.go @@ -0,0 +1,204 @@ +package opencode + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure OpenCodeAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*OpenCodeAgent)(nil) + _ agent.HookHandler = (*OpenCodeAgent)(nil) +) + +// OpenCode hook names - these become subcommands under `entire hooks opencode` +const ( + HookNameSessionCreated = "session-created" + HookNameSessionBusy = "session-busy" + HookNameSessionIdle = "session-idle" +) + +// EntirePluginFileName is the TypeScript plugin file installed by Entire. +const EntirePluginFileName = "entire.ts" + +// entirePluginMarker identifies Entire-generated plugin files. +const entirePluginMarker = "// Generated by Entire CLI — do not edit manually" + +// GetHookNames returns the hook verbs OpenCode supports via the plugin system. +// These become subcommands: entire hooks opencode +func (o *OpenCodeAgent) GetHookNames() []string { + return []string{ + HookNameSessionCreated, + HookNameSessionBusy, + HookNameSessionIdle, + } +} + +// InstallHooks installs the Entire plugin in .opencode/plugins/entire.ts. +// OpenCode auto-discovers TypeScript plugins from .opencode/plugins/*.{ts,js}. +// If force is true, removes existing plugin before installing. +// Returns the number of hooks installed. +func (o *OpenCodeAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + pluginPath := filepath.Join(repoRoot, ".opencode", "plugins", EntirePluginFileName) + + // Check for idempotency (unless force) + if !force { + if data, readErr := os.ReadFile(pluginPath); readErr == nil { //nolint:gosec // path is constructed from repo root + fixed path + content := string(data) + if strings.Contains(content, entirePluginMarker) { + // Already installed — check if mode matches + if localDev && strings.Contains(content, "go run") { + return 0, nil + } + if !localDev && !strings.Contains(content, "go run") { + return 0, nil + } + } + } + } + + // Generate plugin content + content := entirePluginContent(localDev) + + // Ensure plugin directory exists + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create plugins directory: %w", err) + } + + if err := os.WriteFile(pluginPath, []byte(content), 0o600); err != nil { + return 0, fmt.Errorf("failed to write plugin file: %w", err) + } + + // 3 hooks: session-created, session-busy, and session-idle + return 3, nil +} + +// UninstallHooks removes the Entire plugin from OpenCode's plugins directory. +func (o *OpenCodeAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + + pluginPath := filepath.Join(repoRoot, ".opencode", "plugins", EntirePluginFileName) + + // Only remove if it's our plugin (contains marker) + data, err := os.ReadFile(pluginPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No plugin file means nothing to uninstall + } + + if !strings.Contains(string(data), entirePluginMarker) { + return nil // Not our plugin, don't remove + } + + if err := os.Remove(pluginPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove plugin file: %w", err) + } + return nil +} + +// AreHooksInstalled checks if the Entire plugin is installed. +func (o *OpenCodeAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + + pluginPath := filepath.Join(repoRoot, ".opencode", "plugins", EntirePluginFileName) + data, err := os.ReadFile(pluginPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + return strings.Contains(string(data), entirePluginMarker) +} + +// GetSupportedHooks returns the hook types OpenCode supports via its plugin system. +func (o *OpenCodeAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, // session.created maps to SessionStart + agent.HookUserPromptSubmit, // session.status busy maps to UserPromptSubmit (per-turn state capture) + agent.HookStop, // session.status idle maps to Stop + } +} + +// entirePluginContent generates the TypeScript plugin source for the Entire integration. +// The plugin subscribes to OpenCode events and pipes JSON payloads to Entire CLI hooks. +func entirePluginContent(localDev bool) string { + var cmdPrefix string + if localDev { + // Use process.env for local development path resolution + cmdPrefix = "go run ${process.env.OPENCODE_PROJECT_DIR || \".\"}/cmd/entire/main.go" + } else { + cmdPrefix = "entire" + } + + // Backticks can't appear in Go raw string literals, so we build the + // TypeScript BunShell template-literal lines via string concatenation. + bt := "`" // backtick + + var sb strings.Builder + sb.WriteString(entirePluginMarker + "\n") + sb.WriteString("//\n") + sb.WriteString("// This plugin integrates OpenCode with Entire for session checkpointing.\n") + sb.WriteString("// It listens for session lifecycle events and notifies Entire CLI.\n\n") + sb.WriteString("export default async function entirePlugin(input) {\n") + sb.WriteString(" const { $ } = input;\n\n") + sb.WriteString(" return {\n") + sb.WriteString(" event: async ({ event }) => {\n") + sb.WriteString(" if (event.type === \"session.created\") {\n") + sb.WriteString(" const payload = JSON.stringify({\n") + sb.WriteString(" type: \"session-created\",\n") + sb.WriteString(" sessionID: event.properties?.info?.id || \"\",\n") + sb.WriteString(" });\n") + sb.WriteString(" try {\n") + sb.WriteString(" await $" + bt + "echo ${payload} | " + cmdPrefix + " hooks opencode session-created" + bt + ".quiet().nothrow();\n") + sb.WriteString(" } catch {\n") + sb.WriteString(" // Silently ignore hook failures to avoid disrupting the session\n") + sb.WriteString(" }\n") + sb.WriteString(" }\n\n") + sb.WriteString(" if (event.type === \"session.status\") {\n") + sb.WriteString(" const status = event.properties?.status;\n") + sb.WriteString(" if (status?.type === \"busy\") {\n") + sb.WriteString(" const payload = JSON.stringify({\n") + sb.WriteString(" type: \"session-busy\",\n") + sb.WriteString(" sessionID: event.properties?.sessionID || \"\",\n") + sb.WriteString(" });\n") + sb.WriteString(" try {\n") + sb.WriteString(" await $" + bt + "echo ${payload} | " + cmdPrefix + " hooks opencode session-busy" + bt + ".quiet().nothrow();\n") + sb.WriteString(" } catch {\n") + sb.WriteString(" // Silently ignore hook failures to avoid disrupting the session\n") + sb.WriteString(" }\n") + sb.WriteString(" }\n") + sb.WriteString(" if (status?.type === \"idle\") {\n") + sb.WriteString(" const payload = JSON.stringify({\n") + sb.WriteString(" type: \"session-idle\",\n") + sb.WriteString(" sessionID: event.properties?.sessionID || \"\",\n") + sb.WriteString(" });\n") + sb.WriteString(" try {\n") + sb.WriteString(" await $" + bt + "echo ${payload} | " + cmdPrefix + " hooks opencode session-idle" + bt + ".quiet().nothrow();\n") + sb.WriteString(" } catch {\n") + sb.WriteString(" // Silently ignore hook failures to avoid disrupting the session\n") + sb.WriteString(" }\n") + sb.WriteString(" }\n") + sb.WriteString(" }\n") + sb.WriteString(" },\n") + sb.WriteString(" };\n") + sb.WriteString("}\n") + + return sb.String() +} diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go new file mode 100644 index 000000000..d5968ef77 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -0,0 +1,239 @@ +// Package opencode implements the Agent interface for OpenCode. +package opencode + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "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.AgentNameOpenCode, NewOpenCodeAgent) +} + +// OpenCodeAgent implements the Agent interface for OpenCode. +// +//nolint:revive // OpenCodeAgent is clearer than Agent in this context +type OpenCodeAgent struct{} + +// NewOpenCodeAgent creates a new OpenCode agent instance. +func NewOpenCodeAgent() agent.Agent { + return &OpenCodeAgent{} +} + +// Name returns the agent registry key. +func (o *OpenCodeAgent) Name() agent.AgentName { + return agent.AgentNameOpenCode +} + +// Type returns the agent type identifier. +func (o *OpenCodeAgent) Type() agent.AgentType { + return agent.AgentTypeOpenCode +} + +// Description returns a human-readable description. +func (o *OpenCodeAgent) Description() string { + return "OpenCode - Open-source AI coding assistant" +} + +// DetectPresence checks if OpenCode is configured in the repository. +func (o *OpenCodeAgent) DetectPresence() (bool, error) { + // Get repo root to check for .opencode directory + // This is needed because the CLI may be run from a subdirectory + repoRoot, err := paths.RepoRoot() + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + // Check for .opencode directory + opencodeDir := filepath.Join(repoRoot, ".opencode") + if _, err := os.Stat(opencodeDir); err == nil { + return true, nil + } + // Check for opencode.json config file + configJSON := filepath.Join(repoRoot, "opencode.json") + if _, err := os.Stat(configJSON); err == nil { + return true, nil + } + // Check for opencode.jsonc config file + configJSONC := filepath.Join(repoRoot, "opencode.jsonc") + if _, err := os.Stat(configJSONC); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to OpenCode's plugin file. +// OpenCode uses a TypeScript plugin system; hooks are installed as +// .opencode/plugins/entire.ts which is auto-discovered by OpenCode. +func (o *OpenCodeAgent) GetHookConfigPath() string { + return filepath.Join(".opencode", "plugins", EntirePluginFileName) +} + +// SupportsHooks returns true as OpenCode supports hooks via its plugin system. +func (o *OpenCodeAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses OpenCode hook input from stdin. +// The Entire plugin pipes JSON payloads with type and sessionID fields. +func (o *OpenCodeAgent) 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{}), + } + + var payload pluginPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse plugin payload: %w", err) + } + + input.SessionID = payload.SessionID + input.RawData["type"] = payload.Type + + // Resolve session transcript path + sessionDir, dirErr := o.GetSessionDir("") + if dirErr == nil && input.SessionID != "" { + input.SessionRef = o.ResolveSessionFile(sessionDir, input.SessionID) + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (o *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ProtectedDirs returns directories that OpenCode uses for config/state. +func (o *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } + +// GetSessionDir returns the directory where OpenCode stores session data. +// OpenCode stores sessions in XDG data directory: ~/.local/share/opencode/storage/session/ +func (o *OpenCodeAgent) GetSessionDir(_ string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR"); override != "" { + return override, nil + } + + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + dataDir = filepath.Join(homeDir, ".local", "share") + } + + return filepath.Join(dataDir, "opencode", "storage", "session"), nil +} + +// ResolveSessionFile returns the path to an OpenCode session file. +// OpenCode stores sessions nested by projectID: //.json +// Since we don't know the projectID, we walk the directory tree to find a matching file. +// Falls back to a flat path if no match is found. +func (o *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + target := agentSessionID + ".json" + + // Walk the session directory to find the file under any projectID subdirectory + var found string + _ = filepath.WalkDir(sessionDir, func(path string, d os.DirEntry, err error) error { //nolint:errcheck // non-fatal; fallback path handles missing dirs + if err != nil { + return nil //nolint:nilerr // Skip inaccessible directories + } + if d.IsDir() { + return nil + } + if d.Name() == target { + found = path + return filepath.SkipAll // found it, stop walking + } + return nil + }) + + if found != "" { + return found + } + + // Fallback: construct a flat path (may not exist) + return filepath.Join(sessionDir, target) +} + +// ReadSession reads a session from OpenCode's storage (JSON session file). +// The session data is stored in NativeData as raw JSON bytes. +func (o *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (file path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: o.Name(), + SessionRef: input.SessionRef, + NativeData: data, + }, nil +} + +// WriteSession writes a session to OpenCode's storage (JSON session file). +func (o *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != o.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, o.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (file path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Validate it's valid JSON before writing + if !json.Valid(session.NativeData) { + return errors.New("session native data is not valid JSON") + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(session.SessionRef), 0o750); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write session file: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an OpenCode session. +func (o *OpenCodeAgent) FormatResumeCommand(sessionID string) string { + return "opencode run --session " + sessionID +} diff --git a/cmd/entire/cli/agent/opencode/opencode_test.go b/cmd/entire/cli/agent/opencode/opencode_test.go new file mode 100644 index 000000000..1cf417315 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode_test.go @@ -0,0 +1,564 @@ +package opencode + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestNewOpenCodeAgent(t *testing.T) { + t.Parallel() + + ag := NewOpenCodeAgent() + if ag == nil { + t.Fatal("NewOpenCodeAgent() returned nil") + } + + oc, ok := ag.(*OpenCodeAgent) + if !ok { + t.Fatal("NewOpenCodeAgent() didn't return *OpenCodeAgent") + } + if oc == nil { + t.Fatal("NewOpenCodeAgent() returned nil agent") + } +} + +func TestName(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + if name := ag.Name(); name != agent.AgentNameOpenCode { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameOpenCode) + } +} + +func TestType(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + if agType := ag.Type(); agType != agent.AgentTypeOpenCode { + t.Errorf("Type() = %q, want %q", agType, agent.AgentTypeOpenCode) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } +} + +func TestParseHookInput_SessionIdle(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"type":"session-idle","sessionID":"ses_abc123"}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "ses_abc123" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "ses_abc123") + } + if result.RawData["type"] != "session-idle" { + t.Errorf("RawData[type] = %q, want %q", result.RawData["type"], "session-idle") + } +} + +func TestParseHookInput_SessionCreated(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"type":"session-created","sessionID":"ses_xyz789"}` + + result, err := ag.ParseHookInput(agent.HookSessionStart, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "ses_xyz789" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "ses_xyz789") + } +} + +func TestParseHookInput_SessionBusy(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + input := `{"type":"session-busy","sessionID":"ses_busy456"}` + + result, err := ag.ParseHookInput(agent.HookUserPromptSubmit, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.SessionID != "ses_busy456" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "ses_busy456") + } + if result.RawData["type"] != "session-busy" { + t.Errorf("RawData[type] = %q, want %q", result.RawData["type"], "session-busy") + } +} + +func TestParseHookInput_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + _, err := ag.ParseHookInput(agent.HookStop, strings.NewReader("")) + if err == nil { + t.Error("ParseHookInput() should return error for empty input") + } +} + +func TestDetectPresence(t *testing.T) { + t.Run("no .opencode directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) + + t.Run("with .opencode directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.Mkdir(".opencode", 0o755); err != nil { + t.Fatalf("failed to create .opencode: %v", err) + } + + ag := &OpenCodeAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("with opencode.json", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.WriteFile("opencode.json", []byte(`{}`), 0o600); err != nil { + t.Fatalf("failed to create opencode.json: %v", err) + } + + ag := &OpenCodeAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) + + t.Run("with opencode.jsonc", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + if err := os.WriteFile("opencode.jsonc", []byte(`{}`), 0o600); err != nil { + t.Fatalf("failed to create opencode.jsonc: %v", err) + } + + ag := &OpenCodeAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) +} + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + + t.Run("fallback to flat path when dir does not exist", func(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + result := ag.ResolveSessionFile("/data/opencode/storage/session", "ses_abc123") + expected := "/data/opencode/storage/session/ses_abc123.json" + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } + }) + + t.Run("finds file nested under projectID subdirectory", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + // Simulate OpenCode's nested structure: //.json + projectDir := filepath.Join(tempDir, "proj_xyz789") + if err := os.MkdirAll(projectDir, 0o750); err != nil { + t.Fatalf("failed to create project dir: %v", err) + } + sessionFile := filepath.Join(projectDir, "ses_abc123.json") + if err := os.WriteFile(sessionFile, []byte(`{}`), 0o600); err != nil { + t.Fatalf("failed to create session file: %v", err) + } + + ag := &OpenCodeAgent{} + result := ag.ResolveSessionFile(tempDir, "ses_abc123") + if result != sessionFile { + t.Errorf("ResolveSessionFile() = %q, want %q", result, sessionFile) + } + }) + + t.Run("flat file still found when present at top level", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + sessionFile := filepath.Join(tempDir, "ses_flat.json") + if err := os.WriteFile(sessionFile, []byte(`{}`), 0o600); err != nil { + t.Fatalf("failed to create session file: %v", err) + } + + ag := &OpenCodeAgent{} + result := ag.ResolveSessionFile(tempDir, "ses_flat") + if result != sessionFile { + t.Errorf("ResolveSessionFile() = %q, want %q", result, sessionFile) + } + }) +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".opencode" { + t.Errorf("ProtectedDirs() = %v, want [.opencode]", dirs) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + cmd := ag.FormatResumeCommand("ses_abc123") + expected := "opencode run --session ses_abc123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestGetSessionDir(t *testing.T) { + // Not parallel: subtests use t.Setenv which modifies process-global state + + t.Run("with test override", func(t *testing.T) { + t.Setenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR", "/tmp/test-opencode") + ag := &OpenCodeAgent{} + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/tmp/test-opencode" { + t.Errorf("GetSessionDir() = %q, want %q", dir, "/tmp/test-opencode") + } + }) +} + +func TestGetHookNames(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + names := ag.GetHookNames() + if len(names) != 3 { + t.Fatalf("GetHookNames() returned %d names, want 3", len(names)) + } + if names[0] != HookNameSessionCreated { + t.Errorf("names[0] = %q, want %q", names[0], HookNameSessionCreated) + } + if names[1] != HookNameSessionBusy { + t.Errorf("names[1] = %q, want %q", names[1], HookNameSessionBusy) + } + if names[2] != HookNameSessionIdle { + t.Errorf("names[2] = %q, want %q", names[2], HookNameSessionIdle) + } +} + +func TestInstallHooks(t *testing.T) { + t.Run("installs plugin file", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 3 { + t.Errorf("InstallHooks() count = %d, want 3", count) + } + + // Verify file exists and contains marker + pluginPath := filepath.Join(".opencode", "plugins", EntirePluginFileName) + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + content := string(data) + if !strings.Contains(content, entirePluginMarker) { + t.Error("plugin file missing marker") + } + if !strings.Contains(content, "session.created") { + t.Error("plugin file missing session.created handler") + } + if !strings.Contains(content, "session.status") { + t.Error("plugin file missing session.status handler") + } + if !strings.Contains(content, "entire hooks opencode") { + t.Error("plugin file missing entire command reference") + } + if !strings.Contains(content, "session-busy") { + t.Error("plugin file missing session-busy handler") + } + }) + + t.Run("idempotent install", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + // First install + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install should be no-op + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("second InstallHooks() count = %d, want 0", count) + } + }) + + t.Run("local dev mode", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + count, err := ag.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + if count != 3 { + t.Errorf("InstallHooks() count = %d, want 3", count) + } + + pluginPath := filepath.Join(".opencode", "plugins", EntirePluginFileName) + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + content := string(data) + if !strings.Contains(content, "go run") { + t.Error("plugin file missing local dev 'go run' command") + } + }) + + t.Run("force reinstall", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + // Install production mode + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall in local dev mode + count, err := ag.InstallHooks(true, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 3 { + t.Errorf("force InstallHooks() count = %d, want 3", count) + } + + pluginPath := filepath.Join(".opencode", "plugins", EntirePluginFileName) + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("failed to read plugin file: %v", err) + } + if !strings.Contains(string(data), "go run") { + t.Error("plugin file should be in local dev mode after force reinstall") + } + }) +} + +func TestAreHooksInstalled(t *testing.T) { + t.Run("not installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true, want false") + } + }) + + t.Run("installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if !ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false, want true") + } + }) + + t.Run("non-entire plugin file", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create a plugin file without the Entire marker + pluginPath := filepath.Join(".opencode", "plugins", EntirePluginFileName) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + t.Fatalf("failed to create dirs: %v", err) + } + if err := os.WriteFile(pluginPath, []byte("export default {}"), 0o600); err != nil { + t.Fatalf("failed to write plugin: %v", err) + } + + ag := &OpenCodeAgent{} + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true for non-Entire plugin, want false") + } + }) +} + +func TestUninstallHooks(t *testing.T) { + t.Run("uninstalls plugin", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() = true after uninstall") + } + }) + + t.Run("no plugin file", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &OpenCodeAgent{} + if err := ag.UninstallHooks(); err != nil { + t.Errorf("UninstallHooks() error = %v, want nil", err) + } + }) + + t.Run("does not remove non-entire plugin", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create a user's own plugin with the same filename + pluginPath := filepath.Join(".opencode", "plugins", EntirePluginFileName) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + t.Fatalf("failed to create dirs: %v", err) + } + if err := os.WriteFile(pluginPath, []byte("export default {}"), 0o600); err != nil { + t.Fatalf("failed to write plugin: %v", err) + } + + ag := &OpenCodeAgent{} + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // File should still exist + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + t.Error("UninstallHooks() removed non-Entire plugin file") + } + }) +} + +func TestGetSupportedHooks(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + hooks := ag.GetSupportedHooks() + if len(hooks) != 3 { + t.Fatalf("GetSupportedHooks() returned %d hooks, want 3", len(hooks)) + } +} + +func TestGetHookConfigPath(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + path := ag.GetHookConfigPath() + if !strings.Contains(path, "entire.ts") { + t.Errorf("GetHookConfigPath() = %q, want path containing 'entire.ts'", path) + } +} + +func TestWriteSession_CreatesParentDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + sessionPath := filepath.Join(tempDir, "nested", "dir", "session.json") + + ag := &OpenCodeAgent{} + err := ag.WriteSession(&agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameOpenCode, + SessionRef: sessionPath, + NativeData: []byte(`{"test": true}`), + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written + if _, err := os.Stat(sessionPath); os.IsNotExist(err) { + t.Error("WriteSession() did not create file") + } +} diff --git a/cmd/entire/cli/agent/opencode/types.go b/cmd/entire/cli/agent/opencode/types.go new file mode 100644 index 000000000..4c81b137e --- /dev/null +++ b/cmd/entire/cli/agent/opencode/types.go @@ -0,0 +1,27 @@ +package opencode + +// Tool names used in OpenCode transcripts +const ( + ToolWrite = "Write" + ToolEdit = "Edit" + ToolApplyPatch = "ApplyPatch" + ToolMultiEdit = "MultiEdit" +) + +// FileModificationTools returns tools that create or modify files. +// Returns a new slice each call to prevent mutation. +func FileModificationTools() []string { + return []string{ + ToolWrite, + ToolEdit, + ToolApplyPatch, + ToolMultiEdit, + } +} + +// pluginPayload is the JSON structure sent from the Entire plugin to hook handlers. +// The OpenCode plugin pipes this via BunShell stdin. +type pluginPayload struct { + Type string `json:"type"` + SessionID string `json:"sessionID"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..614d1b1b8 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -79,13 +79,17 @@ type AgentType string // Agent name constants (registry keys) const ( AgentNameClaudeCode AgentName = "claude-code" + AgentNameCodex AgentName = "codex" AgentNameGemini AgentName = "gemini" + AgentNameOpenCode AgentName = "opencode" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" + AgentTypeCodex AgentType = "Codex CLI" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpenCode AgentType = "OpenCode" 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..4e3496b3f 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -9,7 +9,9 @@ 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/codex" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -190,6 +192,40 @@ func init() { } return handleGeminiNotification() }) + + // Register Codex CLI handlers + RegisterHookHandler(agent.AgentNameCodex, codex.HookNameAgentTurnComplete, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCodexAgentTurnComplete() + }) + + // Register OpenCode handlers + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameSessionCreated, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenCodeSessionCreated() + }) + + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameSessionBusy, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenCodeSessionBusy() + }) + + RegisterHookHandler(agent.AgentNameOpenCode, opencode.HookNameSessionIdle, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenCodeSessionIdle() + }) } // agentHookLogCleanup stores the cleanup function for agent hook logging. @@ -201,6 +237,17 @@ var agentHookLogCleanup func() // This allows handlers to know which agent invoked the hook without guessing. var currentHookAgentName agent.AgentName +// currentHookArgs stores the positional arguments passed to the currently executing hook command. +// Some agents (e.g., Codex) pass their payload as a positional argument instead of stdin. +// Set by newAgentHookVerbCmdWithLogging before calling the handler, cleared with defer. +var currentHookArgs []string //nolint:gochecknoglobals // same pattern as currentHookAgentName + +// GetCurrentHookArgs returns the positional arguments passed to the current hook command. +// Some agents (e.g., Codex) pass their payload as a positional argument instead of stdin. +func GetCurrentHookArgs() []string { + return currentHookArgs +} + // GetCurrentHookAgent returns the agent for the currently executing hook. // Returns the agent based on the hook command structure (e.g., "entire hooks claude-code ...") // rather than guessing from directory presence. @@ -267,7 +314,8 @@ func newAgentHookVerbCmdWithLogging(agentName agent.AgentName, hookName string) Use: hookName, Hidden: true, Short: "Called on " + hookName, - RunE: func(_ *cobra.Command, _ []string) error { + Args: cobra.ArbitraryArgs, + RunE: func(_ *cobra.Command, args []string) error { // Skip silently if not in a git repository - hooks shouldn't prevent the agent from working if _, err := paths.RepoRoot(); err != nil { return nil @@ -299,10 +347,14 @@ func newAgentHookVerbCmdWithLogging(agentName agent.AgentName, hookName string) return fmt.Errorf("no handler registered for %s/%s", agentName, hookName) } - // Set the current hook agent so handlers can retrieve it + // Set the current hook agent and args so handlers can retrieve them // without guessing from directory presence currentHookAgentName = agentName - defer func() { currentHookAgentName = "" }() + currentHookArgs = args + defer func() { + currentHookAgentName = "" + currentHookArgs = nil + }() hookErr := handler() diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index 450821f2e..fb1d4864a 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -732,35 +732,6 @@ func handleClaudeCodeSessionEnd() error { return nil } -// transitionSessionTurnEnd fires EventTurnEnd to move the session from -// ACTIVE → IDLE (or ACTIVE_COMMITTED → IDLE). Best-effort: logs warnings -// on failure rather than returning errors. -func transitionSessionTurnEnd(sessionID string) { - turnState, loadErr := strategy.LoadSessionState(sessionID) - if loadErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to load session state for turn end: %v\n", loadErr) - return - } - if turnState == nil { - return - } - remaining := strategy.TransitionAndLog(turnState, session.EventTurnEnd, session.TransitionContext{}) - - // Dispatch strategy-specific actions (e.g., ActionCondense for ACTIVE_COMMITTED → IDLE) - if len(remaining) > 0 { - strat := GetStrategy() - if handler, ok := strat.(strategy.TurnEndHandler); ok { - if err := handler.HandleTurnEnd(turnState, remaining); err != nil { - fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) - } - } - } - - if updateErr := strategy.SaveSessionState(turnState); updateErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update session phase on turn end: %v\n", updateErr) - } -} - // markSessionEnded transitions the session to ENDED phase via the state machine. func markSessionEnded(sessionID string) error { state, err := strategy.LoadSessionState(sessionID) diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..39b23128a 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,7 +4,9 @@ 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/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/hooks_codex_handlers.go b/cmd/entire/cli/hooks_codex_handlers.go new file mode 100644 index 000000000..055e94bc1 --- /dev/null +++ b/cmd/entire/cli/hooks_codex_handlers.go @@ -0,0 +1,182 @@ +// hooks_codex_handlers.go contains Codex CLI specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/codex" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// handleCodexAgentTurnComplete handles the agent-turn-complete hook for Codex CLI. +// This is the only hook Codex supports, firing after each agent turn completes. +// It is equivalent to Claude Code's "Stop" hook — it commits session changes with metadata. +func handleCodexAgentTurnComplete() error { + ag, err := agent.Get(agent.AgentNameCodex) + if err != nil { + return fmt.Errorf("failed to get codex agent: %w", err) + } + + // Codex sends its notify payload as the last positional argument (not stdin). + // See codex-rs/hooks/src/user_notification.rs: command.arg(notify_payload), stdin(Stdio::null()) + var reader io.Reader = os.Stdin + if args := GetCurrentHookArgs(); len(args) > 0 { + reader = strings.NewReader(args[len(args)-1]) + } + + input, err := ag.ParseHookInput(agent.HookStop, reader) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "codex-agent-turn-complete", + slog.String("hook", "agent-turn-complete"), + 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 + } + + // Resolve transcript path from session directory + transcriptPath := input.SessionRef + if transcriptPath != "" && sessionID != unknownSessionID { + resolved := ag.ResolveSessionFile(transcriptPath, sessionID) + if fileExistsAndIsRegular(resolved) { + transcriptPath = resolved + } + } + + if transcriptPath == "" || !fileExistsAndIsRegular(transcriptPath) { + return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) + } + + // Create session context + ctx := &codexSessionContext{ + sessionID: sessionID, + transcriptPath: transcriptPath, + userPrompt: input.UserPrompt, + } + + // Ensure strategy setup is in place (git hooks, gitignore, metadata branch). + // Codex has no separate turn-start hook, so we do this here before committing. + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) + } + + if err := setupCodexSessionDir(ctx); err != nil { + return err + } + + if err := extractCodexMetadata(ctx); err != nil { + return err + } + + if err := commitCodexSession(ctx); err != nil { + return err + } + + // Transition session ACTIVE → IDLE (equivalent to Claude's transitionSessionTurnEnd). + // Note: Codex only supports one hook (agent-turn-complete), so the session state + // machine is never initialized (no session-start or turn-start events). This call + // will find no state and silently return. Session phase tracking (and features that + // depend on it like deferred condensation) are not available for Codex sessions. + transitionSessionTurnEnd(sessionID) + + // Capture post-turn state for the next turn's file change detection. + // Codex has no "before turn" hook, so we capture state after each turn completes. + // This gives the next turn's handler an accurate baseline of untracked files. + // The transcript path is not used for offset tracking in Codex, so pass empty. + if err := CapturePrePromptState(sessionID, ""); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to capture state for next turn: %v\n", err) + } + + return nil +} + +// codexSessionContext holds parsed session data for Codex commits. +type codexSessionContext struct { + sessionID string + transcriptPath string + sessionDir string + sessionDirAbs string + transcriptData []byte + userPrompt string + modifiedFiles []string + commitMessage string +} + +// setupCodexSessionDir creates session directory and copies transcript. +func setupCodexSessionDir(ctx *codexSessionContext) error { + ctx.sessionDir = paths.SessionMetadataDirFromSessionID(ctx.sessionID) + sessionDirAbs, err := paths.AbsPath(ctx.sessionDir) + if err != nil { + sessionDirAbs = ctx.sessionDir + } + ctx.sessionDirAbs = sessionDirAbs + + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := copyFile(ctx.transcriptPath, logFile); err != nil { + return fmt.Errorf("failed to copy transcript: %w", err) + } + fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", ctx.sessionDir+"/"+paths.TranscriptFileName) + + transcriptData, err := os.ReadFile(ctx.transcriptPath) + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + ctx.transcriptData = transcriptData + + return nil +} + +// extractCodexMetadata extracts prompts, modified files, and commit message from transcript. +func extractCodexMetadata(ctx *codexSessionContext) error { + // Extract modified files from Codex JSONL transcript + ctx.modifiedFiles = codex.ExtractModifiedFiles(ctx.transcriptData) + + // Write prompt file (Codex provides user prompt in the notify payload) + promptFile := filepath.Join(ctx.sessionDirAbs, paths.PromptFileName) + if ctx.userPrompt != "" { + if err := os.WriteFile(promptFile, []byte(ctx.userPrompt), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted prompt to: %s\n", ctx.sessionDir+"/"+paths.PromptFileName) + } + + ctx.commitMessage = generateCommitMessage(ctx.userPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", ctx.commitMessage) + + return nil +} + +// commitCodexSession commits the session changes using the shared commit pipeline. +func commitCodexSession(ctx *codexSessionContext) error { + return commitAgentSession(&agentCommitContext{ + sessionID: ctx.sessionID, + sessionDir: ctx.sessionDir, + sessionDirAbs: ctx.sessionDirAbs, + commitMessage: ctx.commitMessage, + transcriptPath: ctx.transcriptPath, + transcriptModifiedFiles: ctx.modifiedFiles, + prompts: singlePrompt(ctx.userPrompt), + }) +} diff --git a/cmd/entire/cli/hooks_common_session.go b/cmd/entire/cli/hooks_common_session.go new file mode 100644 index 000000000..7359c53e4 --- /dev/null +++ b/cmd/entire/cli/hooks_common_session.go @@ -0,0 +1,231 @@ +// hooks_common_session.go contains shared session commit logic used by multiple agent handlers. +// This avoids duplicating the commit pipeline across Codex, OpenCode, Gemini, and future agents. +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// agentCommitContext holds all data needed to commit an agent session. +// Each agent handler populates this struct before calling commitAgentSession. +type agentCommitContext struct { + sessionID string + sessionDir string + sessionDirAbs string + commitMessage string + transcriptPath string + + // File sources — agents provide one or both approaches. + // transcriptModifiedFiles: files extracted from agent transcript (Codex, Gemini). + // useGitStatusForModified: if true, use git status for modified files instead of transcript (OpenCode). + transcriptModifiedFiles []string + useGitStatusForModified bool + + // Context file content + prompts []string // Single prompt → len==1; Gemini multi-prompt → len>1 + summary string // Gemini only + + // Optional per-step enrichment (Gemini-specific) + stepTranscriptStart int // Message offset for token calculation + stepTranscriptIdentifier string // Last message ID at step start + tokenUsage *agent.TokenUsage // Calculated token stats +} + +// singlePrompt wraps a single prompt string into a slice for agentCommitContext.prompts. +// Returns nil if the prompt is empty. +func singlePrompt(prompt string) []string { + if prompt == "" { + return nil + } + return []string{prompt} +} + +// commitAgentSession is the shared commit pipeline for non-Claude-Code agents. +// It handles: load pre-prompt state → detect file changes → filter/normalize paths → +// skip if no changes → log changes → create context file → get author → save via strategy. +func commitAgentSession(ctx *agentCommitContext) error { + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + preState, err := LoadPrePromptState(ctx.sessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) + } + if preState != nil { + fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files\n", len(preState.UntrackedFiles)) + } + + // 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) + } + + // Determine modified files: either from transcript extraction or git status + var relModifiedFiles []string + if ctx.useGitStatusForModified { + if changes != nil { + relModifiedFiles = FilterAndNormalizePaths(changes.Modified, repoRoot) + } + } else { + relModifiedFiles = FilterAndNormalizePaths(ctx.transcriptModifiedFiles, repoRoot) + } + + var relNewFiles, relDeletedFiles []string + if changes != nil { + relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) + relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) + } + + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + if totalChanges == 0 { + fmt.Fprintf(os.Stderr, "No files were modified during this session\n") + fmt.Fprintf(os.Stderr, "Skipping commit\n") + if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + return nil + } + + logFileChanges(relModifiedFiles, relNewFiles, relDeletedFiles) + + // Create context file + contextFile := filepath.Join(ctx.sessionDirAbs, paths.ContextFileName) + if err := createContextFile(contextFile, ctx.commitMessage, ctx.sessionID, ctx.prompts, ctx.summary); err != nil { + return fmt.Errorf("failed to create context file: %w", err) + } + fmt.Fprintf(os.Stderr, "Created context file: %s\n", ctx.sessionDir+"/"+paths.ContextFileName) + + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + strat := GetStrategy() + + // Get agent type from the hook agent + hookAgent, agentErr := GetCurrentHookAgent() + if agentErr != nil { + return fmt.Errorf("failed to get agent: %w", agentErr) + } + agentType := hookAgent.Type() + + saveCtx := strategy.SaveContext{ + SessionID: ctx.sessionID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + MetadataDir: ctx.sessionDir, + MetadataDirAbs: ctx.sessionDirAbs, + CommitMessage: ctx.commitMessage, + TranscriptPath: ctx.transcriptPath, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agentType, + StepTranscriptStart: ctx.stepTranscriptStart, + StepTranscriptIdentifier: ctx.stepTranscriptIdentifier, + TokenUsage: ctx.tokenUsage, + } + + if err := strat.SaveChanges(saveCtx); err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + + fmt.Fprintf(os.Stderr, "Session saved successfully\n") + return nil +} + +// createContextFile creates a context.md file for agent sessions. +// Supports single prompt (len==1) or multi-prompt (Gemini) and optional summary. +func createContextFile(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { + var sb strings.Builder + + sb.WriteString("# Session Context\n\n") + sb.WriteString(fmt.Sprintf("Session ID: %s\n", sessionID)) + sb.WriteString(fmt.Sprintf("Commit Message: %s\n\n", commitMessage)) + + if len(prompts) == 1 { + sb.WriteString("## Prompt\n\n") + sb.WriteString(prompts[0]) + sb.WriteString("\n") + } else if len(prompts) > 1 { + sb.WriteString("## Prompts\n\n") + for i, p := range prompts { + sb.WriteString(fmt.Sprintf("### Prompt %d\n\n%s\n\n", i+1, p)) + } + } + + if summary != "" { + sb.WriteString("## Summary\n\n") + sb.WriteString(summary) + sb.WriteString("\n") + } + + if err := os.WriteFile(contextFile, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("failed to write context file: %w", err) + } + return nil +} + +// logFileChanges logs the modified, new, and deleted files to stderr. +func logFileChanges(modified, newFiles, deleted []string) { + fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(modified)) + for _, file := range modified { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + if len(newFiles) > 0 { + fmt.Fprintf(os.Stderr, "New files created (%d):\n", len(newFiles)) + for _, file := range newFiles { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } + if len(deleted) > 0 { + fmt.Fprintf(os.Stderr, "Files deleted (%d):\n", len(deleted)) + for _, file := range deleted { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } +} + +// transitionSessionTurnEnd fires EventTurnEnd to move the session from +// ACTIVE → IDLE (or ACTIVE_COMMITTED → IDLE). Best-effort: logs warnings +// on failure rather than returning errors. +func transitionSessionTurnEnd(sessionID string) { + turnState, loadErr := strategy.LoadSessionState(sessionID) + if loadErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load session state for turn end: %v\n", loadErr) + return + } + if turnState == nil { + return + } + remaining := strategy.TransitionAndLog(turnState, session.EventTurnEnd, session.TransitionContext{}) + + // Dispatch strategy-specific actions (e.g., ActionCondense for ACTIVE_COMMITTED → IDLE) + if len(remaining) > 0 { + strat := GetStrategy() + if handler, ok := strat.(strategy.TurnEndHandler); ok { + if err := handler.HandleTurnEnd(turnState, remaining); err != nil { + fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) + } + } + } + + if updateErr := strategy.SaveSessionState(turnState); updateErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update session phase on turn end: %v\n", updateErr) + } +} diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index c6520179f..97f578200 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -167,28 +167,22 @@ func extractGeminiMetadata(ctx *geminiSessionContext) error { return nil } -// commitGeminiSession commits the session changes using the strategy. +// commitGeminiSession commits the session changes using the shared commit pipeline. +// Gemini has additional enrichment: token usage calculation and per-step transcript tracking. func commitGeminiSession(ctx *geminiSessionContext) error { - repoRoot, err := paths.RepoRoot() - if err != nil { - return fmt.Errorf("failed to get repo root: %w", err) - } - preState, err := LoadPrePromptState(ctx.sessionID) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) - } - if preState != nil { - fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files, start message index: %d\n", len(preState.UntrackedFiles), preState.StartMessageIndex) + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state for Gemini enrichment: %v\n", err) } - // Get transcript position from pre-prompt state + // Gemini-specific: extract transcript position and token usage from pre-prompt state var startMessageIndex int + var transcriptIdentifierAtStart string if preState != nil { startMessageIndex = preState.StartMessageIndex + transcriptIdentifierAtStart = preState.LastTranscriptIdentifier } - // Calculate token usage for this prompt/response cycle (Gemini-specific) var tokenUsage *agent.TokenUsage if ctx.transcriptPath != "" { usage, tokenErr := geminicli.CalculateTokenUsageFromFile(ctx.transcriptPath, startMessageIndex) @@ -201,132 +195,19 @@ func commitGeminiSession(ctx *geminiSessionContext) error { } } - // 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) - } - - relModifiedFiles := FilterAndNormalizePaths(ctx.modifiedFiles, repoRoot) - var relNewFiles, relDeletedFiles []string - if changes != nil { - relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) - relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) - } - - totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) - if totalChanges == 0 { - fmt.Fprintf(os.Stderr, "No files were modified during this session\n") - fmt.Fprintf(os.Stderr, "Skipping commit\n") - if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) - } - return nil - } - - logFileChanges(relModifiedFiles, relNewFiles, relDeletedFiles) - - contextFile := filepath.Join(ctx.sessionDirAbs, paths.ContextFileName) - if err := createContextFileForGemini(contextFile, ctx.commitMessage, ctx.sessionID, ctx.allPrompts, ctx.summary); err != nil { - return fmt.Errorf("failed to create context file: %w", err) - } - fmt.Fprintf(os.Stderr, "Created context file: %s\n", ctx.sessionDir+"/"+paths.ContextFileName) - - author, err := GetGitAuthor() - if err != nil { - return fmt.Errorf("failed to get git author: %w", err) - } - - strat := GetStrategy() - - // Get agent type from the hook agent (determined by which hook command is running) - // This is authoritative - if we're in "entire hooks gemini session-end", it's Gemini CLI - hookAgent, agentErr := GetCurrentHookAgent() - if agentErr != nil { - return fmt.Errorf("failed to get agent: %w", agentErr) - } - agentType := hookAgent.Type() - - // Get transcript identifier at start from pre-prompt state - var transcriptIdentifierAtStart string - if preState != nil { - transcriptIdentifierAtStart = preState.LastTranscriptIdentifier - } - - saveCtx := strategy.SaveContext{ - SessionID: ctx.sessionID, - ModifiedFiles: relModifiedFiles, - NewFiles: relNewFiles, - DeletedFiles: relDeletedFiles, - MetadataDir: ctx.sessionDir, - MetadataDirAbs: ctx.sessionDirAbs, - CommitMessage: ctx.commitMessage, - TranscriptPath: ctx.transcriptPath, - AuthorName: author.Name, - AuthorEmail: author.Email, - AgentType: agentType, - StepTranscriptStart: startMessageIndex, - StepTranscriptIdentifier: transcriptIdentifierAtStart, - TokenUsage: tokenUsage, - } - - if err := strat.SaveChanges(saveCtx); err != nil { - return fmt.Errorf("failed to save session: %w", err) - } - - if cleanupErr := CleanupPrePromptState(ctx.sessionID); cleanupErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) - } - - fmt.Fprintf(os.Stderr, "Session saved successfully\n") - return nil -} - -// logFileChanges logs the modified, new, and deleted files to stderr. -func logFileChanges(modified, newFiles, deleted []string) { - fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(modified)) - for _, file := range modified { - fmt.Fprintf(os.Stderr, " - %s\n", file) - } - if len(newFiles) > 0 { - fmt.Fprintf(os.Stderr, "New files created (%d):\n", len(newFiles)) - for _, file := range newFiles { - fmt.Fprintf(os.Stderr, " - %s\n", file) - } - } - if len(deleted) > 0 { - fmt.Fprintf(os.Stderr, "Files deleted (%d):\n", len(deleted)) - for _, file := range deleted { - fmt.Fprintf(os.Stderr, " - %s\n", file) - } - } -} - -// createContextFileForGemini creates a context.md file for Gemini sessions. -func createContextFileForGemini(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { - var sb strings.Builder - - sb.WriteString("# Session Context\n\n") - sb.WriteString(fmt.Sprintf("Session ID: %s\n", sessionID)) - sb.WriteString(fmt.Sprintf("Commit Message: %s\n\n", commitMessage)) - - if len(prompts) > 0 { - sb.WriteString("## Prompts\n\n") - for i, p := range prompts { - sb.WriteString(fmt.Sprintf("### Prompt %d\n\n%s\n\n", i+1, p)) - } - } - - if summary != "" { - sb.WriteString("## Summary\n\n") - sb.WriteString(summary) - sb.WriteString("\n") - } - - if err := os.WriteFile(contextFile, []byte(sb.String()), 0o600); err != nil { - return fmt.Errorf("failed to write context file: %w", err) - } - return nil + return commitAgentSession(&agentCommitContext{ + sessionID: ctx.sessionID, + sessionDir: ctx.sessionDir, + sessionDirAbs: ctx.sessionDirAbs, + commitMessage: ctx.commitMessage, + transcriptPath: ctx.transcriptPath, + transcriptModifiedFiles: ctx.modifiedFiles, + prompts: ctx.allPrompts, + summary: ctx.summary, + stepTranscriptStart: startMessageIndex, + stepTranscriptIdentifier: transcriptIdentifierAtStart, + tokenUsage: tokenUsage, + }) } // handleGeminiBeforeTool handles the BeforeTool hook for Gemini CLI. diff --git a/cmd/entire/cli/hooks_opencode_handlers.go b/cmd/entire/cli/hooks_opencode_handlers.go new file mode 100644 index 000000000..94bb4bf28 --- /dev/null +++ b/cmd/entire/cli/hooks_opencode_handlers.go @@ -0,0 +1,192 @@ +// hooks_opencode_handlers.go contains OpenCode specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// handleOpenCodeSessionCreated handles the session-created hook for OpenCode. +// This fires when a new OpenCode session starts, equivalent to Claude's SessionStart. +func handleOpenCodeSessionCreated() error { + return handleSessionStartCommon() +} + +// handleOpenCodeSessionBusy handles the session-busy hook for OpenCode. +// This fires when OpenCode's session transitions to busy (agent starts processing a turn). +// Equivalent to Claude's "UserPromptSubmit" hook — it captures pre-prompt state +// (untracked files) so that the session-idle handler can accurately detect new files. +func handleOpenCodeSessionBusy() error { + ag, err := agent.Get(agent.AgentNameOpenCode) + if err != nil { + return fmt.Errorf("failed to get opencode agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookUserPromptSubmit, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "opencode-session-busy", + slog.String("hook", "session-busy"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + ) + + sessionID := input.SessionID + if sessionID == "" { + sessionID = unknownSessionID + } + + // Capture pre-prompt state (untracked files) before the agent starts working. + // The transcript path is not used for OpenCode (no offset tracking), so pass empty. + if err := CapturePrePromptState(sessionID, ""); err != nil { + return fmt.Errorf("failed to capture pre-prompt state: %w", err) + } + + // Ensure strategy setup is in place (git hooks, gitignore, metadata branch). + // Done here at turn start so hooks are installed before any mid-turn commits. + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) + } + + return nil +} + +// handleOpenCodeSessionIdle handles the session-idle hook for OpenCode. +// This fires when OpenCode's session transitions to idle (agent finished processing). +// Equivalent to Claude's "Stop" hook — it commits session changes with metadata. +func handleOpenCodeSessionIdle() error { + ag, err := agent.Get(agent.AgentNameOpenCode) + if err != nil { + return fmt.Errorf("failed to get opencode agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "opencode-session-idle", + slog.String("hook", "session-idle"), + 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 + } + + // Resolve transcript path + transcriptPath := input.SessionRef + if transcriptPath != "" && sessionID != unknownSessionID { + resolved := ag.ResolveSessionFile(transcriptPath, sessionID) + if fileExistsAndIsRegular(resolved) { + transcriptPath = resolved + } + } + + if transcriptPath == "" || !fileExistsAndIsRegular(transcriptPath) { + // OpenCode sessions may not always have a transcript file accessible + // from the filesystem. Continue without transcript to still capture + // git status changes. + logging.Debug(logCtx, "no transcript file found, continuing with git status only", + slog.String("transcript_path", transcriptPath), + ) + } + + ctx := &openCodeSessionContext{ + sessionID: sessionID, + transcriptPath: transcriptPath, + userPrompt: input.UserPrompt, + } + + if err := setupOpenCodeSessionDir(ctx); err != nil { + return err + } + + if err := commitOpenCodeSession(ctx); err != nil { + return err + } + + // Transition session ACTIVE → IDLE + transitionSessionTurnEnd(sessionID) + + return nil +} + +// openCodeSessionContext holds parsed session data for OpenCode commits. +type openCodeSessionContext struct { + sessionID string + transcriptPath string + sessionDir string + sessionDirAbs string + userPrompt string + commitMessage string +} + +// setupOpenCodeSessionDir creates session directory and copies transcript if available. +func setupOpenCodeSessionDir(ctx *openCodeSessionContext) error { + ctx.sessionDir = paths.SessionMetadataDirFromSessionID(ctx.sessionID) + sessionDirAbs, err := paths.AbsPath(ctx.sessionDir) + if err != nil { + sessionDirAbs = ctx.sessionDir + } + ctx.sessionDirAbs = sessionDirAbs + + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + // Copy transcript if available + if ctx.transcriptPath != "" && fileExistsAndIsRegular(ctx.transcriptPath) { + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := copyFile(ctx.transcriptPath, logFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to copy transcript: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", ctx.sessionDir+"/"+paths.TranscriptFileName) + } + } + + // Write prompt file + if ctx.userPrompt != "" { + promptFile := filepath.Join(sessionDirAbs, paths.PromptFileName) + if err := os.WriteFile(promptFile, []byte(ctx.userPrompt), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted prompt to: %s\n", ctx.sessionDir+"/"+paths.PromptFileName) + } + + ctx.commitMessage = generateCommitMessage(ctx.userPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", ctx.commitMessage) + + return nil +} + +// commitOpenCodeSession commits the session changes using the shared commit pipeline. +// OpenCode uses git status as primary file change detection (useGitStatusForModified=true) +// since it doesn't have reliable transcript-based file extraction. +func commitOpenCodeSession(ctx *openCodeSessionContext) error { + return commitAgentSession(&agentCommitContext{ + sessionID: ctx.sessionID, + sessionDir: ctx.sessionDir, + sessionDirAbs: ctx.sessionDirAbs, + commitMessage: ctx.commitMessage, + transcriptPath: ctx.transcriptPath, + useGitStatusForModified: true, + prompts: singlePrompt(ctx.userPrompt), + }) +} diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..d3d6030ba 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -117,9 +117,14 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: - // Claude format - fall through to shared logic below + // Claude Code JSONL format - fall through to shared logic below + case agent.AgentTypeCodex, agent.AgentTypeOpenCode: + // TODO: Codex and OpenCode use different JSONL schemas than Claude Code. + // Routing them through the Claude parser will produce empty results. + // Summarization for these agents requires dedicated parsers. + // For now, fall through to avoid breaking the exhaustive linter. } - // Claude format (JSONL) - handles Claude Code, Unknown, and any future agent types + // Claude Code JSONL format - also used as fallback for unsupported agent types lines, err := transcript.ParseFromBytes(content) if err != nil { return nil, fmt.Errorf("failed to parse transcript: %w", err) diff --git a/cmd/entire/cli/utils.go b/cmd/entire/cli/utils.go index 55da2cb1b..ea3974ae2 100644 --- a/cmd/entire/cli/utils.go +++ b/cmd/entire/cli/utils.go @@ -38,6 +38,13 @@ func fileExists(path string) bool { return err == nil } +// fileExistsAndIsRegular checks if a path exists and is a regular file (not a directory). +// Use this instead of fileExists when the caller needs an actual file (e.g., for reading/copying). +func fileExistsAndIsRegular(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + // copyFile copies a file from src to dst func copyFile(src, dst string) error { input, err := os.ReadFile(src) //nolint:gosec // Reading from controlled git metadata path