diff --git a/internal/agent/codex/codex.go b/internal/agent/codex/codex.go new file mode 100644 index 0000000..ffa9611 --- /dev/null +++ b/internal/agent/codex/codex.go @@ -0,0 +1,14 @@ +package codex + +// Detector implements the agent.Detector interface for OpenAI Codex CLI. +type Detector struct{} + +// New creates a new Codex CLI detector. +func New() *Detector { + return &Detector{} +} + +// Name returns the agent name. +func (d *Detector) Name() string { + return "codex" +} diff --git a/internal/agent/codex/codex_test.go b/internal/agent/codex/codex_test.go new file mode 100644 index 0000000..31bea19 --- /dev/null +++ b/internal/agent/codex/codex_test.go @@ -0,0 +1,18 @@ +package codex + +import ( + "testing" + + "github.com/partio-io/cli/internal/agent" +) + +func TestDetectorImplementsInterface(t *testing.T) { + var _ agent.Detector = (*Detector)(nil) +} + +func TestName(t *testing.T) { + d := New() + if got := d.Name(); got != "codex" { + t.Errorf("Name() = %q, want %q", got, "codex") + } +} diff --git a/internal/agent/codex/find_session_dir.go b/internal/agent/codex/find_session_dir.go new file mode 100644 index 0000000..6d44e26 --- /dev/null +++ b/internal/agent/codex/find_session_dir.go @@ -0,0 +1,23 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" +) + +// FindSessionDir returns the Codex CLI session directory for the given repo. +// Codex stores sessions at ~/.codex/. +func (d *Detector) FindSessionDir(repoRoot string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home directory: %w", err) + } + + sessionDir := filepath.Join(home, ".codex") + if _, err := os.Stat(sessionDir); err != nil { + return "", fmt.Errorf("no Codex session directory found: %w", err) + } + + return sessionDir, nil +} diff --git a/internal/agent/codex/find_session_dir_test.go b/internal/agent/codex/find_session_dir_test.go new file mode 100644 index 0000000..ad57667 --- /dev/null +++ b/internal/agent/codex/find_session_dir_test.go @@ -0,0 +1,59 @@ +package codex + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindSessionDir(t *testing.T) { + tests := []struct { + name string + createDir bool + wantErr bool + }{ + { + name: "returns directory when .codex exists", + createDir: true, + wantErr: false, + }, + { + name: "returns error when .codex does not exist", + createDir: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + home := filepath.Join(tmpDir, "home") + + if tt.createDir { + codexDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + t.Fatal(err) + } + } + + t.Setenv("HOME", home) + + d := &Detector{} + got, err := d.FindSessionDir(filepath.Join(tmpDir, "repo")) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := filepath.Join(home, ".codex") + if got != want { + t.Errorf("got %s, want %s", got, want) + } + }) + } +} diff --git a/internal/agent/codex/process.go b/internal/agent/codex/process.go new file mode 100644 index 0000000..17abd7d --- /dev/null +++ b/internal/agent/codex/process.go @@ -0,0 +1,19 @@ +package codex + +import ( + "os/exec" + "strings" +) + +// IsRunning checks if a Codex CLI process is currently running. +func (d *Detector) IsRunning() (bool, error) { + out, err := exec.Command("pgrep", "-f", "codex").Output() + if err != nil { + // pgrep returns exit code 1 if no processes found + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err + } + return strings.TrimSpace(string(out)) != "", nil +} diff --git a/internal/agent/codex/process_test.go b/internal/agent/codex/process_test.go new file mode 100644 index 0000000..05295ce --- /dev/null +++ b/internal/agent/codex/process_test.go @@ -0,0 +1,44 @@ +package codex + +import ( + "strings" + "testing" +) + +func TestParsePgrepOutput(t *testing.T) { + tests := []struct { + name string + output string + want bool + }{ + { + name: "single PID", + output: "12345\n", + want: true, + }, + { + name: "multiple PIDs", + output: "12345\n67890\n", + want: true, + }, + { + name: "empty output", + output: "", + want: false, + }, + { + name: "whitespace only", + output: " \n ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := strings.TrimSpace(tt.output) != "" + if got != tt.want { + t.Errorf("parsePgrepOutput(%q) = %v, want %v", tt.output, got, tt.want) + } + }) + } +} diff --git a/internal/config/env.go b/internal/config/env.go index 090b142..56b588a 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -15,4 +15,7 @@ func applyEnv(cfg *Config) { if v := os.Getenv("PARTIO_LOG_LEVEL"); v != "" { cfg.LogLevel = v } + if v := os.Getenv("PARTIO_AGENT"); v != "" { + cfg.Agent = v + } } diff --git a/internal/hooks/postcommit.go b/internal/hooks/postcommit.go index a98a36f..72fc6f9 100644 --- a/internal/hooks/postcommit.go +++ b/internal/hooks/postcommit.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/partio-io/cli/internal/agent" "github.com/partio-io/cli/internal/agent/claude" "github.com/partio-io/cli/internal/attribution" "github.com/partio-io/cli/internal/checkpoint" @@ -66,10 +67,15 @@ func runPostCommit(repoRoot string, cfg config.Config) error { } // Parse agent session data - detector := claude.New() - sessionPath, sessionData, err := detector.FindLatestSession(repoRoot) - if err != nil { - slog.Warn("could not read agent session", "error", err) + detector := resolveDetector(cfg.Agent) + var sessionPath string + var sessionData *agent.SessionData + if cd, ok := detector.(*claude.Detector); ok { + var findErr error + sessionPath, sessionData, findErr = cd.FindLatestSession(repoRoot) + if findErr != nil { + slog.Warn("could not read agent session", "error", findErr) + } } // Skip if this session is already fully condensed and ended — re-processing diff --git a/internal/hooks/precommit.go b/internal/hooks/precommit.go index 06c2ec2..f203725 100644 --- a/internal/hooks/precommit.go +++ b/internal/hooks/precommit.go @@ -12,6 +12,12 @@ import ( "github.com/partio-io/cli/internal/session" ) +// claudeDetector is satisfied by *claude.Detector and allows +// Claude-specific session operations in hooks. +type claudeDetector interface { + FindLatestJSONLPath(repoRoot string) (string, error) +} + // preCommitState records the state captured during pre-commit for use by post-commit. type preCommitState struct { AgentActive bool `json:"agent_active"` @@ -27,7 +33,7 @@ func (r *Runner) PreCommit() error { } func runPreCommit(repoRoot string, cfg config.Config) error { - detector := claude.New() + detector := resolveDetector(cfg.Agent) // Detect if agent is running running, err := detector.IsRunning() @@ -36,11 +42,11 @@ func runPreCommit(repoRoot string, cfg config.Config) error { running = false } - if running { - // Quick check: find the latest JSONL path without a full parse and see if - // we have already captured this session in a fully-condensed ended state. - // This avoids the expensive JSONL parse for stale sessions. - latestPath, pathErr := detector.FindLatestJSONLPath(repoRoot) + var sessionPath string + + // Claude-specific: check for already-condensed sessions and find JSONL path. + if cd, ok := detector.(claudeDetector); running && ok { + latestPath, pathErr := cd.FindLatestJSONLPath(repoRoot) if pathErr == nil { sid := claude.PeekSessionID(latestPath) if shouldSkipSession(filepath.Join(repoRoot, config.PartioDir), sid, latestPath) { @@ -48,24 +54,31 @@ func runPreCommit(repoRoot string, cfg config.Config) error { running = false } } - } - var sessionPath string - if running { - path, _, err := detector.FindLatestSession(repoRoot) - if err != nil { - slog.Debug("agent running but no session found", "error", err) - } else { - sessionPath = path - slog.Debug("agent session detected", "path", path) + if running { + if cdFull, ok := detector.(*claude.Detector); ok { + path, _, err := cdFull.FindLatestSession(repoRoot) + if err != nil { + slog.Debug("agent running but no session found", "error", err) + } else { + sessionPath = path + slog.Debug("agent session detected", "path", path) + } + } } } branch, _ := git.CurrentBranch() commitHash, _ := git.CurrentCommit() + // Claude requires a session path to be active; other agents are active when running. + agentActive := running + if _, ok := detector.(*claude.Detector); ok { + agentActive = running && sessionPath != "" + } + state := preCommitState{ - AgentActive: running && sessionPath != "", + AgentActive: agentActive, SessionPath: sessionPath, PreCommitHash: commitHash, Branch: branch, diff --git a/internal/hooks/resolve.go b/internal/hooks/resolve.go new file mode 100644 index 0000000..a612f69 --- /dev/null +++ b/internal/hooks/resolve.go @@ -0,0 +1,17 @@ +package hooks + +import ( + "github.com/partio-io/cli/internal/agent" + "github.com/partio-io/cli/internal/agent/claude" + "github.com/partio-io/cli/internal/agent/codex" +) + +// resolveDetector returns the appropriate agent detector for the given name. +func resolveDetector(name string) agent.Detector { + switch name { + case "codex": + return codex.New() + default: + return claude.New() + } +}