diff --git a/cmd/entire/cli/agent/codexcli/codex.go b/cmd/entire/cli/agent/codexcli/codex.go new file mode 100644 index 000000000..6dc30a330 --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/codex.go @@ -0,0 +1,347 @@ +package codexcli + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "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, NewCodexCLIAgent) +} + +// CodexCLIAgent implements the Agent interface for OpenAI Codex CLI. +// +//nolint:revive // CodexCLIAgent is clearer than Agent in this context +type CodexCLIAgent struct{} + +// NewCodexCLIAgent creates a new Codex CLI agent instance. +func NewCodexCLIAgent() agent.Agent { + return &CodexCLIAgent{} +} + +// Name returns the agent registry key. +func (c *CodexCLIAgent) Name() agent.AgentName { + return agent.AgentNameCodex +} + +// Type returns the agent type identifier. +func (c *CodexCLIAgent) Type() agent.AgentType { + return agent.AgentTypeCodex +} + +// Description returns a human-readable description. +func (c *CodexCLIAgent) Description() string { + return "Codex CLI - OpenAI's CLI coding agent" +} + +// DetectPresence checks if Codex CLI is configured in the repository. +func (c *CodexCLIAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + // Check for AGENTS.md (Codex project config file) + agentsFile := filepath.Join(repoRoot, "AGENTS.md") + if _, err := os.Stat(agentsFile); err == nil { + return true, nil + } + + // Check for ~/.codex directory (Codex is installed) + homeDir, err := os.UserHomeDir() + if err != nil { + return false, fmt.Errorf("failed to get home directory: %w", err) + } + codexDir := filepath.Join(homeDir, ".codex") + if _, err := os.Stat(codexDir); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns the path to Codex's config file. +func (c *CodexCLIAgent) GetHookConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".codex", "config.toml") +} + +// SupportsHooks returns true as Codex CLI supports the notify hook. +func (c *CodexCLIAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Codex hook input from stdin. +// Codex's notify sends a JSON payload with turn completion data. +func (c *CodexCLIAgent) ParseHookInput(_ 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") + } + + var payload notifyPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse notify payload: %w", err) + } + + input := &agent.HookInput{ + HookType: agent.HookStop, // turn-complete maps to stop semantically + SessionID: payload.ThreadID, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + if len(payload.InputMessages) > 0 { + input.UserPrompt = payload.InputMessages[len(payload.InputMessages)-1] + } + + input.RawData["turn_id"] = payload.TurnID + input.RawData["last_message"] = payload.LastAssistantMessage + + // Resolve the transcript file for this session + sessionDir, err := c.GetSessionDir("") + if err == nil { + transcriptPath := c.findTranscriptBySessionID(sessionDir, payload.ThreadID) + if transcriptPath != "" { + input.SessionRef = transcriptPath + } + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (c *CodexCLIAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ProtectedDirs returns directories that Codex uses for config/state. +// Codex does not create a project-level config directory (unlike .claude or .gemini). +func (c *CodexCLIAgent) ProtectedDirs() []string { return nil } + +// GetSessionDir returns the directory where Codex stores session transcripts. +func (c *CodexCLIAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_CODEX_SESSION_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".codex", "sessions"), nil +} + +// ResolveSessionFile returns the path to a Codex session file. +// Codex uses date-based directory hierarchy: sessions////rollout--.jsonl +func (c *CodexCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + path := c.findTranscriptBySessionID(sessionDir, agentSessionID) + if path != "" { + return path + } + // Return a best-guess path using today's date + now := time.Now() + return filepath.Join( + sessionDir, + strconv.Itoa(now.Year()), + fmt.Sprintf("%02d", now.Month()), + fmt.Sprintf("%02d", now.Day()), + fmt.Sprintf("rollout-%s-%s.jsonl", now.Format("2006-01-02T15-04-05"), agentSessionID), + ) +} + +// findTranscriptBySessionID walks the sessions directory to find a transcript containing the session ID. +func (c *CodexCLIAgent) findTranscriptBySessionID(sessionDir, sessionID string) string { + if sessionDir == "" || sessionID == "" { + return "" + } + + var found string + walkErr := filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil //nolint:nilerr // skip directories with errors during best-effort search + } + if !strings.HasSuffix(path, ".jsonl") { + return nil + } + // Check if filename contains the session ID + if strings.Contains(filepath.Base(path), sessionID) { + found = path + return filepath.SkipAll + } + return nil + }) + if walkErr != nil { + return "" + } + + return found +} + +// ReadSession reads a session from Codex's storage (JSONL transcript file). +func (c *CodexCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + lines, err := ParseTranscript(data) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: c.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to Codex's storage (JSONL transcript file). +func (c *CodexCLIAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != c.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, c.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Codex session. +func (c *CodexCLIAgent) FormatResumeCommand(sessionID string) string { + return "codex --resume " + sessionID +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current line count of a Codex transcript. +func (c *CodexCLIAgent) 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 { + _, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +func (c *CodexCLIAgent) 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) + var lines []TranscriptLine + 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 { + var line TranscriptLine + if parseErr := json.Unmarshal(lineData, &line); parseErr == nil { + lines = append(lines, line) + } + } + } + + if readErr == io.EOF { + break + } + } + + return ExtractModifiedFiles(lines), lineNum, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (c *CodexCLIAgent) 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. +// +//nolint:unparam // error return is required by interface, kept for consistency +func (c *CodexCLIAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} diff --git a/cmd/entire/cli/agent/codexcli/codex_test.go b/cmd/entire/cli/agent/codexcli/codex_test.go new file mode 100644 index 000000000..d7ef7c4dc --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/codex_test.go @@ -0,0 +1,146 @@ +package codexcli + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestName(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + if ag.Name() != agent.AgentNameCodex { + t.Errorf("Name() = %q, want %q", ag.Name(), agent.AgentNameCodex) + } +} + +func TestType(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + if ag.Type() != agent.AgentTypeCodex { + t.Errorf("Type() = %q, want %q", ag.Type(), agent.AgentTypeCodex) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + if ag.Description() == "" { + t.Error("Description() should not be empty") + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + dirs := ag.ProtectedDirs() + if dirs != nil { + t.Errorf("ProtectedDirs() = %v, want nil", dirs) + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() should return true") + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + result := ag.FormatResumeCommand("sess-123") + expected := "codex --resume sess-123" + if result != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", result, expected) + } +} + +func TestResolveSessionFile_WithKnownSession(t *testing.T) { + t.Parallel() + // Create a temp directory with a matching file + tmpDir := t.TempDir() + ag := &CodexCLIAgent{} + + // When no transcript file exists, it should return a best-guess path + result := ag.ResolveSessionFile(tmpDir, "abc-123-def") + if result == "" { + t.Error("ResolveSessionFile() should return a non-empty path") + } + if !strings.Contains(result, "abc-123-def") { + t.Errorf("ResolveSessionFile() = %q, should contain session ID", result) + } +} + +func TestParseHookInput_TurnComplete(t *testing.T) { + t.Parallel() + + ag := &CodexCLIAgent{} + input := `{"type":"agent-turn-complete","turn-id":"turn-1","thread-id":"thread-abc","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 != "thread-abc" { + t.Errorf("SessionID = %q, want %q", result.SessionID, "thread-abc") + } + if result.UserPrompt != "fix the bug" { + t.Errorf("UserPrompt = %q, want %q", result.UserPrompt, "fix the bug") + } + if result.RawData["turn_id"] != "turn-1" { + t.Errorf("RawData[turn_id] = %q, want %q", result.RawData["turn_id"], "turn-1") + } + if result.RawData["last_message"] != "done" { + t.Errorf("RawData[last_message] = %q, want %q", result.RawData["last_message"], "done") + } +} + +func TestParseHookInput_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &CodexCLIAgent{} + _, err := ag.ParseHookInput(agent.HookStop, strings.NewReader("")) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + t.Parallel() + + ag := &CodexCLIAgent{} + _, err := ag.ParseHookInput(agent.HookStop, strings.NewReader("not json")) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +func TestParseHookInput_NoInputMessages(t *testing.T) { + t.Parallel() + + ag := &CodexCLIAgent{} + input := `{"type":"agent-turn-complete","turn-id":"turn-1","thread-id":"thread-abc"}` + + result, err := ag.ParseHookInput(agent.HookStop, strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if result.UserPrompt != "" { + t.Errorf("UserPrompt = %q, want empty", result.UserPrompt) + } +} + +func TestGetSessionID(t *testing.T) { + t.Parallel() + + ag := &CodexCLIAgent{} + input := &agent.HookInput{SessionID: "sess-456"} + if ag.GetSessionID(input) != "sess-456" { + t.Errorf("GetSessionID() = %q, want %q", ag.GetSessionID(input), "sess-456") + } +} diff --git a/cmd/entire/cli/agent/codexcli/hooks.go b/cmd/entire/cli/agent/codexcli/hooks.go new file mode 100644 index 000000000..8cfd81f7a --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/hooks.go @@ -0,0 +1,286 @@ +package codexcli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure CodexCLIAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*CodexCLIAgent)(nil) + _ agent.HookHandler = (*CodexCLIAgent)(nil) +) + +// entireNotifyCmd is the full Entire notify command for Codex turn-complete +const entireNotifyCmd = "entire hooks codex turn-complete" + +// entireNotifyPrefix identifies Entire's notify command +const entireNotifyPrefix = "entire hooks codex" + +// localDevNotifyPrefix identifies Entire's local dev notify command +const localDevNotifyPrefix = "go run" + +// GetHookNames returns the hook verbs Codex supports. +// These become subcommands: entire hooks codex +func (c *CodexCLIAgent) GetHookNames() []string { + return []string{ + HookNameTurnComplete, + } +} + +// InstallHooks installs the Codex notify hook in ~/.codex/config.toml. +// Codex's notify config triggers an external command on agent-turn-complete events. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (c *CodexCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { + configPath := c.GetHookConfigPath() + if configPath == "" { + return 0, errors.New("could not determine Codex config path") + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .codex directory: %w", err) + } + + // Define the notify command + var notifyCmd string + if localDev { + // Get repo root for local dev builds + repoRoot, err := paths.RepoRoot() + if err != nil { + return 0, fmt.Errorf("failed to get repo root for local dev: %w", err) + } + notifyCmd = fmt.Sprintf("go run %s/cmd/entire/main.go hooks codex turn-complete", repoRoot) + } else { + notifyCmd = entireNotifyCmd + } + + // Read existing config + existingData, err := os.ReadFile(configPath) //nolint:gosec // path is from user's home dir + if err != nil && !os.IsNotExist(err) { + return 0, fmt.Errorf("failed to read config.toml: %w", err) + } + + content := string(existingData) + + // If force, remove existing Entire notify entries + if force { + content = removeEntireNotify(content) + } + + // Check if notify command already exists + if strings.Contains(content, notifyCmd) { + return 0, nil // Already installed + } + + // Check if there's already a notify line + if hasNotifyConfig(content) { + // There's an existing notify — we need to check if it's an array or a simple value. + // Codex config supports notify as an array of strings. + // For simplicity, we append our command alongside the existing one. + content = appendToNotify(content, notifyCmd) + } else { + // No existing notify — add it + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += fmt.Sprintf("notify = [\"%s\"]\n", escapeTomlString(notifyCmd)) + } + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + return 0, fmt.Errorf("failed to write config.toml: %w", err) + } + + return 1, nil +} + +// UninstallHooks removes Entire hooks from Codex config. +func (c *CodexCLIAgent) UninstallHooks() error { + configPath := c.GetHookConfigPath() + if configPath == "" { + return nil + } + + data, err := os.ReadFile(configPath) //nolint:gosec // path is from user's home dir + if err != nil { + return nil //nolint:nilerr // No config file means nothing to uninstall + } + + content := removeEntireNotify(string(data)) + + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + return fmt.Errorf("failed to write config.toml: %w", err) + } + + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed in Codex config. +func (c *CodexCLIAgent) AreHooksInstalled() bool { + configPath := c.GetHookConfigPath() + if configPath == "" { + return false + } + + data, err := os.ReadFile(configPath) //nolint:gosec // path is from user's home dir + if err != nil { + return false + } + + content := string(data) + return strings.Contains(content, entireNotifyPrefix) +} + +// GetSupportedHooks returns the hook types Codex supports. +func (c *CodexCLIAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookStop, // agent-turn-complete maps to stop + } +} + +// hasNotifyConfig checks if there's already a notify line in the config. +func hasNotifyConfig(content string) bool { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") { + return true + } + } + return false +} + +// appendToNotify adds a command to an existing notify config. +// Handles both array format (notify = ["cmd1", "cmd2"]) and string format (notify = "cmd"). +func appendToNotify(content, cmd string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "notify") || !strings.Contains(trimmed, "=") { + continue + } + + // Check if it's array format + if strings.Contains(trimmed, "[") { + // Find the closing bracket and insert before it + closeBracket := strings.LastIndex(trimmed, "]") + if closeBracket >= 0 { + before := trimmed[:closeBracket] + // Check if there are existing entries + if strings.Contains(before, "\"") { + lines[i] = before + fmt.Sprintf(", \"%s\"]", escapeTomlString(cmd)) + } else { + lines[i] = before + fmt.Sprintf("\"%s\"]", escapeTomlString(cmd)) + } + } + } else { + // Simple string format — convert to array + eqIdx := strings.Index(trimmed, "=") + if eqIdx >= 0 { + existingVal := strings.TrimSpace(trimmed[eqIdx+1:]) + existingVal = strings.Trim(existingVal, "\"") + lines[i] = fmt.Sprintf("notify = [\"%s\", \"%s\"]", + escapeTomlString(existingVal), escapeTomlString(cmd)) + } + } + break + } + return strings.Join(lines, "\n") +} + +// removeEntireNotify removes Entire-related notify entries from config content. +func removeEntireNotify(content string) string { + lines := strings.Split(content, "\n") + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // If it's a notify line, filter out Entire entries + if strings.HasPrefix(trimmed, "notify") && strings.Contains(trimmed, "=") { + if strings.Contains(trimmed, entireNotifyPrefix) || strings.Contains(trimmed, localDevNotifyPrefix) { + // Check if there are other non-Entire entries + // For simplicity, if the entire notify line is just Entire, remove it + if !hasNonEntireNotifyEntries(trimmed) { + continue // Skip the entire line + } + // Otherwise, filter out just the Entire entries + line = filterEntireFromNotifyLine(trimmed) + } + } + + result = append(result, line) + } + + return strings.Join(result, "\n") +} + +// hasNonEntireNotifyEntries checks if a notify line has entries other than Entire's. +func hasNonEntireNotifyEntries(line string) bool { + eqIdx := strings.Index(line, "=") + if eqIdx < 0 { + return false + } + val := strings.TrimSpace(line[eqIdx+1:]) + val = strings.Trim(val, "[]") + + for _, entry := range strings.Split(val, ",") { + entry = strings.TrimSpace(entry) + entry = strings.Trim(entry, "\"") + if entry != "" && !strings.Contains(entry, entireNotifyPrefix) && !strings.Contains(entry, localDevNotifyPrefix) { + return true + } + } + return false +} + +// filterEntireFromNotifyLine removes Entire entries from a notify array line. +func filterEntireFromNotifyLine(line string) string { + eqIdx := strings.Index(line, "=") + if eqIdx < 0 { + return line + } + + val := strings.TrimSpace(line[eqIdx+1:]) + isArray := strings.HasPrefix(val, "[") + + if !isArray { + // Simple string value — if it's Entire, remove the whole line + unquoted := strings.Trim(val, "\"") + if strings.Contains(unquoted, entireNotifyPrefix) || strings.Contains(unquoted, localDevNotifyPrefix) { + return "" + } + return line + } + + // Array format — filter entries + val = strings.Trim(val, "[]") + entries := strings.Split(val, ",") + var kept []string + for _, entry := range entries { + entry = strings.TrimSpace(entry) + unquoted := strings.Trim(entry, "\"") + if unquoted != "" && !strings.Contains(unquoted, entireNotifyPrefix) && !strings.Contains(unquoted, localDevNotifyPrefix) { + kept = append(kept, entry) + } + } + + if len(kept) == 0 { + return "" + } + + return fmt.Sprintf("notify = [%s]", strings.Join(kept, ", ")) +} + +// escapeTomlString escapes a string for use in TOML. +func escapeTomlString(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} diff --git a/cmd/entire/cli/agent/codexcli/hooks_test.go b/cmd/entire/cli/agent/codexcli/hooks_test.go new file mode 100644 index 000000000..4c265b172 --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/hooks_test.go @@ -0,0 +1,222 @@ +package codexcli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestGetHookNames(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + names := ag.GetHookNames() + if len(names) != 1 || names[0] != HookNameTurnComplete { + t.Errorf("GetHookNames() = %v, want [%s]", names, HookNameTurnComplete) + } +} + +func TestGetSupportedHooks(t *testing.T) { + t.Parallel() + ag := &CodexCLIAgent{} + hooks := ag.GetSupportedHooks() + if len(hooks) != 1 || hooks[0] != agent.HookStop { + t.Errorf("GetSupportedHooks() = %v, want [%v]", hooks, agent.HookStop) + } +} + +func TestInstallHooks_FreshInstall(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + + // Override GetHookConfigPath by creating the directory and writing directly + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + + // Simulate a fresh config file + if err := os.WriteFile(configPath, []byte("model = \"gpt-4\"\n"), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + // Test the TOML manipulation helpers directly + content := "model = \"gpt-4\"\n" + notifyCmd := "entire hooks codex turn-complete" + + // No existing notify — should add it + if hasNotifyConfig(content) { + t.Error("hasNotifyConfig() should return false for config without notify") + } + + content += "notify = [\"" + escapeTomlString(notifyCmd) + "\"]\n" + + if !strings.Contains(content, notifyCmd) { + t.Error("content should contain notify command after adding") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + t.Parallel() + + content := "notify = [\"entire hooks codex turn-complete\"]\n" + notifyCmd := "entire hooks codex turn-complete" + + // Already installed — should detect + if !strings.Contains(content, notifyCmd) { + t.Error("should detect already installed command") + } +} + +func TestHasNotifyConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want bool + }{ + {"empty", "", false}, + {"no notify", "model = \"gpt-4\"\n", false}, + {"simple notify", "notify = \"cmd\"\n", true}, + {"array notify", "notify = [\"cmd1\", \"cmd2\"]\n", true}, + {"notify with spaces", " notify = [\"cmd\"]\n", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := hasNotifyConfig(tt.content); got != tt.want { + t.Errorf("hasNotifyConfig(%q) = %v, want %v", tt.content, got, tt.want) + } + }) + } +} + +func TestAppendToNotify_Array(t *testing.T) { + t.Parallel() + + content := "notify = [\"existing-cmd\"]\nmodel = \"gpt-4\"\n" + result := appendToNotify(content, "new-cmd") + + if !strings.Contains(result, "existing-cmd") { + t.Error("should preserve existing command") + } + if !strings.Contains(result, "new-cmd") { + t.Error("should add new command") + } +} + +func TestAppendToNotify_String(t *testing.T) { + t.Parallel() + + content := "notify = \"existing-cmd\"\n" + result := appendToNotify(content, "new-cmd") + + if !strings.Contains(result, "existing-cmd") { + t.Error("should preserve existing command") + } + if !strings.Contains(result, "new-cmd") { + t.Error("should add new command") + } + // Should be converted to array format + if !strings.Contains(result, "[") { + t.Error("should convert to array format") + } +} + +func TestRemoveEntireNotify_RemovesEntireOnly(t *testing.T) { + t.Parallel() + + content := "notify = [\"user-cmd\", \"entire hooks codex turn-complete\"]\nmodel = \"gpt-4\"\n" + result := removeEntireNotify(content) + + if !strings.Contains(result, "user-cmd") { + t.Error("should preserve user command") + } + if strings.Contains(result, "entire hooks codex") { + t.Error("should remove Entire command") + } +} + +func TestRemoveEntireNotify_RemovesEntireLine(t *testing.T) { + t.Parallel() + + content := "notify = [\"entire hooks codex turn-complete\"]\nmodel = \"gpt-4\"\n" + result := removeEntireNotify(content) + + if strings.Contains(result, "notify") { + t.Error("should remove entire notify line when only Entire entries exist") + } + if !strings.Contains(result, "model") { + t.Error("should preserve other config lines") + } +} + +func TestRemoveEntireNotify_LocalDev(t *testing.T) { + t.Parallel() + + content := "notify = [\"go run /path/to/main.go hooks codex turn-complete\"]\n" + result := removeEntireNotify(content) + + if strings.Contains(result, "notify") { + t.Error("should remove local dev notify entries") + } +} + +func TestEscapeTomlString(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"simple", "simple"}, + {"with \"quotes\"", "with \\\"quotes\\\""}, + {"with \\backslash", "with \\\\backslash"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := escapeTomlString(tt.input); got != tt.want { + t.Errorf("escapeTomlString(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAreHooksInstalled(t *testing.T) { + t.Parallel() + + // Without a real config file, should return false + ag := &CodexCLIAgent{} + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() should return false when no config exists") + } +} + +func TestHasNonEntireNotifyEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + want bool + }{ + {"only entire", `notify = ["entire hooks codex turn-complete"]`, false}, + {"mixed", `notify = ["user-cmd", "entire hooks codex turn-complete"]`, true}, + {"only user", `notify = ["user-cmd"]`, true}, + {"local dev only", `notify = ["go run /path/main.go hooks codex turn-complete"]`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := hasNonEntireNotifyEntries(tt.line); got != tt.want { + t.Errorf("hasNonEntireNotifyEntries(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} diff --git a/cmd/entire/cli/agent/codexcli/transcript.go b/cmd/entire/cli/agent/codexcli/transcript.go new file mode 100644 index 000000000..226f96e3e --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/transcript.go @@ -0,0 +1,350 @@ +package codexcli + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Scanner buffer size for large transcript files (10MB) +const scannerBufferSize = 10 * 1024 * 1024 + +// ParseTranscript parses raw JSONL content into transcript lines. +func ParseTranscript(data []byte) ([]TranscriptLine, error) { + var lines []TranscriptLine + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, scannerBufferSize), scannerBufferSize) + + for scanner.Scan() { + var line TranscriptLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + continue // Skip malformed lines + } + lines = append(lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan transcript: %w", err) + } + return lines, nil +} + +// SerializeTranscript converts transcript lines back to JSONL bytes. +func SerializeTranscript(lines []TranscriptLine) ([]byte, error) { + var buf bytes.Buffer + for _, line := range lines { + data, err := json.Marshal(line) + if err != nil { + return nil, fmt.Errorf("failed to marshal line: %w", err) + } + buf.Write(data) + buf.WriteByte('\n') + } + return buf.Bytes(), nil +} + +// ExtractModifiedFiles extracts files modified by exec_command tool calls. +// Unlike Claude/Gemini which use dedicated Write/Edit tools, Codex uses +// exec_command with shell commands. Detection is heuristic-based. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]bool) + var files []string + + for _, line := range lines { + if line.Type != eventTypeResponseItem { + continue + } + + var payload responseItemPayload + if err := json.Unmarshal(line.Payload, &payload); err != nil { + continue + } + + if payload.Type != responseItemFunctionCall || payload.Name != "exec_command" { + continue + } + + var args execCommandArgs + if err := json.Unmarshal([]byte(payload.Arguments), &args); err != nil { + continue + } + + // Check if the command modifies files + if !isFileModifyingCommand(args.Cmd) { + continue + } + + // Extract file paths from the command + extractedFiles := extractFilesFromCommand(args.Cmd, args.Workdir) + for _, f := range extractedFiles { + if f != "" && !fileSet[f] { + fileSet[f] = true + files = append(files, f) + } + } + } + + return files +} + +// isFileModifyingCommand checks if a shell command modifies files. +func isFileModifyingCommand(cmd string) bool { + for _, pattern := range fileModifyingPatterns { + if strings.Contains(cmd, pattern) { + return true + } + } + return false +} + +// extractFilesFromCommand attempts to extract file paths from a shell command. +// This is heuristic-based — it handles common patterns but not all edge cases. +func extractFilesFromCommand(cmd, workdir string) []string { + var files []string + + // Handle apply_patch — look for diff headers + if strings.Contains(cmd, "apply_patch") { + for _, line := range strings.Split(cmd, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "--- a/") || strings.HasPrefix(line, "+++ b/") { + path := strings.TrimPrefix(line, "--- a/") + path = strings.TrimPrefix(path, "+++ b/") + if path != "" && path != "/dev/null" { + if workdir != "" && !filepath.IsAbs(path) { + path = filepath.Join(workdir, path) + } + files = append(files, path) + } + } + } + return files + } + + // For other commands, extract paths from common patterns. + // This is intentionally conservative — we only extract when + // the pattern is unambiguous. + + // Handle "cat > file" or "tee file" + if idx := strings.Index(cmd, " > "); idx > 0 { + path := strings.TrimSpace(cmd[idx+3:]) + path = strings.SplitN(path, " ", 2)[0] + path = strings.Trim(path, "'\"") + if path != "" { + files = append(files, path) + } + } + + // Handle "sed -i ... file" — last argument is typically the file + if strings.Contains(cmd, "sed -i") { + parts := strings.Fields(cmd) + if len(parts) > 0 { + last := parts[len(parts)-1] + last = strings.Trim(last, "'\"") + if !strings.HasPrefix(last, "-") && strings.Contains(last, ".") { + files = append(files, last) + } + } + } + + return files +} + +// ExtractLastUserPrompt extracts the last user message from the transcript. +func ExtractLastUserPrompt(lines []TranscriptLine) string { + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != eventTypeEventMsg { + continue + } + + var payload eventMsgPayload + if err := json.Unmarshal(lines[i].Payload, &payload); err != nil { + continue + } + + if payload.Type == eventMsgUserMessage && payload.Message != "" { + return payload.Message + } + } + return "" +} + +// ExtractAllUserPrompts collects all user messages from the transcript. +func ExtractAllUserPrompts(lines []TranscriptLine) []string { + var prompts []string + for _, line := range lines { + if line.Type != eventTypeEventMsg { + continue + } + + var payload eventMsgPayload + if err := json.Unmarshal(line.Payload, &payload); err != nil { + continue + } + + if payload.Type == eventMsgUserMessage && payload.Message != "" { + prompts = append(prompts, payload.Message) + } + } + return prompts +} + +// ExtractLastAssistantMessage extracts the last agent message from the transcript. +func ExtractLastAssistantMessage(lines []TranscriptLine) string { + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Type != eventTypeEventMsg { + continue + } + + var payload eventMsgPayload + if err := json.Unmarshal(lines[i].Payload, &payload); err != nil { + continue + } + + if payload.Type == eventMsgAgentMessage && payload.Message != "" { + return payload.Message + } + } + return "" +} + +// ExtractSessionID extracts the session ID from the first session_meta event. +func ExtractSessionID(lines []TranscriptLine) string { + for _, line := range lines { + if line.Type != eventTypeSessionMeta { + continue + } + + var meta sessionMetaPayload + if err := json.Unmarshal(line.Payload, &meta); err != nil { + continue + } + + return meta.ID + } + return "" +} + +// ExtractSessionCWD extracts the working directory from session metadata. +func ExtractSessionCWD(lines []TranscriptLine) string { + for _, line := range lines { + if line.Type != eventTypeSessionMeta { + continue + } + + var meta sessionMetaPayload + if err := json.Unmarshal(line.Payload, &meta); err != nil { + continue + } + + return meta.CWD + } + return "" +} + +// CalculateTokenUsage calculates token usage from a Codex transcript. +// Codex emits token_count events with cumulative totals per turn. +// We take the last token_count per turn to get accurate turn-level usage. +func CalculateTokenUsage(lines []TranscriptLine) *agent.TokenUsage { + usage := &agent.TokenUsage{} + + // Track the last token_count event (cumulative for the session) + var lastInfo tokenCountInfo + + for _, line := range lines { + if line.Type != eventTypeEventMsg { + continue + } + + var payload eventMsgPayload + if err := json.Unmarshal(line.Payload, &payload); err != nil { + continue + } + + if payload.Type != eventMsgTokenCount || payload.Info == nil { + continue + } + + var info tokenCountInfo + if err := json.Unmarshal(payload.Info, &info); err != nil { + continue + } + + lastInfo = info + } + + // Use the last cumulative token count + usage.InputTokens = lastInfo.TotalTokenUsage.InputTokens + usage.CacheReadTokens = lastInfo.TotalTokenUsage.CachedInputTokens + usage.OutputTokens = lastInfo.TotalTokenUsage.OutputTokens + + // Count API calls by counting task_complete events + for _, line := range lines { + if line.Type != eventTypeEventMsg { + continue + } + var payload eventMsgPayload + if err := json.Unmarshal(line.Payload, &payload); err != nil { + continue + } + if payload.Type == eventMsgTaskComplete { + usage.APICallCount++ + } + } + + return usage +} + +// CalculateTokenUsageFromFile calculates token usage from a Codex transcript file. +// If startLine > 0, only considers lines from startLine onwards. +func CalculateTokenUsageFromFile(path string, startLine int) (*agent.TokenUsage, error) { + if path == "" { + return &agent.TokenUsage{}, nil + } + + lines, err := parseTranscriptFromLine(path, startLine) + if err != nil { + return nil, err + } + + return CalculateTokenUsage(lines), nil +} + +// parseTranscriptFromLine parses a transcript file starting from a specific line. +func parseTranscriptFromLine(path string, startLine int) ([]TranscriptLine, error) { + file, err := os.Open(path) //nolint:gosec // Path comes from Codex transcript location + if err != nil { + return nil, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + var lines []TranscriptLine + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, scannerBufferSize), scannerBufferSize) + + lineNum := 0 + for scanner.Scan() { + if lineNum < startLine { + lineNum++ + continue + } + lineNum++ + + var line TranscriptLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + continue // Skip malformed lines + } + lines = append(lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan transcript: %w", err) + } + + return lines, nil +} diff --git a/cmd/entire/cli/agent/codexcli/transcript_test.go b/cmd/entire/cli/agent/codexcli/transcript_test.go new file mode 100644 index 000000000..1e8efe623 --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/transcript_test.go @@ -0,0 +1,454 @@ +package codexcli + +import ( + "encoding/json" + "os" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestParseTranscript(t *testing.T) { + t.Parallel() + + data := makeTranscriptJSONL(t, + TranscriptLine{Timestamp: "2024-01-01T00:00:00Z", Type: eventTypeSessionMeta, Payload: mustMarshalRaw(t, sessionMetaPayload{ID: "sess-1", CWD: "/tmp"})}, + TranscriptLine{Timestamp: "2024-01-01T00:01:00Z", Type: eventTypeEventMsg, Payload: mustMarshalRaw(t, eventMsgPayload{Type: eventMsgUserMessage, Message: "hello"})}, + ) + + lines, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(lines) != 2 { + t.Errorf("ParseTranscript() got %d lines, want 2", len(lines)) + } + + if lines[0].Type != eventTypeSessionMeta { + t.Errorf("First line type = %q, want %q", lines[0].Type, eventTypeSessionMeta) + } + if lines[1].Type != eventTypeEventMsg { + t.Errorf("Second line type = %q, want %q", lines[1].Type, eventTypeEventMsg) + } +} + +func TestParseTranscript_SkipsMalformed(t *testing.T) { + t.Parallel() + + data := []byte(`{"timestamp":"t1","type":"session_meta","payload":{}} +not valid json +{"timestamp":"t2","type":"event_msg","payload":{}} +`) + + lines, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(lines) != 2 { + t.Errorf("ParseTranscript() got %d lines, want 2 (skipping malformed)", len(lines)) + } +} + +func TestSerializeTranscript(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + {Timestamp: "t1", Type: eventTypeSessionMeta}, + {Timestamp: "t2", Type: eventTypeEventMsg}, + } + + data, err := SerializeTranscript(lines) + if err != nil { + t.Fatalf("SerializeTranscript() error = %v", err) + } + + parsed, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript(serialized) error = %v", err) + } + + if len(parsed) != 2 { + t.Errorf("Round-trip got %d lines, want 2", len(parsed)) + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeExecCommandItem(t, `{"cmd":"apply_patch <<'EOF'\n--- a/foo.go\n+++ b/foo.go\nEOF"}`), + makeExecCommandItem(t, `{"cmd":"cat > bar.txt"}`), + makeExecCommandItem(t, `{"cmd":"ls -la"}`), // not file-modifying + makeExecCommandItem(t, `{"cmd":"apply_patch <<'EOF'\n--- a/foo.go\n+++ b/foo.go\nEOF"}`), // duplicate + } + + files := ExtractModifiedFiles(lines) + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("ExtractModifiedFiles() missing foo.go from apply_patch") + } + if !hasFile("bar.txt") { + t.Error("ExtractModifiedFiles() missing bar.txt from cat >") + } +} + +func TestExtractModifiedFiles_SedCommand(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeExecCommandItem(t, `{"cmd":"sed -i 's/old/new/g' config.yaml"}`), + } + + files := ExtractModifiedFiles(lines) + + if len(files) != 1 || files[0] != "config.yaml" { + t.Errorf("ExtractModifiedFiles() = %v, want [config.yaml]", files) + } +} + +func TestExtractLastUserPrompt(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeEventMsg(t, eventMsgUserMessage, "first prompt"), + makeEventMsg(t, eventMsgAgentMessage, "response 1"), + makeEventMsg(t, eventMsgUserMessage, "second prompt"), + } + + got := ExtractLastUserPrompt(lines) + if got != "second prompt" { + t.Errorf("ExtractLastUserPrompt() = %q, want %q", got, "second prompt") + } +} + +func TestExtractLastUserPrompt_Empty(t *testing.T) { + t.Parallel() + + got := ExtractLastUserPrompt(nil) + if got != "" { + t.Errorf("ExtractLastUserPrompt(nil) = %q, want empty", got) + } +} + +func TestExtractAllUserPrompts(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeEventMsg(t, eventMsgUserMessage, "prompt 1"), + makeEventMsg(t, eventMsgAgentMessage, "response"), + makeEventMsg(t, eventMsgUserMessage, "prompt 2"), + makeEventMsg(t, eventMsgTokenCount, ""), + } + + prompts := ExtractAllUserPrompts(lines) + if len(prompts) != 2 { + t.Fatalf("ExtractAllUserPrompts() got %d prompts, want 2", len(prompts)) + } + if prompts[0] != "prompt 1" || prompts[1] != "prompt 2" { + t.Errorf("ExtractAllUserPrompts() = %v, want [prompt 1, prompt 2]", prompts) + } +} + +func TestExtractLastAssistantMessage(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeEventMsg(t, eventMsgAgentMessage, "first response"), + makeEventMsg(t, eventMsgAgentMessage, "second response"), + } + + got := ExtractLastAssistantMessage(lines) + if got != "second response" { + t.Errorf("ExtractLastAssistantMessage() = %q, want %q", got, "second response") + } +} + +func TestExtractSessionID(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + {Type: eventTypeSessionMeta, Payload: mustMarshalRaw(t, sessionMetaPayload{ID: "sess-abc-123"})}, + makeEventMsg(t, eventMsgUserMessage, "hello"), + } + + got := ExtractSessionID(lines) + if got != "sess-abc-123" { + t.Errorf("ExtractSessionID() = %q, want %q", got, "sess-abc-123") + } +} + +func TestExtractSessionCWD(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + {Type: eventTypeSessionMeta, Payload: mustMarshalRaw(t, sessionMetaPayload{CWD: "/home/user/project"})}, + } + + got := ExtractSessionCWD(lines) + if got != "/home/user/project" { + t.Errorf("ExtractSessionCWD() = %q, want %q", got, "/home/user/project") + } +} + +func TestCalculateTokenUsage(t *testing.T) { + t.Parallel() + + lines := []TranscriptLine{ + makeTokenCountEvent(t, 100, 50, 200, 20), + makeTokenCountEvent(t, 300, 100, 500, 50), // cumulative — last one wins + makeEventMsg(t, eventMsgTaskComplete, ""), + } + + usage := CalculateTokenUsage(lines) + + if usage.InputTokens != 300 { + t.Errorf("InputTokens = %d, want 300", usage.InputTokens) + } + if usage.CacheReadTokens != 100 { + t.Errorf("CacheReadTokens = %d, want 100", usage.CacheReadTokens) + } + if usage.OutputTokens != 500 { + t.Errorf("OutputTokens = %d, want 500", usage.OutputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } +} + +func TestCalculateTokenUsage_Empty(t *testing.T) { + t.Parallel() + + usage := CalculateTokenUsage(nil) + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.APICallCount != 0 { + t.Errorf("empty transcript should return zero usage, got %+v", usage) + } +} + +func TestCalculateTokenUsageFromFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := tmpDir + "/transcript.jsonl" + + data := makeTranscriptJSONL(t, + makeTokenCountEvent(t, 100, 0, 50, 10), + makeEventMsg(t, eventMsgTaskComplete, ""), + ) + + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + usage, err := CalculateTokenUsageFromFile(path, 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile() error = %v", err) + } + + if usage.InputTokens != 100 { + t.Errorf("InputTokens = %d, want 100", usage.InputTokens) + } + if usage.APICallCount != 1 { + t.Errorf("APICallCount = %d, want 1", usage.APICallCount) + } +} + +func TestCalculateTokenUsageFromFile_EmptyPath(t *testing.T) { + t.Parallel() + + usage, err := CalculateTokenUsageFromFile("", 0) + if err != nil { + t.Fatalf("CalculateTokenUsageFromFile() error = %v", err) + } + if usage.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", usage.InputTokens) + } +} + +func TestIsFileModifyingCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + cmd string + want bool + }{ + {"apply_patch <<'EOF'", true}, + {"cat > file.txt", true}, + {"cat >> file.txt", true}, + {"tee output.log", true}, + {"sed -i 's/a/b/' file.go", true}, + {"ls -la", false}, + {"git status", false}, + {"grep pattern file", false}, + {"echo hello > file.txt", true}, + {"mv old.txt new.txt", true}, + {"cp src.txt dst.txt", true}, + {"mkdir -p dir/subdir", true}, + {"touch newfile.txt", true}, + {"rm old.txt", true}, + } + + for _, tt := range tests { + t.Run(tt.cmd, func(t *testing.T) { + t.Parallel() + if got := isFileModifyingCommand(tt.cmd); got != tt.want { + t.Errorf("isFileModifyingCommand(%q) = %v, want %v", tt.cmd, got, tt.want) + } + }) + } +} + +func TestExtractFilesFromCommand_ApplyPatch(t *testing.T) { + t.Parallel() + + cmd := "apply_patch <<'EOF'\n--- a/src/main.go\n+++ b/src/main.go\n@@ -1,3 +1,4 @@\n package main\n+import \"fmt\"\nEOF" + files := extractFilesFromCommand(cmd, "") + + // Both --- a/ and +++ b/ lines produce paths; dedup happens in ExtractModifiedFiles + if len(files) == 0 { + t.Fatal("extractFilesFromCommand() returned no files") + } + found := false + for _, f := range files { + if f == "src/main.go" { + found = true + break + } + } + if !found { + t.Errorf("extractFilesFromCommand() = %v, want to contain src/main.go", files) + } +} + +func TestExtractFilesFromCommand_CatRedirect(t *testing.T) { + t.Parallel() + + cmd := "cat > output.txt" + files := extractFilesFromCommand(cmd, "") + + if len(files) != 1 || files[0] != "output.txt" { + t.Errorf("extractFilesFromCommand(%q) = %v, want [output.txt]", cmd, files) + } +} + +func TestExtractFilesFromCommand_SedInPlace(t *testing.T) { + t.Parallel() + + cmd := "sed -i 's/old/new/g' config.yaml" + files := extractFilesFromCommand(cmd, "") + + if len(files) != 1 || files[0] != "config.yaml" { + t.Errorf("extractFilesFromCommand(%q) = %v, want [config.yaml]", cmd, files) + } +} + +func TestExtractFilesFromCommand_ApplyPatchWithWorkdir(t *testing.T) { + t.Parallel() + + cmd := "apply_patch <<'EOF'\n--- a/file.go\n+++ b/file.go\nEOF" + files := extractFilesFromCommand(cmd, "/home/user/project") + + // Both --- a/ and +++ b/ lines produce paths; dedup happens in ExtractModifiedFiles + if len(files) == 0 { + t.Fatal("extractFilesFromCommand() returned no files") + } + found := false + for _, f := range files { + if f == "/home/user/project/file.go" { + found = true + break + } + } + if !found { + t.Errorf("extractFilesFromCommand() = %v, want to contain /home/user/project/file.go", files) + } +} + +// Helper functions + +func mustMarshalRaw(t *testing.T, v interface{}) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + return data +} + +func makeTranscriptJSONL(t *testing.T, lines ...TranscriptLine) []byte { + t.Helper() + data, err := SerializeTranscript(lines) + if err != nil { + t.Fatalf("SerializeTranscript() error = %v", err) + } + return data +} + +func makeEventMsg(t *testing.T, msgType, message string) TranscriptLine { + t.Helper() + return TranscriptLine{ + Type: eventTypeEventMsg, + Payload: mustMarshalRaw(t, eventMsgPayload{ + Type: msgType, + Message: message, + }), + } +} + +func makeExecCommandItem(t *testing.T, args string) TranscriptLine { + t.Helper() + return TranscriptLine{ + Type: eventTypeResponseItem, + Payload: mustMarshalRaw(t, responseItemPayload{ + Type: responseItemFunctionCall, + Name: "exec_command", + Arguments: args, + }), + } +} + +func makeTokenCountEvent(t *testing.T, input, cached, output, reasoning int) TranscriptLine { + t.Helper() + info := tokenCountInfo{} + info.TotalTokenUsage.InputTokens = input + info.TotalTokenUsage.CachedInputTokens = cached + info.TotalTokenUsage.OutputTokens = output + info.TotalTokenUsage.ReasoningOutputTokens = reasoning + + return TranscriptLine{ + Type: eventTypeEventMsg, + Payload: mustMarshalRaw(t, eventMsgPayload{ + Type: eventMsgTokenCount, + Info: mustMarshalRaw(t, info), + }), + } +} + +// Ensure the TokenUsage type is correctly wired +func TestTokenUsageType(t *testing.T) { + t.Parallel() + usage := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 50, + APICallCount: 1, + } + if usage.InputTokens != 100 { + t.Error("TokenUsage.InputTokens should be 100") + } + if usage.OutputTokens != 50 { + t.Error("TokenUsage.OutputTokens should be 50") + } + if usage.APICallCount != 1 { + t.Error("TokenUsage.APICallCount should be 1") + } +} diff --git a/cmd/entire/cli/agent/codexcli/types.go b/cmd/entire/cli/agent/codexcli/types.go new file mode 100644 index 000000000..0a0050218 --- /dev/null +++ b/cmd/entire/cli/agent/codexcli/types.go @@ -0,0 +1,123 @@ +// Package codexcli implements the Agent interface for OpenAI Codex CLI. +package codexcli + +import "encoding/json" + +// Codex CLI hook names - these become subcommands under `entire hooks codex` +const ( + HookNameTurnComplete = "turn-complete" +) + +// notifyPayload is the JSON structure received from Codex's notify hook +// when the agent-turn-complete event fires. +type notifyPayload struct { + Type string `json:"type"` + TurnID string `json:"turn-id"` + ThreadID string `json:"thread-id"` + InputMessages []string `json:"input-messages"` + LastAssistantMessage string `json:"last-assistant-message"` +} + +// Codex transcript JSONL event types +const ( + eventTypeSessionMeta = "session_meta" + eventTypeResponseItem = "response_item" + eventTypeEventMsg = "event_msg" + eventTypeTurnContext = "turn_context" +) + +// Codex event_msg subtypes +const ( + eventMsgUserMessage = "user_message" + eventMsgAgentMessage = "agent_message" + eventMsgAgentReasoning = "agent_reasoning" + eventMsgTaskStarted = "task_started" + eventMsgTaskComplete = "task_complete" + eventMsgTokenCount = "token_count" + eventMsgTurnAborted = "turn_aborted" +) + +// Codex response_item subtypes +const ( + responseItemMessage = "message" + responseItemFunctionCall = "function_call" + responseItemFunctionCallOut = "function_call_output" + responseItemReasoning = "reasoning" +) + +// TranscriptLine represents a single line in a Codex JSONL transcript. +type TranscriptLine struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +// sessionMetaPayload is the payload for session_meta events. +type sessionMetaPayload struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` + Originator string `json:"originator"` + CLIVersion string `json:"cli_version"` + Model string `json:"model_provider"` + Git struct { + CommitHash string `json:"commit_hash"` + Branch string `json:"branch"` + RepositoryURL string `json:"repository_url"` + } `json:"git"` +} + +// eventMsgPayload is the payload for event_msg events. +type eventMsgPayload struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + TurnID string `json:"turn_id,omitempty"` + Info json.RawMessage `json:"info,omitempty"` +} + +// tokenCountInfo holds token usage info from a token_count event. +type tokenCountInfo struct { + TotalTokenUsage struct { + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningOutputTokens int `json:"reasoning_output_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"total_token_usage"` +} + +// responseItemPayload is the payload for response_item events. +type responseItemPayload struct { + Type string `json:"type"` + Role string `json:"role,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + CallID string `json:"call_id,omitempty"` + Output string `json:"output,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + Phase string `json:"phase,omitempty"` +} + +// execCommandArgs represents the parsed arguments of an exec_command function call. +type execCommandArgs struct { + Cmd string `json:"cmd"` + Workdir string `json:"workdir,omitempty"` +} + +// fileModifyingPatterns are shell command patterns that indicate file modifications. +// Codex uses exec_command with shell commands instead of dedicated Write/Edit tools. +var fileModifyingPatterns = []string{ + "apply_patch", + "cat >", + "cat >>", + "tee ", + "sed -i", + "> ", + ">> ", + "mv ", + "cp ", + "mkdir -p", + "touch ", + "rm ", + "echo ", +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..552811bfb 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -80,12 +80,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameCodex AgentName = "codex" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeCodex AgentType = "Codex CLI" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 8505c9be5..1f27b0988 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent/codexcli" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -190,6 +191,15 @@ func init() { } return handleGeminiNotification() }) + + // Register Codex CLI handlers + RegisterHookHandler(agent.AgentNameCodex, codexcli.HookNameTurnComplete, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleCodexTurnComplete() + }) } // agentHookLogCleanup stores the cleanup function for agent hook logging. diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..4b2014c60 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,6 +4,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/codexcli" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" diff --git a/cmd/entire/cli/hooks_codexcli_handlers.go b/cmd/entire/cli/hooks_codexcli_handlers.go new file mode 100644 index 000000000..137bb2543 --- /dev/null +++ b/cmd/entire/cli/hooks_codexcli_handlers.go @@ -0,0 +1,340 @@ +// hooks_codexcli_handlers.go contains Codex CLI specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +// +// Codex CLI only supports a single hook: agent-turn-complete (via its notify config). +// Unlike Claude Code (7 hooks) or Gemini CLI (10 hooks), Codex only fires +// a notify command after each turn completes. This handler captures state, +// extracts metadata, and commits in a single pass. +package cli + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/codexcli" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// codexSessionContext holds parsed session data for Codex commits. +type codexSessionContext struct { + sessionID string + transcriptPath string + sessionDir string + sessionDirAbs string + transcriptData []byte + allPrompts []string + summary string + modifiedFiles []string + commitMessage string +} + +// handleCodexTurnComplete handles the turn-complete hook for Codex CLI. +// This is the only hook Codex supports — it fires after each agent turn completes. +// It combines the work of before-agent (state capture) and after-agent (commit) +// into a single handler since Codex doesn't have separate hooks for these phases. +func handleCodexTurnComplete() error { + // Always use the Codex agent for Codex hooks + ag, err := agent.Get(agent.AgentNameCodex) + if err != nil { + return fmt.Errorf("failed to get codex agent: %w", err) + } + + // Parse hook input — Codex sends a JSON payload via stdin + 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, "codex-turn-complete", + slog.String("hook", "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 + } + + transcriptPath := input.SessionRef + if transcriptPath == "" || !fileExists(transcriptPath) { + // Try to resolve transcript from session directory + if transcriptPath == "" { + transcriptPath, err = resolveCodexTranscript(ag, sessionID) + if err != nil || transcriptPath == "" { + return fmt.Errorf("transcript file not found for session %s", sessionID) + } + } else { + return fmt.Errorf("transcript file not found: %s", transcriptPath) + } + } + + // Early check: bail out if the repo has no commits yet. + if repo, repoErr := strategy.OpenRepository(); repoErr == nil && strategy.IsEmptyRepository(repo) { + fmt.Fprintln(os.Stderr, "Entire: skipping checkpoint. Will activate after first commit.") + return NewSilentError(strategy.ErrEmptyRepository) + } + + // Create session context and commit + ctx := &codexSessionContext{ + sessionID: sessionID, + transcriptPath: transcriptPath, + } + + 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 + transitionSessionTurnEnd(sessionID) + + return nil +} + +// resolveCodexTranscript attempts to find the transcript file for a Codex session. +func resolveCodexTranscript(ag agent.Agent, sessionID string) (string, error) { + sessionDir, err := ag.GetSessionDir("") + if err != nil { + return "", fmt.Errorf("failed to get session dir: %w", err) + } + + transcriptPath := ag.ResolveSessionFile(sessionDir, sessionID) + if transcriptPath != "" && fileExists(transcriptPath) { + return transcriptPath, nil + } + + return "", errors.New("could not resolve transcript file") +} + +// 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, summary, and modified files from transcript. +func extractCodexMetadata(ctx *codexSessionContext) error { + // Parse the JSONL transcript + lines, err := codexcli.ParseTranscript(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to parse transcript: %v\n", err) + } + + // Extract all user prompts + allPrompts := codexcli.ExtractAllUserPrompts(lines) + ctx.allPrompts = allPrompts + + promptFile := filepath.Join(ctx.sessionDirAbs, paths.PromptFileName) + promptContent := strings.Join(allPrompts, "\n\n---\n\n") + if err := os.WriteFile(promptFile, []byte(promptContent), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted %d prompt(s) to: %s\n", len(allPrompts), ctx.sessionDir+"/"+paths.PromptFileName) + + // Extract last assistant message as summary + summary := codexcli.ExtractLastAssistantMessage(lines) + ctx.summary = summary + + summaryFile := filepath.Join(ctx.sessionDirAbs, paths.SummaryFileName) + if err := os.WriteFile(summaryFile, []byte(summary), 0o600); err != nil { + return fmt.Errorf("failed to write summary file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted summary to: %s\n", ctx.sessionDir+"/"+paths.SummaryFileName) + + // Extract modified files from exec_command tool calls + modifiedFiles := codexcli.ExtractModifiedFiles(lines) + ctx.modifiedFiles = modifiedFiles + + // Generate commit message from the last prompt + lastPrompt := "" + if len(allPrompts) > 0 { + lastPrompt = allPrompts[len(allPrompts)-1] + } + ctx.commitMessage = generateCommitMessage(lastPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", ctx.commitMessage) + + return nil +} + +// commitCodexSession commits the session changes using the strategy. +func commitCodexSession(ctx *codexSessionContext) 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) + } + + // Get transcript position from pre-prompt state + var startMessageIndex int + if preState != nil { + startMessageIndex = preState.StartMessageIndex + } + + // Calculate token usage for this turn (Codex-specific) + var tokenUsage *agent.TokenUsage + if ctx.transcriptPath != "" { + usage, tokenErr := codexcli.CalculateTokenUsageFromFile(ctx.transcriptPath, startMessageIndex) + if tokenErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to calculate token usage: %v\n", tokenErr) + } else if usage != nil && usage.APICallCount > 0 { + tokenUsage = usage + fmt.Fprintf(os.Stderr, "Token usage for this checkpoint: input=%d, output=%d, cache_read=%d, api_calls=%d\n", + tokenUsage.InputTokens, tokenUsage.OutputTokens, tokenUsage.CacheReadTokens, tokenUsage.APICallCount) + } + } + + // 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 := createContextFileForCodex(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 — we're in "entire hooks codex turn-complete", so it's Codex 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 +} + +// createContextFileForCodex creates a context.md file for Codex sessions. +func createContextFileForCodex(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { + var sb strings.Builder + + sb.WriteString("# Session Context\n\n") + sb.WriteString("Agent: Codex CLI\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 +} diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index bb04eecae..ab3086c4d 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -1108,6 +1108,19 @@ func removeAgentHooks(w io.Writer) error { } } + // Remove Codex CLI hooks + codexAgent, err := agent.Get(agent.AgentNameCodex) + if err == nil { + if hookAgent, ok := codexAgent.(agent.HookSupport); ok { + wasInstalled := hookAgent.AreHooksInstalled() + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, err) + } else if wasInstalled { + fmt.Fprintln(w, " Removed Codex CLI hooks") + } + } + } + return errors.Join(errs...) } diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..fe63dd719 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -116,7 +116,7 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) - case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: + case agent.AgentTypeClaudeCode, agent.AgentTypeCodex, agent.AgentTypeUnknown: // Claude format - fall through to shared logic below } // Claude format (JSONL) - handles Claude Code, Unknown, and any future agent types