diff --git a/README.md b/README.md index 564a747..84a976d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,17 @@ See the [documentation](https://minicodemonkey.github.io/chief/concepts/how-it-w ## Requirements -- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated +- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**, **[Codex CLI](https://developers.openai.com/codex/cli/reference)**, or **[OpenCode CLI](https://opencode.ai)** installed and authenticated + +Use Claude by default, or configure Codex or OpenCode in `.chief/config.yaml`: + +```yaml +agent: + provider: opencode + cliPath: /usr/local/bin/opencode # optional +``` + +Or run with `chief --agent opencode` or set `CHIEF_AGENT=opencode`. ## License diff --git a/cmd/chief/main.go b/cmd/chief/main.go index b0796bc..ee1fcd9 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -8,9 +8,11 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/minicodemonkey/chief/internal/agent" "github.com/minicodemonkey/chief/internal/cmd" "github.com/minicodemonkey/chief/internal/config" "github.com/minicodemonkey/chief/internal/git" + "github.com/minicodemonkey/chief/internal/loop" "github.com/minicodemonkey/chief/internal/prd" "github.com/minicodemonkey/chief/internal/tui" ) @@ -26,6 +28,8 @@ type TUIOptions struct { Merge bool Force bool NoRetry bool + Agent string // --agent claude|codex + AgentPath string // --agent-path } func main() { @@ -147,6 +151,26 @@ func parseTUIFlags() *TUIOptions { opts.Force = true case arg == "--no-retry": opts.NoRetry = true + case arg == "--agent": + if i+1 < len(os.Args) { + i++ + opts.Agent = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent="): + opts.Agent = strings.TrimPrefix(arg, "--agent=") + case arg == "--agent-path": + if i+1 < len(os.Args) { + i++ + opts.AgentPath = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent-path="): + opts.AgentPath = strings.TrimPrefix(arg, "--agent-path=") case arg == "--max-iterations" || arg == "-n": // Next argument should be the number if i+1 < len(os.Args) { @@ -210,15 +234,47 @@ func parseTUIFlags() *TUIOptions { func runNew() { opts := cmd.NewOptions{} + flagAgent, flagPath := "", "" + var positional []string - // Parse arguments: chief new [name] [context...] - if len(os.Args) > 2 { - opts.Name = os.Args[2] + // Parse arguments: chief new [name] [context...] [--agent X] [--agent-path X] + for i := 2; i < len(os.Args); i++ { + arg := os.Args[i] + switch { + case arg == "--agent": + if i+1 < len(os.Args) { + i++ + flagAgent = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent="): + flagAgent = strings.TrimPrefix(arg, "--agent=") + case arg == "--agent-path": + if i+1 < len(os.Args) { + i++ + flagPath = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent-path="): + flagPath = strings.TrimPrefix(arg, "--agent-path=") + case strings.HasPrefix(arg, "-"): + // skip unknown flags + default: + positional = append(positional, arg) + } } - if len(os.Args) > 3 { - opts.Context = strings.Join(os.Args[3:], " ") + if len(positional) > 0 { + opts.Name = positional[0] + } + if len(positional) > 1 { + opts.Context = strings.Join(positional[1:], " ") } + opts.Provider = resolveProvider(flagAgent, flagPath) if err := cmd.RunNew(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -227,23 +283,44 @@ func runNew() { func runEdit() { opts := cmd.EditOptions{} + flagAgent, flagPath := "", "" - // Parse arguments: chief edit [name] [--merge] [--force] + // Parse arguments: chief edit [name] [--merge] [--force] [--agent X] [--agent-path X] for i := 2; i < len(os.Args); i++ { arg := os.Args[i] - switch arg { - case "--merge": + switch { + case arg == "--merge": opts.Merge = true - case "--force": + case arg == "--force": opts.Force = true + case arg == "--agent": + if i+1 < len(os.Args) { + i++ + flagAgent = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent="): + flagAgent = strings.TrimPrefix(arg, "--agent=") + case arg == "--agent-path": + if i+1 < len(os.Args) { + i++ + flagPath = os.Args[i] + } else { + fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n") + os.Exit(1) + } + case strings.HasPrefix(arg, "--agent-path="): + flagPath = strings.TrimPrefix(arg, "--agent-path=") default: - // If not a flag, treat as PRD name (first non-flag arg) if opts.Name == "" && !strings.HasPrefix(arg, "-") { opts.Name = arg } } } + opts.Provider = resolveProvider(flagAgent, flagPath) if err := cmd.RunEdit(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -282,7 +359,33 @@ func runList() { } } +// resolveProvider loads config and resolves the agent provider, exiting on error. +func resolveProvider(flagAgent, flagPath string) loop.Provider { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + cfg, err := config.Load(cwd) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to load .chief/config.yaml: %v\n", err) + os.Exit(1) + } + provider, err := agent.Resolve(flagAgent, flagPath, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if err := agent.CheckInstalled(provider); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return provider +} + func runTUIWithOptions(opts *TUIOptions) { + provider := resolveProvider(opts.Agent, opts.AgentPath) + prdPath := opts.PRDPath // If no PRD specified, try to find one @@ -322,7 +425,8 @@ func runTUIWithOptions(opts *TUIOptions) { // Create the PRD newOpts := cmd.NewOptions{ - Name: result.PRDName, + Name: result.PRDName, + Provider: provider, } if err := cmd.RunNew(newOpts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -344,19 +448,19 @@ func runTUIWithOptions(opts *TUIOptions) { fmt.Printf("Warning: failed to check conversion status: %v\n", err) } else if needsConvert { fmt.Println("prd.md is newer than prd.json, running conversion...") - convertOpts := prd.ConvertOptions{ - PRDDir: prdDir, - Merge: opts.Merge, - Force: opts.Force, - } - if err := prd.Convert(convertOpts); err != nil { + if err := cmd.RunConvertWithOptions(cmd.ConvertOptions{ + PRDDir: prdDir, + Merge: opts.Merge, + Force: opts.Force, + Provider: provider, + }); err != nil { fmt.Printf("Error converting PRD: %v\n", err) os.Exit(1) } fmt.Println("Conversion complete.") } - app, err := tui.NewAppWithOptions(prdPath, opts.MaxIterations) + app, err := tui.NewAppWithOptions(prdPath, opts.MaxIterations, provider) if err != nil { // Check if this is a missing PRD file error if os.IsNotExist(err) || strings.Contains(err.Error(), "no such file") { @@ -403,7 +507,8 @@ func runTUIWithOptions(opts *TUIOptions) { case tui.PostExitInit: // Run new command then restart TUI newOpts := cmd.NewOptions{ - Name: finalApp.PostExitPRD, + Name: finalApp.PostExitPRD, + Provider: provider, } if err := cmd.RunNew(newOpts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -416,9 +521,10 @@ func runTUIWithOptions(opts *TUIOptions) { case tui.PostExitEdit: // Run edit command then restart TUI editOpts := cmd.EditOptions{ - Name: finalApp.PostExitPRD, - Merge: opts.Merge, - Force: opts.Force, + Name: finalApp.PostExitPRD, + Merge: opts.Merge, + Force: opts.Force, + Provider: provider, } if err := cmd.RunEdit(editOpts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -447,9 +553,11 @@ Commands: help Show this help message Global Options: + --agent Agent CLI to use: claude (default), codex, or opencode + --agent-path Custom path to agent CLI binary --max-iterations N, -n N Set maximum iterations (default: dynamic) - --no-retry Disable auto-retry on Claude crashes - --verbose Show raw Claude output in log + --no-retry Disable auto-retry on agent crashes + --verbose Show raw agent output in log --merge Auto-merge progress on conversion conflicts --force Auto-overwrite on conversion conflicts --help, -h Show this help message @@ -470,7 +578,8 @@ Examples: chief -n 20 Launch with 20 max iterations chief --max-iterations=5 auth Launch auth PRD with 5 max iterations - chief --verbose Launch with raw Claude output visible + chief --verbose Launch with raw agent output visible + chief --agent codex Use Codex CLI instead of Claude chief new Create PRD in .chief/prds/main/ chief new auth Create PRD in .chief/prds/auth/ chief new auth "JWT authentication for REST API" diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 956d3ef..c2bce13 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -8,7 +8,9 @@ Chief is distributed as a single binary with no runtime dependencies. Choose you ## Prerequisites -Before installing Chief, ensure you have **Claude Code CLI** installed and authenticated: +Chief needs an agent CLI: **Claude Code** (default), **Codex**, or **OpenCode**. Install at least one and authenticate. + +### Option A: Claude Code CLI (default) ::: code-group @@ -27,8 +29,32 @@ npx @anthropic-ai/claude-code login ::: -::: tip Verify Claude Code Installation -Run `claude --version` to confirm Claude Code is installed. Chief will not work without it. +::: tip Verify Claude Code +Run `claude --version` to confirm Claude Code is installed. +::: + +### Option B: Codex CLI + +To use [OpenAI Codex CLI](https://developers.openai.com/codex/cli/reference) instead of Claude: + +1. Install Codex per the [official reference](https://developers.openai.com/codex/cli/reference). +2. Ensure `codex` is on your PATH, or set `agent.cliPath` in `.chief/config.yaml` (see [Configuration](/reference/configuration#agent)). +3. Run Chief with `chief --agent codex` or set `CHIEF_AGENT=codex`, or set `agent.provider: codex` in `.chief/config.yaml`. + +::: tip Verify Codex +Run `codex --version` (or your custom path) to confirm Codex is available. +::: + +### Option C: OpenCode CLI + +To use [OpenCode CLI](https://opencode.ai) as an alternative: + +1. Install OpenCode per the [official docs](https://opencode.ai/docs/). +2. Ensure `opencode` is on your PATH, or set `agent.cliPath` in `.chief/config.yaml` (see [Configuration](/reference/configuration#agent)). +3. Run Chief with `chief --agent opencode` or set `CHIEF_AGENT=opencode`, or set `agent.provider: opencode` in `.chief/config.yaml`. + +::: tip Verify OpenCode +Run `opencode --version` (or your custom path) to confirm OpenCode is available. ::: ### Optional: GitHub CLI (`gh`) @@ -238,11 +264,12 @@ chief --version # View help chief --help -# Check that Claude Code is accessible +# Check that your agent CLI is accessible (Claude default, or codex if configured) claude --version +# or: codex --version ``` -Expected output: +Expected output (example with Claude): ``` $ chief --version diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 6a1b8ef..36d0786 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -13,6 +13,9 @@ Chief stores project-level settings in `.chief/config.yaml`. This file is create ### Format ```yaml +agent: + provider: claude # or "codex" or "opencode" + cliPath: "" # optional path to CLI binary worktree: setup: "npm install" onComplete: @@ -24,6 +27,8 @@ onComplete: | Key | Type | Default | Description | |-----|------|---------|-------------| +| `agent.provider` | string | `"claude"` | Agent CLI to use: `claude`, `codex`, or `opencode` | +| `agent.cliPath` | string | `""` | Optional path to the agent binary (e.g. `/usr/local/bin/opencode`). If empty, Chief uses the provider name from PATH. | | `worktree.setup` | string | `""` | Shell command to run in new worktrees (e.g., `npm install`, `go mod download`) | | `onComplete.push` | bool | `false` | Automatically push the branch to remote when a PRD completes | | `onComplete.createPR` | bool | `false` | Automatically create a pull request when a PRD completes (requires `gh` CLI) | @@ -83,17 +88,29 @@ These settings are saved to `.chief/config.yaml` and can be changed at any time | Flag | Description | Default | |------|-------------|---------| +| `--agent ` | Agent CLI to use: `claude`, `codex`, or `opencode` | From config / env / `claude` | +| `--agent-path ` | Custom path to the agent CLI binary | From config / env | | `--max-iterations `, `-n` | Loop iteration limit | Dynamic | -| `--no-retry` | Disable auto-retry on Claude crashes | `false` | -| `--verbose` | Show raw Claude output in log | `false` | +| `--no-retry` | Disable auto-retry on agent crashes | `false` | +| `--verbose` | Show raw agent output in log | `false` | | `--merge` | Auto-merge progress on conversion conflicts | `false` | | `--force` | Auto-overwrite on conversion conflicts | `false` | +Agent resolution order: `--agent` / `--agent-path` → `CHIEF_AGENT` / `CHIEF_AGENT_PATH` env vars → `agent.provider` / `agent.cliPath` in `.chief/config.yaml` → default `claude`. + When `--max-iterations` is not specified, Chief calculates a dynamic limit based on the number of remaining stories plus a buffer. You can also adjust the limit at runtime with `+`/`-` in the TUI. +## Agent + +Chief can use **Claude Code** (default), **Codex CLI**, or **OpenCode CLI** as the agent. Choose via: + +- **Config:** `agent.provider: opencode` and optionally `agent.cliPath: /path/to/opencode` in `.chief/config.yaml` +- **Environment:** `CHIEF_AGENT=opencode`, `CHIEF_AGENT_PATH=/path/to/opencode` +- **CLI:** `chief --agent opencode --agent-path /path/to/opencode` + ## Claude Code Configuration -Chief invokes Claude Code under the hood. Claude Code has its own configuration: +When using Claude, Chief invokes Claude Code under the hood. Claude Code has its own configuration: ```bash # Authentication diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 768df3a..79b56e2 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -6,23 +6,44 @@ description: Troubleshoot common Chief issues including Claude not found, permis Solutions to frequently encountered problems. -## Claude Not Found +## Agent CLI Not Found -**Symptom:** Error message about Claude Code CLI not being installed. +**Symptom:** Error that the agent CLI (Claude, Codex, or OpenCode) is not found. ``` -Error: Claude Code CLI not found. Please install it first. +Error: Claude CLI not found in PATH. Install it or set agent.cliPath in .chief/config.yaml +``` +or +``` +Error: Codex CLI not found in PATH. Install it or set agent.cliPath in .chief/config.yaml +``` +or +``` +Error: OpenCode CLI not found in PATH. Install it or set agent.cliPath in .chief/config.yaml ``` -**Cause:** Claude Code isn't installed or isn't in your PATH. +**Cause:** The chosen agent CLI isn't installed or isn't in your PATH. **Solution:** -Install Claude Code following the [official instructions](https://docs.anthropic.com/en/docs/claude-code/getting-started), then verify: - -```bash -claude --version -``` +- **Claude (default):** Install [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started), then verify: + ```bash + claude --version + ``` +- **Codex:** Install [Codex CLI](https://developers.openai.com/codex/cli/reference) and ensure `codex` is in PATH, or set the path in config: + ```yaml + agent: + provider: codex + cliPath: /usr/local/bin/codex + ``` + Verify with `codex --version` (or your `cliPath`). +- **OpenCode:** Install [OpenCode CLI](https://opencode.ai/docs/) and ensure `opencode` is in PATH, or set the path in config: + ```yaml + agent: + provider: opencode + cliPath: /usr/local/bin/opencode + ``` + Verify with `opencode --version` (or your `cliPath`). ## Permission Denied @@ -42,7 +63,7 @@ Chief automatically runs Claude with permission prompts disabled for autonomous **Solution:** -1. Check `claude.log` for errors: +1. Check the agent log for errors (e.g. `claude.log`, `codex.log`, or `opencode.log` in the PRD directory): ```bash tail -100 .chief/prds/your-prd/claude.log ``` @@ -66,7 +87,7 @@ Chief automatically runs Claude with permission prompts disabled for autonomous **Solution:** -1. Check `claude.log` for what Claude is doing: +1. Check the agent log (e.g. `claude.log`, `codex.log`, or `opencode.log`) for what the agent is doing: ```bash tail -f .chief/prds/your-prd/claude.log ``` @@ -97,7 +118,7 @@ Chief automatically runs Claude with permission prompts disabled for autonomous 2. Or investigate why it's taking so many iterations: - Story too complex? Split it - - Stuck in a loop? Check `claude.log` + - Stuck in a loop? Check the agent log (`claude.log`, `codex.log`, or `opencode.log`) - Unclear acceptance criteria? Clarify them ## "No PRD Found" @@ -239,4 +260,4 @@ If none of these solutions help: 3. Open a new issue with: - Chief version (`chief --version`) - Your `prd.json` (sanitized) - - Relevant `claude.log` excerpts + - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, or `opencode.log`) diff --git a/internal/agent/claude.go b/internal/agent/claude.go new file mode 100644 index 0000000..bd25cb9 --- /dev/null +++ b/internal/agent/claude.go @@ -0,0 +1,73 @@ +package agent + +import ( + "context" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +// ClaudeProvider implements loop.Provider for the Claude Code CLI. +type ClaudeProvider struct { + cliPath string +} + +// NewClaudeProvider returns a Provider for the Claude CLI. +// If cliPath is empty, "claude" is used. +func NewClaudeProvider(cliPath string) *ClaudeProvider { + if cliPath == "" { + cliPath = "claude" + } + return &ClaudeProvider{cliPath: cliPath} +} + +// Name implements loop.Provider. +func (p *ClaudeProvider) Name() string { return "Claude" } + +// CLIPath implements loop.Provider. +func (p *ClaudeProvider) CLIPath() string { return p.cliPath } + +// LoopCommand implements loop.Provider. +func (p *ClaudeProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, + "--dangerously-skip-permissions", + "-p", prompt, + "--output-format", "stream-json", + "--verbose", + ) + cmd.Dir = workDir + return cmd +} + +// InteractiveCommand implements loop.Provider. +func (p *ClaudeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, prompt) + cmd.Dir = workDir + return cmd +} + +// ConvertCommand implements loop.Provider. +func (p *ClaudeProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "-p", "--tools", "") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "", nil +} + +// FixJSONCommand implements loop.Provider. +func (p *ClaudeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "-p", prompt) + return cmd, loop.OutputStdout, "", nil +} + +// ParseLine implements loop.Provider. +func (p *ClaudeProvider) ParseLine(line string) *loop.Event { + return loop.ParseLine(line) +} + +// LogFileName implements loop.Provider. +func (p *ClaudeProvider) LogFileName() string { return "claude.log" } + +// CleanOutput implements loop.Provider - Claude doesn't use a special format. +func (p *ClaudeProvider) CleanOutput(output string) string { return output } diff --git a/internal/agent/codex.go b/internal/agent/codex.go new file mode 100644 index 0000000..cbd7482 --- /dev/null +++ b/internal/agent/codex.go @@ -0,0 +1,84 @@ +package agent + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +// CodexProvider implements loop.Provider for the Codex CLI. +type CodexProvider struct { + cliPath string +} + +// NewCodexProvider returns a Provider for the Codex CLI. +// If cliPath is empty, "codex" is used. +func NewCodexProvider(cliPath string) *CodexProvider { + if cliPath == "" { + cliPath = "codex" + } + return &CodexProvider{cliPath: cliPath} +} + +// Name implements loop.Provider. +func (p *CodexProvider) Name() string { return "Codex" } + +// CLIPath implements loop.Provider. +func (p *CodexProvider) CLIPath() string { return p.cliPath } + +// LoopCommand implements loop.Provider. +func (p *CodexProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, "exec", "--json", "--yolo", "-C", workDir, "-") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd +} + +// InteractiveCommand implements loop.Provider. +func (p *CodexProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, prompt) + cmd.Dir = workDir + return cmd +} + +// ConvertCommand implements loop.Provider. +func (p *CodexProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + f, err := os.CreateTemp("", "chief-codex-convert-*.txt") + if err != nil { + return nil, 0, "", fmt.Errorf("failed to create temp file for conversion output: %w", err) + } + outPath := f.Name() + f.Close() + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "-o", outPath, "-") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, outPath, nil +} + +// FixJSONCommand implements loop.Provider. +func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + f, err := os.CreateTemp("", "chief-codex-fixjson-*.txt") + if err != nil { + return nil, 0, "", fmt.Errorf("failed to create temp file for fix output: %w", err) + } + outPath := f.Name() + f.Close() + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "-o", outPath, "-") + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, outPath, nil +} + +// ParseLine implements loop.Provider. +func (p *CodexProvider) ParseLine(line string) *loop.Event { + return loop.ParseLineCodex(line) +} + +// LogFileName implements loop.Provider. +func (p *CodexProvider) LogFileName() string { return "codex.log" } + +// CleanOutput implements loop.Provider - Codex doesn't use a special format. +func (p *CodexProvider) CleanOutput(output string) string { return output } diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go new file mode 100644 index 0000000..3a9d2c0 --- /dev/null +++ b/internal/agent/codex_test.go @@ -0,0 +1,138 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestCodexProvider_Name(t *testing.T) { + p := NewCodexProvider("") + if p.Name() != "Codex" { + t.Errorf("Name() = %q, want Codex", p.Name()) + } +} + +func TestCodexProvider_CLIPath(t *testing.T) { + p := NewCodexProvider("") + if p.CLIPath() != "codex" { + t.Errorf("CLIPath() empty arg = %q, want codex", p.CLIPath()) + } + p2 := NewCodexProvider("/usr/local/bin/codex") + if p2.CLIPath() != "/usr/local/bin/codex" { + t.Errorf("CLIPath() custom = %q, want /usr/local/bin/codex", p2.CLIPath()) + } +} + +func TestCodexProvider_LogFileName(t *testing.T) { + p := NewCodexProvider("") + if p.LogFileName() != "codex.log" { + t.Errorf("LogFileName() = %q, want codex.log", p.LogFileName()) + } +} + +func TestCodexProvider_LoopCommand(t *testing.T) { + ctx := context.Background() + p := NewCodexProvider("/bin/codex") + cmd := p.LoopCommand(ctx, "hello world", "/work/dir") + + if cmd.Path != "/bin/codex" { + t.Errorf("LoopCommand Path = %q, want /bin/codex", cmd.Path) + } + wantArgs := []string{"/bin/codex", "exec", "--json", "--yolo", "-C", "/work/dir", "-"} + if len(cmd.Args) != len(wantArgs) { + t.Fatalf("LoopCommand Args len = %d, want %d: %v", len(cmd.Args), len(wantArgs), cmd.Args) + } + for i, w := range wantArgs { + if cmd.Args[i] != w { + t.Errorf("LoopCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) + } + } + if cmd.Dir != "/work/dir" { + t.Errorf("LoopCommand Dir = %q, want /work/dir", cmd.Dir) + } + if cmd.Stdin == nil { + t.Error("LoopCommand Stdin must be set (prompt on stdin)") + } + // Stdin should contain the prompt + // We can't easily read cmd.Stdin without running; just check it's non-nil (done above) +} + +func TestCodexProvider_ConvertCommand(t *testing.T) { + p := NewCodexProvider("codex") + cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") + if err != nil { + t.Fatalf("ConvertCommand unexpected error: %v", err) + } + if mode != loop.OutputFromFile { + t.Errorf("ConvertCommand mode = %v, want OutputFromFile", mode) + } + if outPath == "" { + t.Error("ConvertCommand outPath should be non-empty temp file") + } + if !strings.Contains(cmd.Path, "codex") { + t.Errorf("ConvertCommand Path = %q", cmd.Path) + } + foundO := false + for i, a := range cmd.Args { + if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { + foundO = true + break + } + } + if !foundO { + t.Errorf("ConvertCommand should have -o %q in args: %v", outPath, cmd.Args) + } + if cmd.Dir != "/prd/dir" { + t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) + } +} + +func TestCodexProvider_FixJSONCommand(t *testing.T) { + p := NewCodexProvider("codex") + cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") + if err != nil { + t.Fatalf("FixJSONCommand unexpected error: %v", err) + } + if mode != loop.OutputFromFile { + t.Errorf("FixJSONCommand mode = %v, want OutputFromFile", mode) + } + if outPath == "" { + t.Error("FixJSONCommand outPath should be non-empty temp file") + } + foundO := false + for i, a := range cmd.Args { + if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { + foundO = true + break + } + } + if !foundO { + t.Errorf("FixJSONCommand should have -o %q in args: %v", outPath, cmd.Args) + } +} + +func TestCodexProvider_InteractiveCommand(t *testing.T) { + p := NewCodexProvider("codex") + cmd := p.InteractiveCommand("/work", "my prompt") + if cmd.Dir != "/work" { + t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir) + } + if len(cmd.Args) < 2 || cmd.Args[0] != "codex" || cmd.Args[1] != "my prompt" { + t.Errorf("InteractiveCommand Args = %v", cmd.Args) + } +} + +func TestCodexProvider_ParseLine(t *testing.T) { + p := NewCodexProvider("") + // thread.started -> EventIterationStart + e := p.ParseLine(`{"type":"thread.started"}`) + if e == nil { + t.Fatal("ParseLine(thread.started) returned nil") + } + if e.Type != loop.EventIterationStart { + t.Errorf("ParseLine(thread.started) Type = %v, want EventIterationStart", e.Type) + } +} diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go new file mode 100644 index 0000000..77f1795 --- /dev/null +++ b/internal/agent/opencode.go @@ -0,0 +1,85 @@ +package agent + +import ( + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/loop" +) + +type OpenCodeProvider struct { + cliPath string +} + +func NewOpenCodeProvider(cliPath string) *OpenCodeProvider { + if cliPath == "" { + cliPath = "opencode" + } + return &OpenCodeProvider{cliPath: cliPath} +} + +func (p *OpenCodeProvider) Name() string { return "OpenCode" } + +func (p *OpenCodeProvider) CLIPath() string { return p.cliPath } + +func (p *OpenCodeProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd { + cmd := exec.CommandContext(ctx, p.cliPath, "run", "--format", "json", prompt) + cmd.Dir = workDir + return cmd +} + +func (p *OpenCodeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { + cmd := exec.Command(p.cliPath, "--prompt", prompt) + cmd.Dir = workDir + return cmd +} + +func (p *OpenCodeProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "run", "--format", "json", "--", prompt) + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "", nil +} + +func (p *OpenCodeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { + cmd := exec.Command(p.cliPath, "run", "--format", "json", "--", prompt) + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "", nil +} + +func (p *OpenCodeProvider) ParseLine(line string) *loop.Event { + return loop.ParseLineOpenCode(line) +} + +func (p *OpenCodeProvider) LogFileName() string { return "opencode.log" } + +// CleanOutput extracts JSON from opencode's NDJSON output format. +func (p *OpenCodeProvider) CleanOutput(output string) string { + output = strings.TrimSpace(output) + + if !strings.Contains(output, "\n") || !strings.Contains(output, `"type":"step_start"`) || !strings.Contains(output, `"type":"text"`) { + return output + } + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.Contains(line, `"type":"text"`) { + var ev struct { + Type string `json:"type"` + Part struct { + Text string `json:"text"` + } `json:"part"` + } + if json.Unmarshal([]byte(line), &ev) == nil && ev.Part.Text != "" { + return ev.Part.Text + } + } + } + return output +} diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go new file mode 100644 index 0000000..75db98a --- /dev/null +++ b/internal/agent/opencode_test.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "testing" + + "github.com/minicodemonkey/chief/internal/loop" +) + +func TestOpenCodeProvider_Name(t *testing.T) { + p := NewOpenCodeProvider("") + if p.Name() != "OpenCode" { + t.Errorf("Name() = %q, want OpenCode", p.Name()) + } +} + +func TestOpenCodeProvider_CLIPath(t *testing.T) { + p := NewOpenCodeProvider("") + if p.CLIPath() != "opencode" { + t.Errorf("CLIPath() empty arg = %q, want opencode", p.CLIPath()) + } + p2 := NewOpenCodeProvider("/usr/local/bin/opencode") + if p2.CLIPath() != "/usr/local/bin/opencode" { + t.Errorf("CLIPath() custom = %q, want /usr/local/bin/opencode", p2.CLIPath()) + } +} + +func TestOpenCodeProvider_LogFileName(t *testing.T) { + p := NewOpenCodeProvider("") + if p.LogFileName() != "opencode.log" { + t.Errorf("LogFileName() = %q, want opencode.log", p.LogFileName()) + } +} + +func TestOpenCodeProvider_LoopCommand(t *testing.T) { + ctx := context.Background() + p := NewOpenCodeProvider("/bin/opencode") + cmd := p.LoopCommand(ctx, "hello world", "/work/dir") + + if cmd.Path != "/bin/opencode" { + t.Errorf("LoopCommand Path = %q, want /bin/opencode", cmd.Path) + } + wantArgs := []string{"/bin/opencode", "run", "--format", "json", "hello world"} + if len(cmd.Args) != len(wantArgs) { + t.Fatalf("LoopCommand Args len = %d, want %d: %v", len(cmd.Args), len(wantArgs), cmd.Args) + } + for i, w := range wantArgs { + if cmd.Args[i] != w { + t.Errorf("LoopCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) + } + } + if cmd.Dir != "/work/dir" { + t.Errorf("LoopCommand Dir = %q, want /work/dir", cmd.Dir) + } +} + +func TestOpenCodeProvider_ConvertCommand(t *testing.T) { + p := NewOpenCodeProvider("opencode") + cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") + if err != nil { + t.Fatalf("ConvertCommand unexpected error: %v", err) + } + if mode != loop.OutputStdout { + t.Errorf("ConvertCommand mode = %v, want OutputStdout", mode) + } + if outPath != "" { + t.Errorf("ConvertCommand outPath = %q, want empty string", outPath) + } + if cmd.Path != "opencode" { + t.Errorf("ConvertCommand Path = %q, want opencode", cmd.Path) + } + if cmd.Dir != "/prd/dir" { + t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) + } +} + +func TestOpenCodeProvider_FixJSONCommand(t *testing.T) { + p := NewOpenCodeProvider("opencode") + cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") + if err != nil { + t.Fatalf("FixJSONCommand unexpected error: %v", err) + } + if mode != loop.OutputStdout { + t.Errorf("FixJSONCommand mode = %v, want OutputStdout", mode) + } + if outPath != "" { + t.Errorf("FixJSONCommand outPath = %q, want empty string", outPath) + } + if cmd.Path != "opencode" { + t.Errorf("FixJSONCommand Path = %q, want opencode", cmd.Path) + } +} + +func TestOpenCodeProvider_InteractiveCommand(t *testing.T) { + p := NewOpenCodeProvider("opencode") + cmd := p.InteractiveCommand("/work", "my prompt") + if cmd.Dir != "/work" { + t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir) + } + if len(cmd.Args) != 3 || cmd.Args[0] != "opencode" || cmd.Args[1] != "--prompt" || cmd.Args[2] != "my prompt" { + t.Errorf("InteractiveCommand Args = %v, want [opencode --prompt 'my prompt']", cmd.Args) + } +} + +func TestOpenCodeProvider_ParseLine(t *testing.T) { + p := NewOpenCodeProvider("") + e := p.ParseLine(`{"type":"step_start","timestamp":1234567890,"sessionID":"ses_test123"}`) + if e == nil { + t.Fatal("ParseLine(step_start) returned nil") + } + if e.Type != loop.EventIterationStart { + t.Errorf("ParseLine(step_start) Type = %v, want EventIterationStart", e.Type) + } +} diff --git a/internal/agent/resolve.go b/internal/agent/resolve.go new file mode 100644 index 0000000..c2649c1 --- /dev/null +++ b/internal/agent/resolve.go @@ -0,0 +1,54 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/minicodemonkey/chief/internal/config" + "github.com/minicodemonkey/chief/internal/loop" +) + +// Resolve returns the agent Provider using priority: flagAgent > CHIEF_AGENT env > config > "claude". +// flagPath overrides the CLI path when non-empty (flag > CHIEF_AGENT_PATH > config agent.cliPath). +// Returns an error if the resolved provider name is not recognised. +func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, error) { + providerName := "claude" + if flagAgent != "" { + providerName = strings.ToLower(strings.TrimSpace(flagAgent)) + } else if v := os.Getenv("CHIEF_AGENT"); v != "" { + providerName = strings.ToLower(strings.TrimSpace(v)) + } else if cfg != nil && cfg.Agent.Provider != "" { + providerName = strings.ToLower(strings.TrimSpace(cfg.Agent.Provider)) + } + + cliPath := "" + if flagPath != "" { + cliPath = flagPath + } else if v := os.Getenv("CHIEF_AGENT_PATH"); v != "" { + cliPath = strings.TrimSpace(v) + } else if cfg != nil && cfg.Agent.CLIPath != "" { + cliPath = strings.TrimSpace(cfg.Agent.CLIPath) + } + + switch providerName { + case "claude": + return NewClaudeProvider(cliPath), nil + case "codex": + return NewCodexProvider(cliPath), nil + case "opencode": + return NewOpenCodeProvider(cliPath), nil + default: + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", or \"opencode\"", providerName) + } +} + +// CheckInstalled verifies that the provider's CLI binary is found in PATH (or at cliPath). +func CheckInstalled(p loop.Provider) error { + _, err := exec.LookPath(p.CLIPath()) + if err != nil { + return fmt.Errorf("%s CLI not found in PATH. Install it or set agent.cliPath in .chief/config.yaml", p.Name()) + } + return nil +} diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go new file mode 100644 index 0000000..fa199f8 --- /dev/null +++ b/internal/agent/resolve_test.go @@ -0,0 +1,196 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/config" + "github.com/minicodemonkey/chief/internal/loop" +) + +func mustResolve(t *testing.T, flagAgent, flagPath string, cfg *config.Config) loop.Provider { + t.Helper() + p, err := Resolve(flagAgent, flagPath, cfg) + if err != nil { + t.Fatalf("Resolve(%q, %q, cfg) unexpected error: %v", flagAgent, flagPath, err) + } + return p +} + +func TestResolve_priority(t *testing.T) { + // Default: no flag, no env, nil config -> Claude + got := mustResolve(t, "", "", nil) + if got.Name() != "Claude" { + t.Errorf("Resolve(_, _, nil) name = %q, want Claude", got.Name()) + } + if got.CLIPath() != "claude" { + t.Errorf("Resolve(_, _, nil) CLIPath = %q, want claude", got.CLIPath()) + } + + // Flag overrides everything + got = mustResolve(t, "codex", "", nil) + if got.Name() != "Codex" { + t.Errorf("Resolve(codex, _, nil) name = %q, want Codex", got.Name()) + } + + // Config only (no flag, no env) + cfg := &config.Config{} + cfg.Agent.Provider = "codex" + cfg.Agent.CLIPath = "/usr/local/bin/codex" + got = mustResolve(t, "", "", cfg) + if got.Name() != "Codex" { + t.Errorf("Resolve(_, _, config codex) name = %q, want Codex", got.Name()) + } + if got.CLIPath() != "/usr/local/bin/codex" { + t.Errorf("Resolve(_, _, config) CLIPath = %q, want /usr/local/bin/codex", got.CLIPath()) + } + + // Flag overrides config + got = mustResolve(t, "claude", "", cfg) + if got.Name() != "Claude" { + t.Errorf("Resolve(claude, _, config codex) name = %q, want Claude", got.Name()) + } + // flag path overrides config path + got = mustResolve(t, "codex", "/opt/codex", cfg) + if got.CLIPath() != "/opt/codex" { + t.Errorf("Resolve(codex, /opt/codex, cfg) CLIPath = %q, want /opt/codex", got.CLIPath()) + } +} + +func TestResolve_env(t *testing.T) { + const keyAgent = "CHIEF_AGENT" + const keyPath = "CHIEF_AGENT_PATH" + saveAgent := os.Getenv(keyAgent) + savePath := os.Getenv(keyPath) + defer func() { + if saveAgent != "" { + os.Setenv(keyAgent, saveAgent) + } else { + os.Unsetenv(keyAgent) + } + if savePath != "" { + os.Setenv(keyPath, savePath) + } else { + os.Unsetenv(keyPath) + } + }() + + os.Unsetenv(keyAgent) + os.Unsetenv(keyPath) + + // Env provider when no flag + os.Setenv(keyAgent, "codex") + got := mustResolve(t, "", "", nil) + if got.Name() != "Codex" { + t.Errorf("with CHIEF_AGENT=codex, name = %q, want Codex", got.Name()) + } + os.Unsetenv(keyAgent) + + // Env path when no flag path + os.Setenv(keyAgent, "codex") + os.Setenv(keyPath, "/env/codex") + got = mustResolve(t, "", "", nil) + if got.CLIPath() != "/env/codex" { + t.Errorf("with CHIEF_AGENT_PATH, CLIPath = %q, want /env/codex", got.CLIPath()) + } + os.Unsetenv(keyPath) + os.Unsetenv(keyAgent) +} + +func TestResolve_normalize(t *testing.T) { + got := mustResolve(t, " CODEX ", "", nil) + if got.Name() != "Codex" { + t.Errorf("Resolve(' CODEX ') name = %q, want Codex", got.Name()) + } +} + +func TestResolve_opencode(t *testing.T) { + // Test OpenCode provider resolution + got := mustResolve(t, "opencode", "", nil) + if got.Name() != "OpenCode" { + t.Errorf("Resolve(opencode) name = %q, want OpenCode", got.Name()) + } + if got.CLIPath() != "opencode" { + t.Errorf("Resolve(opencode) CLIPath = %q, want opencode", got.CLIPath()) + } + + // Test OpenCode with custom path + got = mustResolve(t, "opencode", "/usr/local/bin/opencode", nil) + if got.CLIPath() != "/usr/local/bin/opencode" { + t.Errorf("Resolve(opencode, /usr/local/bin/opencode) CLIPath = %q, want /usr/local/bin/opencode", got.CLIPath()) + } + + // Test from config + cfg := &config.Config{} + cfg.Agent.Provider = "opencode" + cfg.Agent.CLIPath = "/opt/opencode" + got = mustResolve(t, "", "", cfg) + if got.Name() != "OpenCode" { + t.Errorf("Resolve(_, _, config opencode) name = %q, want OpenCode", got.Name()) + } + if got.CLIPath() != "/opt/opencode" { + t.Errorf("Resolve(_, _, config opencode) CLIPath = %q, want /opt/opencode", got.CLIPath()) + } +} + +func TestResolve_unknownProvider(t *testing.T) { + _, err := Resolve("typo", "", nil) + if err == nil { + t.Fatal("Resolve(typo) expected error, got nil") + } + if !strings.Contains(err.Error(), "typo") { + t.Errorf("error should mention the bad provider name: %v", err) + } +} + +func TestCheckInstalled_notFound(t *testing.T) { + // Use a path that does not exist + p := NewCodexProvider("/nonexistent/codex-binary-that-does-not-exist") + err := CheckInstalled(p) + if err == nil { + t.Error("CheckInstalled(nonexistent) expected error, got nil") + } + if err != nil && !strings.Contains(err.Error(), "Codex") { + t.Errorf("CheckInstalled error should mention Codex: %v", err) + } +} + +func TestCheckInstalled_found(t *testing.T) { + // Go test binary is in PATH + goPath, err := exec.LookPath("go") + if err != nil { + t.Skip("go not in PATH, skipping CheckInstalled found test") + } + p := NewClaudeProvider(goPath) // abuse: use "go" as cli path to get a binary that exists + err = CheckInstalled(p) + if err != nil { + t.Errorf("CheckInstalled(existing binary) err = %v", err) + } +} + +func TestResolve_configFile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".chief", "config.yaml") + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + const yamlContent = ` +agent: + provider: codex + cliPath: /usr/local/bin/codex +` + if err := os.WriteFile(cfgPath, []byte(yamlContent), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(dir) + if err != nil { + t.Fatal(err) + } + got := mustResolve(t, "", "", cfg) + if got.Name() != "Codex" || got.CLIPath() != "/usr/local/bin/codex" { + t.Errorf("Resolve from config: name=%q path=%q", got.Name(), got.CLIPath()) + } +} diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go new file mode 100644 index 0000000..e25fcc0 --- /dev/null +++ b/internal/cmd/convert.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/minicodemonkey/chief/embed" + "github.com/minicodemonkey/chief/internal/loop" + "github.com/minicodemonkey/chief/internal/prd" +) + +// runConversionWithProvider runs the agent to convert prd.md to JSON. +func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string, error) { + prompt := embed.GetConvertPrompt(filepath.Join(absPRDDir, "prd.md"), "US") + cmd, mode, outPath, err := provider.ConvertCommand(absPRDDir, prompt) + if err != nil { + return "", fmt.Errorf("failed to prepare conversion command: %w", err) + } + + var stdout, stderr bytes.Buffer + if mode == loop.OutputStdout { + cmd.Stdout = &stdout + } else { + cmd.Stdout = &bytes.Buffer{} + } + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + if outPath != "" { + _ = os.Remove(outPath) + } + return "", fmt.Errorf("failed to start %s: %w", provider.Name(), err) + } + if err := prd.WaitWithPanel(cmd, "Converting PRD", "Analyzing PRD...", &stderr); err != nil { + if outPath != "" { + os.Remove(outPath) + } + return "", err + } + if mode == loop.OutputFromFile && outPath != "" { + defer os.Remove(outPath) + data, err := os.ReadFile(outPath) + if err != nil { + return "", fmt.Errorf("failed to read conversion output: %w", err) + } + return string(data), nil + } + return stdout.String(), nil +} + +// runFixJSONWithProvider runs the agent to fix invalid JSON. +func runFixJSONWithProvider(provider loop.Provider, prompt string) (string, error) { + cmd, mode, outPath, err := provider.FixJSONCommand(prompt) + if err != nil { + return "", fmt.Errorf("failed to prepare fix command: %w", err) + } + + var stdout, stderr bytes.Buffer + if mode == loop.OutputStdout { + cmd.Stdout = &stdout + } else { + cmd.Stdout = &bytes.Buffer{} + } + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + if outPath != "" { + _ = os.Remove(outPath) + } + return "", fmt.Errorf("failed to start %s: %w", provider.Name(), err) + } + if err := prd.WaitWithSpinner(cmd, "Fixing JSON", "Fixing prd.json...", &stderr); err != nil { + if outPath != "" { + os.Remove(outPath) + } + return "", err + } + if mode == loop.OutputFromFile && outPath != "" { + defer os.Remove(outPath) + data, err := os.ReadFile(outPath) + if err != nil { + return "", fmt.Errorf("failed to read fix output: %w", err) + } + return string(data), nil + } + return stdout.String(), nil +} diff --git a/internal/cmd/edit.go b/internal/cmd/edit.go index 87c2c96..83a8134 100644 --- a/internal/cmd/edit.go +++ b/internal/cmd/edit.go @@ -6,14 +6,16 @@ import ( "path/filepath" "github.com/minicodemonkey/chief/embed" + "github.com/minicodemonkey/chief/internal/loop" ) // EditOptions contains configuration for the edit command. type EditOptions struct { - Name string // PRD name (default: "main") - BaseDir string // Base directory for .chief/prds/ (default: current directory) - Merge bool // Auto-merge without prompting on conversion conflicts - Force bool // Auto-overwrite without prompting on conversion conflicts + Name string // PRD name (default: "main") + BaseDir string // Base directory for .chief/prds/ (default: current directory) + Merge bool // Auto-merge without prompting on conversion conflicts + Force bool // Auto-overwrite without prompting on conversion conflicts + Provider loop.Provider // Agent CLI provider (Claude or Codex) } // RunEdit edits an existing PRD by launching an interactive Claude session. @@ -46,23 +48,27 @@ func RunEdit(opts EditOptions) error { // Get the edit prompt with the PRD directory path prompt := embed.GetEditPrompt(prdDir) + if opts.Provider == nil { + return fmt.Errorf("edit command requires Provider to be set") + } - // Launch interactive Claude session + // Launch interactive agent session fmt.Printf("Editing PRD at %s...\n", prdDir) - fmt.Println("Launching Claude to help you edit your PRD...") + fmt.Printf("Launching %s to help you edit your PRD...\n", opts.Provider.Name()) fmt.Println() - if err := runInteractiveClaude(opts.BaseDir, prompt); err != nil { - return fmt.Errorf("Claude session failed: %w", err) + if err := runInteractiveAgent(opts.Provider, opts.BaseDir, prompt); err != nil { + return fmt.Errorf("%s session failed: %w", opts.Provider.Name(), err) } fmt.Println("\nPRD editing complete!") // Run conversion from prd.md to prd.json with progress protection convertOpts := ConvertOptions{ - PRDDir: prdDir, - Merge: opts.Merge, - Force: opts.Force, + PRDDir: prdDir, + Merge: opts.Merge, + Force: opts.Force, + Provider: opts.Provider, } if err := RunConvertWithOptions(convertOpts); err != nil { return fmt.Errorf("conversion failed: %w", err) diff --git a/internal/cmd/edit_test.go b/internal/cmd/edit_test.go index 21d12be..c07f694 100644 --- a/internal/cmd/edit_test.go +++ b/internal/cmd/edit_test.go @@ -120,6 +120,31 @@ func TestEditOptionsDefaults(t *testing.T) { } } +func TestRunEditRequiresProvider(t *testing.T) { + tmpDir := t.TempDir() + prdDir := filepath.Join(tmpDir, ".chief", "prds", "main") + if err := os.MkdirAll(prdDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + prdMdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdMdPath, []byte("# Main PRD"), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) + } + + opts := EditOptions{ + Name: "main", + BaseDir: tmpDir, + } + + err := RunEdit(opts) + if err == nil { + t.Fatal("expected provider validation error") + } + if !contains(err.Error(), "Provider") { + t.Fatalf("expected error to mention Provider, got: %v", err) + } +} + // Helper function to check if a string contains a substring func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) diff --git a/internal/cmd/new.go b/internal/cmd/new.go index c8f4f88..bd01446 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -6,21 +6,22 @@ package cmd import ( "fmt" "os" - "os/exec" "path/filepath" "github.com/minicodemonkey/chief/embed" + "github.com/minicodemonkey/chief/internal/loop" "github.com/minicodemonkey/chief/internal/prd" ) // NewOptions contains configuration for the new command. type NewOptions struct { - Name string // PRD name (default: "main") - Context string // Optional context to pass to Claude - BaseDir string // Base directory for .chief/prds/ (default: current directory) + Name string // PRD name (default: "main") + Context string // Optional context to pass to the agent + BaseDir string // Base directory for .chief/prds/ (default: current directory) + Provider loop.Provider // Agent CLI provider (Claude or Codex) } -// RunNew creates a new PRD by launching an interactive Claude session. +// RunNew creates a new PRD by launching an interactive agent session. func RunNew(opts NewOptions) error { // Set defaults if opts.Name == "" { @@ -53,14 +54,17 @@ func RunNew(opts NewOptions) error { // Get the init prompt with the PRD directory path prompt := embed.GetInitPrompt(prdDir, opts.Context) + if opts.Provider == nil { + return fmt.Errorf("new command requires Provider to be set") + } - // Launch interactive Claude session + // Launch interactive agent session fmt.Printf("Creating PRD in %s...\n", prdDir) - fmt.Println("Launching Claude to help you create your PRD...") + fmt.Printf("Launching %s to help you create your PRD...\n", opts.Provider.Name()) fmt.Println() - if err := runInteractiveClaude(opts.BaseDir, prompt); err != nil { - return fmt.Errorf("Claude session failed: %w", err) + if err := runInteractiveAgent(opts.Provider, opts.BaseDir, prompt); err != nil { + return fmt.Errorf("%s session failed: %w", opts.Provider.Name(), err) } // Check if prd.md was created @@ -74,7 +78,7 @@ func RunNew(opts NewOptions) error { fmt.Println("\nPRD created successfully!") // Run conversion from prd.md to prd.json - if err := RunConvert(prdDir); err != nil { + if err := RunConvertWithOptions(ConvertOptions{PRDDir: prdDir, Provider: opts.Provider}); err != nil { return fmt.Errorf("conversion failed: %w", err) } @@ -82,37 +86,55 @@ func RunNew(opts NewOptions) error { return nil } -// runInteractiveClaude launches an interactive Claude session in the specified directory. -func runInteractiveClaude(workDir, prompt string) error { - // Pass prompt as argument (not -p which is print mode / non-interactive) - cmd := exec.Command("claude", prompt) - cmd.Dir = workDir +// runInteractiveAgent launches an interactive agent session in the specified directory. +func runInteractiveAgent(provider loop.Provider, workDir, prompt string) error { + if provider == nil { + return fmt.Errorf("interactive agent requires Provider to be set") + } + cmd := provider.InteractiveCommand(workDir, prompt) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() } // ConvertOptions contains configuration for the conversion command. type ConvertOptions struct { - PRDDir string // PRD directory containing prd.md - Merge bool // Auto-merge without prompting on conversion conflicts - Force bool // Auto-overwrite without prompting on conversion conflicts + PRDDir string // PRD directory containing prd.md + Merge bool // Auto-merge without prompting on conversion conflicts + Force bool // Auto-overwrite without prompting on conversion conflicts + Provider loop.Provider // Agent CLI provider for conversion } -// RunConvert converts prd.md to prd.json using Claude. -func RunConvert(prdDir string) error { - return RunConvertWithOptions(ConvertOptions{PRDDir: prdDir}) +// RunConvert converts prd.md to prd.json using the given agent provider. +func RunConvert(prdDir string, provider loop.Provider) error { + return RunConvertWithOptions(ConvertOptions{PRDDir: prdDir, Provider: provider}) } -// RunConvertWithOptions converts prd.md to prd.json using Claude with options. -// The Merge and Force flags will be fully implemented in US-019. +// RunConvertWithOptions converts prd.md to prd.json using the configured agent with options. func RunConvertWithOptions(opts ConvertOptions) error { + if opts.Provider == nil { + return fmt.Errorf("conversion requires Provider to be set") + } + provider := opts.Provider return prd.Convert(prd.ConvertOptions{ PRDDir: opts.PRDDir, Merge: opts.Merge, Force: opts.Force, + RunConversion: func(absPRDDir, idPrefix string) (string, error) { + raw, err := runConversionWithProvider(provider, absPRDDir) + if err != nil { + return "", err + } + return provider.CleanOutput(raw), nil + }, + RunFixJSON: func(prompt string) (string, error) { + raw, err := runFixJSONWithProvider(provider, prompt) + if err != nil { + return "", err + } + return provider.CleanOutput(raw), nil + }, }) } diff --git a/internal/cmd/new_test.go b/internal/cmd/new_test.go index 6ee04be..02f672b 100644 --- a/internal/cmd/new_test.go +++ b/internal/cmd/new_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" ) @@ -151,3 +152,18 @@ func TestRunNewRejectsExistingPRD(t *testing.T) { t.Error("Expected error for existing PRD") } } + +func TestRunNewRequiresProvider(t *testing.T) { + opts := NewOptions{ + Name: "main", + BaseDir: t.TempDir(), + } + + err := RunNew(opts) + if err == nil { + t.Fatal("expected provider validation error") + } + if !strings.Contains(err.Error(), "Provider") { + t.Fatalf("expected error to mention Provider, got: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6f43ec0..6bad1f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,13 @@ const configFile = ".chief/config.yaml" type Config struct { Worktree WorktreeConfig `yaml:"worktree"` OnComplete OnCompleteConfig `yaml:"onComplete"` + Agent AgentConfig `yaml:"agent"` +} + +// AgentConfig holds agent CLI settings (Claude, Codex, or OpenCode). +type AgentConfig struct { + Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" + CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary } // WorktreeConfig holds worktree-related settings. diff --git a/internal/loop/codex_parser.go b/internal/loop/codex_parser.go new file mode 100644 index 0000000..3dd5f39 --- /dev/null +++ b/internal/loop/codex_parser.go @@ -0,0 +1,132 @@ +package loop + +import ( + "encoding/json" + "errors" + "strings" +) + +// codexEvent represents the top-level structure of a Codex exec --json JSONL line. +type codexEvent struct { + Type string `json:"type"` + Item *codexItem `json:"item,omitempty"` + Message string `json:"message,omitempty"` // top-level for type "error" + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// codexItem represents an item in item.started / item.completed / item.updated events. +type codexItem struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Command string `json:"command,omitempty"` + AggregatedOutput string `json:"aggregated_output,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Status string `json:"status,omitempty"` + Server string `json:"server,omitempty"` + Tool string `json:"tool,omitempty"` +} + +// ParseLineCodex parses a single line of Codex exec --json JSONL output and returns an Event. +// If the line cannot be parsed or is not relevant, it returns nil. +func ParseLineCodex(line string) *Event { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + + var ev codexEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + return nil + } + + switch ev.Type { + case "thread.started", "turn.started": + return &Event{Type: EventIterationStart} + + case "turn.failed": + msg := "" + if ev.Error != nil { + msg = ev.Error.Message + } + return &Event{Type: EventError, Err: errors.New(msg)} + + case "error": + msg := ev.Message + if msg == "" && ev.Error != nil { + msg = ev.Error.Message + } + if msg == "" { + msg = "unknown error" + } + return &Event{Type: EventError, Err: errors.New(msg)} + + case "item.started": + if ev.Item == nil { + return nil + } + switch ev.Item.Type { + case "command_execution": + return &Event{ + Type: EventToolStart, + Tool: ev.Item.Command, + } + case "mcp_tool_call": + toolName := ev.Item.Tool + if ev.Item.Server != "" { + toolName = ev.Item.Server + "/" + ev.Item.Tool + } + return &Event{ + Type: EventToolStart, + Tool: toolName, + } + } + return nil + + case "item.completed": + if ev.Item == nil { + return nil + } + switch ev.Item.Type { + case "command_execution": + return &Event{ + Type: EventToolResult, + Text: ev.Item.AggregatedOutput, + } + case "mcp_tool_call": + return &Event{ + Type: EventToolResult, + Text: ev.Item.AggregatedOutput, + } + case "agent_message": + text := ev.Item.Text + if strings.Contains(text, "") { + return &Event{Type: EventComplete, Text: text} + } + if storyID := extractStoryID(text, "", ""); storyID != "" { + return &Event{ + Type: EventStoryStarted, + Text: text, + StoryID: storyID, + } + } + return &Event{Type: EventAssistantText, Text: text} + case "file_change": + return &Event{ + Type: EventToolResult, + Tool: "file_change", + Text: ev.Item.AggregatedOutput, + } + } + return nil + + case "turn.completed": + // Usage info only, no event + return nil + + default: + return nil + } +} diff --git a/internal/loop/codex_parser_test.go b/internal/loop/codex_parser_test.go new file mode 100644 index 0000000..81df2a0 --- /dev/null +++ b/internal/loop/codex_parser_test.go @@ -0,0 +1,157 @@ +package loop + +import ( + "testing" +) + +func TestParseLineCodex_threadStarted(t *testing.T) { + line := `{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventIterationStart { + t.Errorf("expected EventIterationStart, got %v", ev.Type) + } +} + +func TestParseLineCodex_turnStarted(t *testing.T) { + line := `{"type":"turn.started"}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventIterationStart { + t.Errorf("expected EventIterationStart, got %v", ev.Type) + } +} + +func TestParseLineCodex_commandExecutionStarted(t *testing.T) { + line := `{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "bash -lc ls" { + t.Errorf("expected Tool bash -lc ls, got %q", ev.Tool) + } +} + +func TestParseLineCodex_commandExecutionCompleted(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"docs\nsrc\n","exit_code":0,"status":"completed"}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolResult { + t.Errorf("expected EventToolResult, got %v", ev.Type) + } + if ev.Text != "docs\nsrc\n" { + t.Errorf("expected Text docs\\nsrc\\n, got %q", ev.Text) + } +} + +func TestParseLineCodex_agentMessageWithComplete(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. "}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventComplete { + t.Errorf("expected EventComplete, got %v", ev.Type) + } + if ev.Text != "Done. " { + t.Errorf("unexpected Text: %q", ev.Text) + } +} + +func TestParseLineCodex_agentMessageWithRalphStatus(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Working on US-056 now."}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventStoryStarted { + t.Errorf("expected EventStoryStarted, got %v", ev.Type) + } + if ev.StoryID != "US-056" { + t.Errorf("expected StoryID US-056, got %q", ev.StoryID) + } +} + +func TestParseLineCodex_agentMessagePlain(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. I updated the docs."}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventAssistantText { + t.Errorf("expected EventAssistantText, got %v", ev.Type) + } + if ev.Text != "Done. I updated the docs." { + t.Errorf("unexpected Text: %q", ev.Text) + } +} + +func TestParseLineCodex_turnFailed(t *testing.T) { + line := `{"type":"turn.failed","error":{"message":"model response stream ended unexpectedly"}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventError { + t.Errorf("expected EventError, got %v", ev.Type) + } + if ev.Err == nil { + t.Fatal("expected Err set") + } + if ev.Err.Error() != "model response stream ended unexpectedly" { + t.Errorf("unexpected Err: %v", ev.Err) + } +} + +func TestParseLineCodex_error(t *testing.T) { + line := `{"type":"error","message":"stream error: broken pipe"}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventError { + t.Errorf("expected EventError, got %v", ev.Type) + } +} + +func TestParseLineCodex_turnCompleted_ignored(t *testing.T) { + line := `{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}` + ev := ParseLineCodex(line) + if ev != nil { + t.Errorf("expected nil (ignore turn.completed), got %v", ev) + } +} + +func TestParseLineCodex_mcpToolCallStarted(t *testing.T) { + line := `{"type":"item.started","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":null,"error":null,"status":"in_progress"}}` + ev := ParseLineCodex(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "docs/search" { + t.Errorf("expected Tool docs/search, got %q", ev.Tool) + } +} + +func TestParseLineCodex_emptyOrInvalid_returnsNil(t *testing.T) { + tests := []string{"", " ", "not json", "{}", `{"type":"unknown"}`} + for _, line := range tests { + ev := ParseLineCodex(line) + if ev != nil { + t.Errorf("ParseLineCodex(%q) expected nil, got %v", line, ev) + } + } +} diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 8d20e50..0d3dd74 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -22,9 +22,9 @@ import ( // RetryConfig configures automatic retry behavior on Claude crashes. type RetryConfig struct { - MaxRetries int // Maximum number of retry attempts (default: 3) + MaxRetries int // Maximum number of retry attempts (default: 3) RetryDelays []time.Duration // Delays between retries (default: 0s, 5s, 15s) - Enabled bool // Whether retry is enabled (default: true) + Enabled bool // Whether retry is enabled (default: true) } // DefaultWatchdogTimeout is the default duration of silence before the watchdog kills a hung process. @@ -39,31 +39,33 @@ func DefaultRetryConfig() RetryConfig { } } -// Loop manages the core agent loop that invokes Claude repeatedly until all stories are complete. +// Loop manages the core agent loop that invokes the configured agent repeatedly until all stories are complete. type Loop struct { - prdPath string - workDir string - prompt string - buildPrompt func() (string, error) // optional: rebuild prompt each iteration - maxIter int - iteration int - events chan Event - claudeCmd *exec.Cmd - logFile *os.File - mu sync.Mutex - stopped bool - paused bool - retryConfig RetryConfig - lastOutputTime time.Time - watchdogTimeout time.Duration + prdPath string + workDir string + prompt string + buildPrompt func() (string, error) // optional: rebuild prompt each iteration + maxIter int + iteration int + events chan Event + provider Provider + agentCmd *exec.Cmd + logFile *os.File + mu sync.Mutex + stopped bool + paused bool + retryConfig RetryConfig + lastOutputTime time.Time + watchdogTimeout time.Duration } // NewLoop creates a new Loop instance. -func NewLoop(prdPath, prompt string, maxIter int) *Loop { +func NewLoop(prdPath, prompt string, maxIter int, provider Provider) *Loop { return &Loop{ prdPath: prdPath, prompt: prompt, maxIter: maxIter, + provider: provider, events: make(chan Event, 100), retryConfig: DefaultRetryConfig(), watchdogTimeout: DefaultWatchdogTimeout, @@ -72,12 +74,13 @@ func NewLoop(prdPath, prompt string, maxIter int) *Loop { // NewLoopWithWorkDir creates a new Loop instance with a configurable working directory. // When workDir is empty, defaults to the project root for backward compatibility. -func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int) *Loop { +func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int, provider Provider) *Loop { return &Loop{ prdPath: prdPath, workDir: workDir, prompt: prompt, maxIter: maxIter, + provider: provider, events: make(chan Event, 100), retryConfig: DefaultRetryConfig(), watchdogTimeout: DefaultWatchdogTimeout, @@ -86,8 +89,8 @@ func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int) *Lo // NewLoopWithEmbeddedPrompt creates a new Loop instance using the embedded agent prompt. // The prompt is rebuilt on each iteration to inline the current story context. -func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int) *Loop { - l := NewLoop(prdPath, "", maxIter) +func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int, provider Provider) *Loop { + l := NewLoop(prdPath, "", maxIter, provider) l.buildPrompt = promptBuilderForPRD(prdPath) return l } @@ -127,9 +130,13 @@ func (l *Loop) Iteration() int { // Run executes the agent loop until completion or max iterations. func (l *Loop) Run(ctx context.Context) error { + if l.provider == nil { + return fmt.Errorf("loop provider is not configured") + } + // Open log file in PRD directory prdDir := filepath.Dir(l.prdPath) - logPath := filepath.Join(prdDir, "claude.log") + logPath := filepath.Join(prdDir, l.provider.LogFileName()) var err error l.logFile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { @@ -256,7 +263,7 @@ func (l *Loop) runIterationWithRetry(ctx context.Context) error { Iteration: iter, RetryCount: attempt, RetryMax: config.MaxRetries, - Text: fmt.Sprintf("Claude crashed, retrying (%d/%d)...", attempt, config.MaxRetries), + Text: fmt.Sprintf("%s crashed, retrying (%d/%d)...", l.provider.Name(), attempt, config.MaxRetries), } // Wait before retry @@ -302,37 +309,31 @@ func (l *Loop) runIterationWithRetry(ctx context.Context) error { return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, lastErr) } -// runIteration spawns Claude and processes its output. +// runIteration spawns the agent and processes its output. func (l *Loop) runIteration(ctx context.Context) error { - // Build Claude command with required flags + workDir := l.effectiveWorkDir() + cmd := l.provider.LoopCommand(ctx, l.prompt, workDir) l.mu.Lock() - l.claudeCmd = exec.CommandContext(ctx, "claude", - "--dangerously-skip-permissions", - "-p", l.prompt, - "--output-format", "stream-json", - "--verbose", - ) - // Set working directory: use workDir if configured, otherwise default to PRD directory - l.claudeCmd.Dir = l.effectiveWorkDir() + l.agentCmd = cmd // Initialize watchdog state l.lastOutputTime = time.Now() watchdogTimeout := l.watchdogTimeout l.mu.Unlock() // Create pipes for stdout and stderr - stdout, err := l.claudeCmd.StdoutPipe() + stdout, err := l.agentCmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %w", err) } - stderr, err := l.claudeCmd.StderrPipe() + stderr, err := l.agentCmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) } // Start the command - if err := l.claudeCmd.Start(); err != nil { - return fmt.Errorf("failed to start Claude: %w", err) + if err := l.agentCmd.Start(); err != nil { + return fmt.Errorf("failed to start %s: %w", l.provider.Name(), err) } // Start watchdog goroutine to detect hung processes @@ -364,7 +365,7 @@ func (l *Loop) runIteration(ctx context.Context) error { close(watchdogDone) // Wait for the command to finish - if err := l.claudeCmd.Wait(); err != nil { + if err := l.agentCmd.Wait(); err != nil { // If the context was cancelled, don't treat it as an error if ctx.Err() != nil { return ctx.Err() @@ -380,11 +381,11 @@ func (l *Loop) runIteration(ctx context.Context) error { if watchdogFired.Load() { return fmt.Errorf("watchdog timeout: no output for %s", watchdogTimeout) } - return fmt.Errorf("Claude exited with error: %w", err) + return fmt.Errorf("%s exited with error: %w", l.provider.Name(), err) } l.mu.Lock() - l.claudeCmd = nil + l.agentCmd = nil l.mu.Unlock() return nil @@ -431,8 +432,8 @@ func (l *Loop) runWatchdog(timeout time.Duration, done <-chan struct{}, fired *a // Kill the process l.mu.Lock() - if l.claudeCmd != nil && l.claudeCmd.Process != nil { - l.claudeCmd.Process.Kill() + if l.agentCmd != nil && l.agentCmd.Process != nil { + l.agentCmd.Process.Kill() } l.mu.Unlock() return @@ -462,7 +463,7 @@ func (l *Loop) processOutput(r io.Reader) { l.logLine(line) // Parse the line and emit event if valid - if event := ParseLine(line); event != nil { + if event := l.provider.ParseLine(line); event != nil { l.mu.Lock() event.Iteration = l.iteration l.mu.Unlock() @@ -486,16 +487,15 @@ func (l *Loop) logLine(line string) { } } -// Stop terminates the current Claude process and stops the loop. +// Stop terminates the current agent process and stops the loop. func (l *Loop) Stop() { l.mu.Lock() defer l.mu.Unlock() l.stopped = true - if l.claudeCmd != nil && l.claudeCmd.Process != nil { - // Kill the process - l.claudeCmd.Process.Kill() + if l.agentCmd != nil && l.agentCmd.Process != nil { + l.agentCmd.Process.Kill() } } @@ -527,7 +527,7 @@ func (l *Loop) IsStopped() bool { return l.stopped } -// effectiveWorkDir returns the working directory to use for Claude. +// effectiveWorkDir returns the working directory to use for the agent. // If workDir is set, it is used directly. Otherwise, defaults to the PRD directory. func (l *Loop) effectiveWorkDir() string { if l.workDir != "" { @@ -536,11 +536,11 @@ func (l *Loop) effectiveWorkDir() string { return filepath.Dir(l.prdPath) } -// IsRunning returns whether a Claude process is currently running. +// IsRunning returns whether an agent process is currently running. func (l *Loop) IsRunning() bool { l.mu.Lock() defer l.mu.Unlock() - return l.claudeCmd != nil && l.claudeCmd.Process != nil + return l.agentCmd != nil && l.agentCmd.Process != nil } // SetMaxIterations updates the maximum iterations limit. diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 65de29e..782d50d 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" "sync/atomic" @@ -14,6 +15,44 @@ import ( "github.com/minicodemonkey/chief/internal/prd" ) +// mockProvider implements Provider for tests without importing agent (avoids import cycle). +type mockProvider struct { + cliPath string // if set, used as CLI path; otherwise "claude" +} + +func (m *mockProvider) Name() string { return "Test" } +func (m *mockProvider) CLIPath() string { return m.path() } +func (m *mockProvider) InteractiveCommand(_, _ string) *exec.Cmd { return exec.Command("true") } +func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) } +func (m *mockProvider) LogFileName() string { return "claude.log" } + +func (m *mockProvider) ConvertCommand(_, _ string) (*exec.Cmd, OutputMode, string, error) { + return exec.Command("true"), OutputStdout, "", nil +} + +func (m *mockProvider) FixJSONCommand(_ string) (*exec.Cmd, OutputMode, string, error) { + return exec.Command("true"), OutputStdout, "", nil +} + +func (m *mockProvider) path() string { + if m.cliPath != "" { + return m.cliPath + } + return "claude" +} + +func (m *mockProvider) LoopCommand(ctx context.Context, _, workDir string) *exec.Cmd { + p := m.path() + cmd := exec.CommandContext(ctx, p) + cmd.Dir = workDir + return cmd +} + +func (m *mockProvider) CleanOutput(output string) string { return output } + +// testProvider is used by loop tests so they don't need to run a real CLI. +var testProvider Provider = &mockProvider{} + // createMockClaudeScript creates a shell script that outputs predefined stream-json. func createMockClaudeScript(t *testing.T, dir string, output []string) string { t.Helper() @@ -59,7 +98,7 @@ func createTestPRD(t *testing.T, dir string, allComplete bool) string { } func TestNewLoop(t *testing.T) { - l := NewLoop("/path/to/prd.json", "test prompt", 5) + l := NewLoop("/path/to/prd.json", "test prompt", 5, testProvider) if l.prdPath != "/path/to/prd.json" { t.Errorf("Expected prdPath %q, got %q", "/path/to/prd.json", l.prdPath) @@ -76,7 +115,7 @@ func TestNewLoop(t *testing.T) { } func TestNewLoopWithWorkDir(t *testing.T) { - l := NewLoopWithWorkDir("/path/to/prd.json", "/work/dir", "test prompt", 5) + l := NewLoopWithWorkDir("/path/to/prd.json", "/work/dir", "test prompt", 5, testProvider) if l.prdPath != "/path/to/prd.json" { t.Errorf("Expected prdPath %q, got %q", "/path/to/prd.json", l.prdPath) @@ -96,7 +135,7 @@ func TestNewLoopWithWorkDir(t *testing.T) { } func TestNewLoopWithWorkDir_EmptyWorkDir(t *testing.T) { - l := NewLoopWithWorkDir("/path/to/prd.json", "", "test prompt", 5) + l := NewLoopWithWorkDir("/path/to/prd.json", "", "test prompt", 5, testProvider) if l.workDir != "" { t.Errorf("Expected empty workDir, got %q", l.workDir) @@ -104,7 +143,7 @@ func TestNewLoopWithWorkDir_EmptyWorkDir(t *testing.T) { } func TestLoop_Events(t *testing.T) { - l := NewLoop("/path/to/prd.json", "test prompt", 5) + l := NewLoop("/path/to/prd.json", "test prompt", 5, testProvider) events := l.Events() if events == nil { @@ -113,7 +152,7 @@ func TestLoop_Events(t *testing.T) { } func TestLoop_Iteration(t *testing.T) { - l := NewLoop("/path/to/prd.json", "test prompt", 5) + l := NewLoop("/path/to/prd.json", "test prompt", 5, testProvider) if l.Iteration() != 0 { t.Errorf("Expected initial iteration to be 0, got %d", l.Iteration()) @@ -126,7 +165,7 @@ func TestLoop_Iteration(t *testing.T) { } func TestLoop_Stop(t *testing.T) { - l := NewLoop("/path/to/prd.json", "test prompt", 5) + l := NewLoop("/path/to/prd.json", "test prompt", 5, testProvider) l.Stop() @@ -162,7 +201,7 @@ func TestLoop_RunWithMockClaude(t *testing.T) { // Create a prompt that invokes our mock script instead of real Claude // For the actual test, we'll test the internal methods - l := NewLoop(prdPath, "test prompt", 1) + l := NewLoop(prdPath, "test prompt", 1, testProvider) // Override the command for testing - we'll test processOutput directly ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -244,7 +283,7 @@ func TestLoop_MaxIterations(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRD(t, tmpDir, false) // Not complete - l := NewLoop(prdPath, "test prompt", 2) + l := NewLoop(prdPath, "test prompt", 2, testProvider) // Simulate reaching max iterations by manually incrementing l.iteration = 2 @@ -290,7 +329,7 @@ func TestLoop_LogFile(t *testing.T) { t.Fatalf("Failed to create log file: %v", err) } - l := NewLoop(filepath.Join(tmpDir, "prd.json"), "test", 1) + l := NewLoop(filepath.Join(tmpDir, "prd.json"), "test", 1, testProvider) l.logFile = logFile l.logLine("test log line") @@ -309,7 +348,7 @@ func TestLoop_LogFile(t *testing.T) { // TestLoop_ChiefCompleteEvent tests detection of event. func TestLoop_ChiefCompleteEvent(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.iteration = 1 done := make(chan bool) @@ -350,7 +389,7 @@ func TestLoop_ChiefCompleteEvent(t *testing.T) { // TestLoop_SetMaxIterations tests setting max iterations at runtime. func TestLoop_SetMaxIterations(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) if l.MaxIterations() != 5 { t.Errorf("Expected initial maxIter 5, got %d", l.MaxIterations()) @@ -380,7 +419,7 @@ func TestDefaultRetryConfig(t *testing.T) { // TestLoop_SetRetryConfig tests setting retry config. func TestLoop_SetRetryConfig(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) // Check default if !l.retryConfig.Enabled { @@ -408,7 +447,7 @@ func TestLoop_SetRetryConfig(t *testing.T) { // TestLoop_WatchdogDefaultTimeout tests that the default watchdog timeout is set. func TestLoop_WatchdogDefaultTimeout(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) if l.WatchdogTimeout() != DefaultWatchdogTimeout { t.Errorf("Expected default watchdog timeout %v, got %v", DefaultWatchdogTimeout, l.WatchdogTimeout()) @@ -417,7 +456,7 @@ func TestLoop_WatchdogDefaultTimeout(t *testing.T) { // TestLoop_SetWatchdogTimeout tests setting the watchdog timeout. func TestLoop_SetWatchdogTimeout(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.SetWatchdogTimeout(10 * time.Minute) if l.WatchdogTimeout() != 10*time.Minute { @@ -433,7 +472,7 @@ func TestLoop_SetWatchdogTimeout(t *testing.T) { // TestLoop_WatchdogKillsHungProcess tests that a hung process is killed after timeout. func TestLoop_WatchdogKillsHungProcess(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.iteration = 1 // Use a very short timeout for testing @@ -496,7 +535,7 @@ func TestLoop_WatchdogKillsHungProcess(t *testing.T) { // TestLoop_WatchdogDoesNotFireForActiveProcess tests that an active process doesn't trigger the watchdog. func TestLoop_WatchdogDoesNotFireForActiveProcess(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.iteration = 1 // Use a timeout that's longer than our test @@ -551,7 +590,7 @@ func TestLoop_WatchdogDoesNotFireForActiveProcess(t *testing.T) { // TestLoop_WatchdogDisabledWithZeroTimeout tests that watchdog is disabled when timeout is 0. func TestLoop_WatchdogDisabledWithZeroTimeout(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.SetWatchdogTimeout(0) if l.WatchdogTimeout() != 0 { @@ -561,7 +600,7 @@ func TestLoop_WatchdogDisabledWithZeroTimeout(t *testing.T) { // Verify that runIteration would not start a watchdog // (tested indirectly: timeout == 0 means the if-block in runIteration is skipped) // We test this by verifying the constructor behavior and setter - l2 := NewLoop("/test/prd.json", "test", 5) + l2 := NewLoop("/test/prd.json", "test", 5, testProvider) l2.SetWatchdogTimeout(0) l2.mu.Lock() @@ -575,7 +614,7 @@ func TestLoop_WatchdogDisabledWithZeroTimeout(t *testing.T) { // TestLoop_LastOutputTimeUpdated tests that lastOutputTime is updated on each scanner output. func TestLoop_LastOutputTimeUpdated(t *testing.T) { - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.iteration = 1 // Drain events to avoid blocking @@ -616,7 +655,7 @@ func TestLoop_LastOutputTimeUpdated(t *testing.T) { // that feeds into retry logic. func TestLoop_WatchdogReturnsError(t *testing.T) { // This test verifies the error message format that runIterationWithRetry will see - l := NewLoop("/test/prd.json", "test", 5) + l := NewLoop("/test/prd.json", "test", 5, testProvider) l.SetWatchdogTimeout(100 * time.Millisecond) // The watchdog error message should contain "watchdog timeout" @@ -630,7 +669,7 @@ func TestLoop_WatchdogReturnsError(t *testing.T) { // TestLoop_WatchdogWithWorkDir tests that watchdog works with NewLoopWithWorkDir too. func TestLoop_WatchdogWithWorkDir(t *testing.T) { - l := NewLoopWithWorkDir("/test/prd.json", "/work", "test", 5) + l := NewLoopWithWorkDir("/test/prd.json", "/work", "test", 5, testProvider) if l.WatchdogTimeout() != DefaultWatchdogTimeout { t.Errorf("Expected default watchdog timeout for NewLoopWithWorkDir, got %v", l.WatchdogTimeout()) diff --git a/internal/loop/manager.go b/internal/loop/manager.go index bb7be3f..ec5aa20 100644 --- a/internal/loop/manager.go +++ b/internal/loop/manager.go @@ -66,12 +66,13 @@ type ManagerEvent struct { // Manager manages multiple Loop instances for parallel PRD execution. type Manager struct { - instances map[string]*LoopInstance - events chan ManagerEvent - maxIter int - retryConfig RetryConfig - baseDir string // Project root directory (for CLAUDE.md etc.) - config *config.Config // Project config for post-completion actions + instances map[string]*LoopInstance + events chan ManagerEvent + maxIter int + retryConfig RetryConfig + provider Provider + baseDir string // Project root directory (for CLAUDE.md etc.) + config *config.Config // Project config for post-completion actions mu sync.RWMutex wg sync.WaitGroup onComplete func(prdName string) // Callback when a PRD completes @@ -79,12 +80,13 @@ type Manager struct { } // NewManager creates a new loop manager. -func NewManager(maxIter int) *Manager { +func NewManager(maxIter int, provider Provider) *Manager { return &Manager{ instances: make(map[string]*LoopInstance), events: make(chan ManagerEvent, 100), maxIter: maxIter, retryConfig: DefaultRetryConfig(), + provider: provider, } } @@ -207,6 +209,10 @@ func (m *Manager) Unregister(name string) error { // Start starts the loop for a specific PRD. func (m *Manager) Start(name string) error { + if m.provider == nil { + return fmt.Errorf("manager provider is not configured") + } + m.mu.Lock() instance, exists := m.instances[name] m.mu.Unlock() @@ -230,7 +236,7 @@ func (m *Manager) Start(name string) error { workDir = m.baseDir m.mu.RUnlock() } - instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, "", m.maxIter) + instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, "", m.maxIter, m.provider) instance.Loop.buildPrompt = promptBuilderForPRD(instance.PRDPath) m.mu.RLock() instance.Loop.SetRetryConfig(m.retryConfig) diff --git a/internal/loop/manager_test.go b/internal/loop/manager_test.go index d9b23b9..f2f65fb 100644 --- a/internal/loop/manager_test.go +++ b/internal/loop/manager_test.go @@ -36,7 +36,7 @@ func createTestPRDWithName(t *testing.T, dir, name string) string { } func TestNewManager(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) if m == nil { t.Fatal("expected non-nil manager") } @@ -52,7 +52,7 @@ func TestManagerRegister(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) // Register a new PRD err := m.Register("test-prd", prdPath) @@ -83,7 +83,7 @@ func TestManagerUnregister(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) // Unregister @@ -109,7 +109,7 @@ func TestManagerGetState(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) state, iteration, err := m.GetState("test-prd") @@ -136,7 +136,7 @@ func TestManagerGetAllInstances(t *testing.T) { prd2Path := createTestPRDWithName(t, tmpDir, "prd2") prd3Path := createTestPRDWithName(t, tmpDir, "prd3") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("prd1", prd1Path) m.Register("prd2", prd2Path) m.Register("prd3", prd3Path) @@ -159,7 +159,7 @@ func TestManagerGetAllInstances(t *testing.T) { } func TestManagerGetRunningPRDs(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) // Initially no running PRDs running := m.GetRunningPRDs() @@ -169,7 +169,7 @@ func TestManagerGetRunningPRDs(t *testing.T) { } func TestManagerGetRunningCount(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) count := m.GetRunningCount() if count != 0 { @@ -178,7 +178,7 @@ func TestManagerGetRunningCount(t *testing.T) { } func TestManagerIsAnyRunning(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) if m.IsAnyRunning() { t.Error("expected no running loops") @@ -189,7 +189,7 @@ func TestManagerPauseNonRunning(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) // Pause a non-running PRD should error @@ -203,7 +203,7 @@ func TestManagerStopNonRunning(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) // Stop a non-running PRD should not error (idempotent) @@ -214,7 +214,7 @@ func TestManagerStopNonRunning(t *testing.T) { } func TestManagerStartNonExistent(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) err := m.Start("non-existent") if err == nil { @@ -222,11 +222,29 @@ func TestManagerStartNonExistent(t *testing.T) { } } +func TestManagerStartRequiresProvider(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDWithName(t, tmpDir, "test-prd") + + m := NewManager(10, nil) + if err := m.Register("test-prd", prdPath); err != nil { + t.Fatalf("register failed: %v", err) + } + + err := m.Start("test-prd") + if err == nil { + t.Fatal("expected provider validation error") + } + if err.Error() != "manager provider is not configured" { + t.Fatalf("unexpected error: %v", err) + } +} + func TestManagerConcurrentAccess(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) // Test concurrent access to manager methods @@ -267,7 +285,7 @@ func TestLoopStateString(t *testing.T) { } func TestManagerSetCompletionCallback(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) called := false var calledWith string @@ -298,7 +316,7 @@ func TestManagerStopAll(t *testing.T) { prd1Path := createTestPRDWithName(t, tmpDir, "prd1") prd2Path := createTestPRDWithName(t, tmpDir, "prd2") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("prd1", prd1Path) m.Register("prd2", prd2Path) @@ -318,7 +336,7 @@ func TestManagerStopAll(t *testing.T) { } func TestManagerSetMaxIterations(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) if m.MaxIterations() != 10 { t.Errorf("expected initial maxIter 10, got %d", m.MaxIterations()) @@ -332,7 +350,7 @@ func TestManagerSetMaxIterations(t *testing.T) { } func TestManagerRetryConfig(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) // Check default retry config if !m.retryConfig.Enabled { @@ -360,7 +378,7 @@ func TestManagerRegisterWithWorktree(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) err := m.RegisterWithWorktree("test-prd", prdPath, "/tmp/worktree/test-prd", "chief/test-prd") if err != nil { @@ -396,7 +414,7 @@ func TestManagerRegisterWithWorktreeFieldsInGetAllInstances(t *testing.T) { prd1Path := createTestPRDWithName(t, tmpDir, "prd1") prd2Path := createTestPRDWithName(t, tmpDir, "prd2") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("prd1", prd1Path) m.RegisterWithWorktree("prd2", prd2Path, "/tmp/wt/prd2", "chief/prd2") @@ -425,7 +443,7 @@ func TestManagerRegisterWithWorktreeFieldsInGetAllInstances(t *testing.T) { } func TestManagerSetConfig(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) // Initially nil if m.Config() != nil { @@ -454,7 +472,7 @@ func TestManagerSetConfig(t *testing.T) { } func TestManagerSetPostCompleteCallback(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) var calledPRD, calledBranch, calledWorkDir string m.SetPostCompleteCallback(func(prdName, branch, workDir string) { @@ -487,7 +505,7 @@ func TestManagerClearWorktreeInfoAll(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") // Clear both worktree and branch @@ -508,7 +526,7 @@ func TestManagerClearWorktreeInfoKeepBranch(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") // Clear worktree only, keep branch @@ -526,7 +544,7 @@ func TestManagerClearWorktreeInfoKeepBranch(t *testing.T) { } func TestManagerClearWorktreeInfoNotFound(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) err := m.ClearWorktreeInfo("nonexistent", true) if err == nil { t.Error("expected error for nonexistent PRD") @@ -537,7 +555,7 @@ func TestManagerUpdateWorktreeInfo(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.Register("test-prd", prdPath) // Initially no worktree info @@ -561,7 +579,7 @@ func TestManagerUpdateWorktreeInfo(t *testing.T) { } func TestManagerUpdateWorktreeInfoNotFound(t *testing.T) { - m := NewManager(10) + m := NewManager(10, testProvider) err := m.UpdateWorktreeInfo("nonexistent", "/tmp", "branch") if err == nil { t.Error("expected error for nonexistent PRD") @@ -572,7 +590,7 @@ func TestManagerUpdateWorktreeInfoOverwrite(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.RegisterWithWorktree("test-prd", prdPath, "/old/path", "old-branch") // Update with new values @@ -593,7 +611,7 @@ func TestManagerConcurrentAccessWithWorktreeFields(t *testing.T) { tmpDir := t.TempDir() prdPath := createTestPRDWithName(t, tmpDir, "test-prd") - m := NewManager(10) + m := NewManager(10, testProvider) m.RegisterWithWorktree("test-prd", prdPath, "/tmp/wt/test", "chief/test") m.SetConfig(&config.Config{}) diff --git a/internal/loop/opencode_parser.go b/internal/loop/opencode_parser.go new file mode 100644 index 0000000..ca0c861 --- /dev/null +++ b/internal/loop/opencode_parser.go @@ -0,0 +1,125 @@ +package loop + +import ( + "encoding/json" + "errors" + "strings" +) + +type opencodeEvent struct { + Type string `json:"type"` + Timestamp int64 `json:"timestamp"` + SessionID string `json:"sessionID"` + Part *opencodePart `json:"part,omitempty"` + Error *opencodeError `json:"error,omitempty"` +} + +type opencodePart struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + CallID string `json:"callID,omitempty"` + Reason string `json:"reason,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + State *opencodeState `json:"state,omitempty"` + Tokens *opencodeTokens `json:"tokens,omitempty"` + Cost float64 `json:"cost,omitempty"` +} + +type opencodeState struct { + Status string `json:"status"` + Input map[string]interface{} `json:"input,omitempty"` + Output string `json:"output,omitempty"` + Title string `json:"title,omitempty"` + Time *opencodeTime `json:"time,omitempty"` +} + +type opencodeTime struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +type opencodeTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Reasoning int `json:"reasoning"` + Cache *opencodeCacheTokens `json:"cache,omitempty"` +} + +type opencodeCacheTokens struct { + Read int `json:"read"` + Write int `json:"write"` +} + +type opencodeError struct { + Name string `json:"name"` + Data *opencodeErrorData `json:"data,omitempty"` +} + +type opencodeErrorData struct { + Message string `json:"message"` + StatusCode int `json:"statusCode,omitempty"` +} + +func ParseLineOpenCode(line string) *Event { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + + var ev opencodeEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + return nil + } + + switch ev.Type { + case "step_start": + return &Event{Type: EventIterationStart} + + case "tool_use": + if ev.Part == nil || ev.Part.State == nil { + return nil + } + if ev.Part.State.Status == "completed" { + return &Event{ + Type: EventToolStart, + Tool: ev.Part.Tool, + } + } + return nil + + case "text": + if ev.Part == nil { + return nil + } + return &Event{ + Type: EventAssistantText, + Text: ev.Part.Text, + } + + case "step_finish": + if ev.Part == nil { + return nil + } + if ev.Part.Reason == "stop" { + return &Event{Type: EventComplete} + } + return nil + + case "error": + msg := "unknown error" + if ev.Error != nil { + if ev.Error.Data != nil { + msg = ev.Error.Data.Message + } + if msg == "" { + msg = ev.Error.Name + } + } + return &Event{Type: EventError, Err: errors.New(msg)} + + default: + return nil + } +} diff --git a/internal/loop/opencode_parser_test.go b/internal/loop/opencode_parser_test.go new file mode 100644 index 0000000..4580071 --- /dev/null +++ b/internal/loop/opencode_parser_test.go @@ -0,0 +1,90 @@ +package loop + +import ( + "testing" +) + +func TestParseLineOpenCode_stepStart(t *testing.T) { + line := `{"type":"step_start","timestamp":1767036059338,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e7ec7001qAZUB7eTENxPpI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-start","snapshot":"71db24a798b347669c0ebadb2dfad238f991753d"}}` + ev := ParseLineOpenCode(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventIterationStart { + t.Errorf("expected EventIterationStart, got %v", ev.Type) + } +} + +func TestParseLineOpenCode_toolUseCompleted(t *testing.T) { + line := `{"type":"tool_use","timestamp":1767036061199,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85bb001CzBoN2dDlEZJnP","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"tool","callID":"r9bQWsNLvOrJGIOz","tool":"bash","state":{"status":"completed","input":{"command":"echo hello","description":"Print hello to stdout"},"output":"hello\n","title":"Print hello to stdout","metadata":{"output":"hello\n","exit":0,"description":"Print hello to stdout"},"time":{"start":1767036061123,"end":1767036061173}}}}` + ev := ParseLineOpenCode(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventToolStart { + t.Errorf("expected EventToolStart, got %v", ev.Type) + } + if ev.Tool != "bash" { + t.Errorf("expected Tool bash, got %q", ev.Tool) + } +} + +func TestParseLineOpenCode_text(t *testing.T) { + line := `{"type":"text","timestamp":1767036064268,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e8ff2002mxSx9LtvAlf8Ng","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"text","text":"hello\n","time":{"start":1767036064265,"end":1767036064265}}}` + ev := ParseLineOpenCode(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventAssistantText { + t.Errorf("expected EventAssistantText, got %v", ev.Type) + } + if ev.Text != "hello\n" { + t.Errorf("expected Text hello\\n, got %q", ev.Text) + } +} + +func TestParseLineOpenCode_stepFinishStop(t *testing.T) { + line := `{"type":"step_finish","timestamp":1767036064273,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e9209001ojZ4ECN1geZISm","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"step-finish","reason":"stop","snapshot":"09dd05d11a4ac013136c1df10932efc0ad9116e8","cost":0.001,"tokens":{"input":671,"output":8,"reasoning":0,"cache":{"read":21415,"write":0}}}}` + ev := ParseLineOpenCode(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventComplete { + t.Errorf("expected EventComplete, got %v", ev.Type) + } +} + +func TestParseLineOpenCode_stepFinishToolCalls(t *testing.T) { + line := `{"type":"step_finish","timestamp":1767036061205,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85fb001L4I3WHMqH6EQNI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-finish","reason":"tool-calls","snapshot":"ee3406d50c7d9048674bbb1a3e325d82513b74ed","cost":0,"tokens":{"input":21772,"output":110,"reasoning":0,"cache":{"read":0,"write":0}}}}` + ev := ParseLineOpenCode(line) + if ev != nil { + t.Errorf("expected nil (ignore step_finish with reason=tool-calls), got %v", ev) + } +} + +func TestParseLineOpenCode_error(t *testing.T) { + line := `{"type":"error","timestamp":1767036065000,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429,"isRetryable":true}}}` + ev := ParseLineOpenCode(line) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.Type != EventError { + t.Errorf("expected EventError, got %v", ev.Type) + } + if ev.Err == nil { + t.Fatal("expected Err set") + } + if ev.Err.Error() != "Rate limit exceeded" { + t.Errorf("unexpected Err: %v", ev.Err) + } +} + +func TestParseLineOpenCode_emptyOrInvalid_returnsNil(t *testing.T) { + tests := []string{"", " ", "not json", "{}", `{"type":"unknown"}`} + for _, line := range tests { + ev := ParseLineOpenCode(line) + if ev != nil { + t.Errorf("ParseLineOpenCode(%q) expected nil, got %v", line, ev) + } + } +} diff --git a/internal/loop/provider.go b/internal/loop/provider.go new file mode 100644 index 0000000..2fc0d75 --- /dev/null +++ b/internal/loop/provider.go @@ -0,0 +1,32 @@ +package loop + +import ( + "context" + "os/exec" +) + +// OutputMode indicates how to capture the result of a one-shot agent command. +type OutputMode int + +const ( + // OutputStdout means the result is read from stdout. + OutputStdout OutputMode = iota + // OutputFromFile means the result is written to a file; use the path returned by ConvertCommand/FixJSONCommand. + OutputFromFile +) + +// Provider is the interface for an agent CLI (e.g. Claude, Codex). +// Implementations live in internal/agent to avoid import cycles. +type Provider interface { + Name() string + CLIPath() string + LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd + InteractiveCommand(workDir, prompt string) *exec.Cmd + ConvertCommand(workDir, prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error) + FixJSONCommand(prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error) + // CleanOutput extracts JSON from the provider's output format (e.g., NDJSON). + // Returns the original output if no cleaning needed. + CleanOutput(output string) string + ParseLine(line string) *Event + LogFileName() string +} diff --git a/internal/prd/generator.go b/internal/prd/generator.go index ff1fcb3..ec77a6c 100644 --- a/internal/prd/generator.go +++ b/internal/prd/generator.go @@ -14,7 +14,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/term" - "github.com/minicodemonkey/chief/embed" ) // Colors duplicated from tui/styles.go to avoid import cycle (tui → git → prd). @@ -58,6 +57,10 @@ type ConvertOptions struct { PRDDir string // Directory containing prd.md Merge bool // Auto-merge progress on conversion conflicts Force bool // Auto-overwrite on conversion conflicts + // RunConversion runs the agent to convert prd.md to JSON. Required. + RunConversion func(absPRDDir, idPrefix string) (string, error) + // RunFixJSON runs the agent to fix invalid JSON. Required. + RunFixJSON func(prompt string) (string, error) } // ProgressConflictChoice represents the user's choice when a progress conflict is detected. @@ -69,8 +72,7 @@ const ( ChoiceCancel // Cancel conversion ) -// Convert converts prd.md to prd.json using Claude one-shot mode. -// Claude receives the PRD content inline and returns JSON on stdout. +// Convert converts prd.md to prd.json using the configured agent one-shot. // This function is called: // - After chief new (new PRD creation) // - After chief edit (PRD modification) @@ -96,6 +98,10 @@ func Convert(opts ConvertOptions) error { return fmt.Errorf("failed to resolve absolute path: %w", err) } + if opts.RunConversion == nil || opts.RunFixJSON == nil { + return fmt.Errorf("conversion requires RunConversion and RunFixJSON callbacks") + } + // Check for existing progress before conversion var existingPRD *PRD hasProgress := false @@ -110,27 +116,27 @@ func Convert(opts ConvertOptions) error { idPrefix = existingPRD.ExtractIDPrefix() } - // Run Claude to convert prd.md → JSON string - rawJSON, err := runClaudeConversion(absPRDDir, idPrefix) + // Run agent to convert prd.md → JSON string + rawJSON, err := opts.RunConversion(absPRDDir, idPrefix) if err != nil { return err } // Clean up output (strip markdown fences if any) - cleanedJSON := cleanJSONOutput(rawJSON) + cleanedJSON := stripMarkdownFences(rawJSON) // Parse and validate newPRD, err := parseAndValidatePRD(cleanedJSON) if err != nil { - // Retry once: ask Claude to fix the invalid JSON + // Retry once: ask agent to fix the invalid JSON fmt.Println("Conversion produced invalid JSON, retrying...") fmt.Printf("Raw output:\n---\n%s\n---\n", cleanedJSON) - fixedJSON, retryErr := runClaudeJSONFix(cleanedJSON, err) + fixedJSON, retryErr := opts.RunFixJSON(fixPromptForRetry(cleanedJSON, err)) if retryErr != nil { return fmt.Errorf("conversion retry failed: %w", retryErr) } - cleanedJSON = cleanJSONOutput(fixedJSON) + cleanedJSON = stripMarkdownFences(fixedJSON) newPRD, err = parseAndValidatePRD(cleanedJSON) if err != nil { return fmt.Errorf("conversion produced invalid JSON after retry:\n---\n%s\n---\n%w", cleanedJSON, err) @@ -195,56 +201,14 @@ func Convert(opts ConvertOptions) error { return nil } -// runClaudeConversion sends the PRD file path to Claude and returns the JSON output. -// Claude reads prd.md itself using file-reading tools, avoiding token limits for large PRDs. -// The idPrefix determines the story ID convention (e.g., "US" → US-001, "MFR" → MFR-001). -func runClaudeConversion(absPRDDir, idPrefix string) (string, error) { - prdMdPath := filepath.Join(absPRDDir, "prd.md") - prompt := embed.GetConvertPrompt(prdMdPath, idPrefix) - - cmd := exec.Command("claude", "-p") - cmd.Dir = absPRDDir - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("failed to start Claude: %w", err) - } - - if err := waitWithPanel(cmd, "Converting PRD", "Analyzing PRD...", &stderr); err != nil { - return "", err - } - - return stdout.String(), nil -} - -// runClaudeJSONFix asks Claude to fix invalid JSON inline and returns the corrected output. -func runClaudeJSONFix(badJSON string, validationErr error) (string, error) { - fixPrompt := fmt.Sprintf( +// fixPromptForRetry builds the prompt for the agent to fix invalid JSON. +func fixPromptForRetry(badJSON string, validationErr error) string { + return fmt.Sprintf( "The following JSON is invalid. The error is: %s\n\n"+ "Fix the JSON (pay special attention to escaping double quotes inside string values with backslashes) "+ "and return ONLY the corrected JSON — no markdown fences, no explanation.\n\n%s", validationErr.Error(), badJSON, ) - - cmd := exec.Command("claude", "-p", fixPrompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("failed to start Claude: %w", err) - } - - if err := waitWithSpinner(cmd, "Fixing JSON", "Fixing prd.json...", &stderr); err != nil { - return "", err - } - - return stdout.String(), nil } // parseAndValidatePRD unmarshals a JSON string and validates it as a PRD. @@ -477,8 +441,9 @@ func repaintBox(box string, prevLines int) int { return newLines } -// waitWithSpinner runs a bordered panel while waiting for a command to finish. -func waitWithSpinner(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) error { +// WaitWithSpinner runs a bordered panel while waiting for a command to finish. +// Exported for use by cmd when running agent conversion. +func WaitWithSpinner(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) error { done := make(chan error, 1) go func() { done <- cmd.Wait() @@ -501,7 +466,7 @@ func waitWithSpinner(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) case err := <-done: clearPanelLines(prevLines) if err != nil { - return fmt.Errorf("Claude failed: %s", stderr.String()) + return fmt.Errorf("agent failed: %s", stderr.String()) } return nil case <-ticker.C: @@ -511,10 +476,9 @@ func waitWithSpinner(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) } } -// waitWithPanel runs a full progress panel (header, activity, progress bar, jokes) -// while waiting for a command to finish. Unlike waitWithProgress, it does not parse -// stdout — activity text is static. -func waitWithPanel(cmd *exec.Cmd, title, activity string, stderr *bytes.Buffer) error { +// WaitWithPanel runs a full progress panel (header, activity, progress bar, jokes) +// while waiting for a command to finish. Exported for use by cmd when running agent conversion. +func WaitWithPanel(cmd *exec.Cmd, title, activity string, stderr *bytes.Buffer) error { done := make(chan error, 1) go func() { done <- cmd.Wait() @@ -542,7 +506,7 @@ func waitWithPanel(cmd *exec.Cmd, title, activity string, stderr *bytes.Buffer) case err := <-done: clearPanelLines(prevLines) if err != nil { - return fmt.Errorf("Claude failed: %s", stderr.String()) + return fmt.Errorf("agent failed: %s", stderr.String()) } return nil case <-ticker.C: @@ -606,9 +570,9 @@ func NeedsConversion(prdDir string) (bool, error) { return mdInfo.ModTime().After(jsonInfo.ModTime()), nil } -// cleanJSONOutput removes markdown code blocks, conversational preamble, and trims -// whitespace from Claude's output to extract the JSON object. -func cleanJSONOutput(output string) string { +// stripMarkdownFences removes markdown code blocks and extracts the JSON object. +// This handles output from providers like Claude that may wrap JSON in markdown fences. +func stripMarkdownFences(output string) string { output = strings.TrimSpace(output) // Remove markdown code blocks if present @@ -624,7 +588,7 @@ func cleanJSONOutput(output string) string { output = strings.TrimSpace(output) - // If output doesn't start with '{', Claude may have added preamble text. + // If output doesn't start with '{', the provider may have added preamble text. // Extract the JSON object by finding the first '{' and matching closing '}'. if len(output) > 0 && output[0] != '{' { start := strings.Index(output, "{") diff --git a/internal/prd/generator_test.go b/internal/prd/generator_test.go index 6785988..dd7b0fd 100644 --- a/internal/prd/generator_test.go +++ b/internal/prd/generator_test.go @@ -8,7 +8,7 @@ import ( "time" ) -func TestCleanJSONOutput(t *testing.T) { +func TestStripMarkdownFences(t *testing.T) { tests := []struct { name string input string @@ -63,9 +63,9 @@ func TestCleanJSONOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := cleanJSONOutput(tt.input) + result := stripMarkdownFences(tt.input) if result != tt.expected { - t.Errorf("cleanJSONOutput() = %q, want %q", result, tt.expected) + t.Errorf("stripMarkdownFences() = %q, want %q", result, tt.expected) } }) } @@ -381,10 +381,10 @@ func TestMergeProgress(t *testing.T) { t.Run("mixed scenario - add, remove, keep", func(t *testing.T) { oldPRD := &PRD{ UserStories: []UserStory{ - {ID: "US-001", Passes: true}, // Keep with progress - {ID: "US-002", Passes: true}, // Removed - {ID: "US-003", InProgress: true}, // Keep with progress - {ID: "US-004", Passes: false}, // Keep without progress + {ID: "US-001", Passes: true}, // Keep with progress + {ID: "US-002", Passes: true}, // Removed + {ID: "US-003", InProgress: true}, // Keep with progress + {ID: "US-004", Passes: false}, // Keep without progress }, } newPRD := &PRD{ diff --git a/internal/prd/watcher.go b/internal/prd/watcher.go index d86d5a7..001397c 100644 --- a/internal/prd/watcher.go +++ b/internal/prd/watcher.go @@ -15,13 +15,13 @@ type WatcherEvent struct { // Watcher watches a prd.json file for changes and sends events. type Watcher struct { - path string - watcher *fsnotify.Watcher - events chan WatcherEvent - done chan struct{} - mu sync.Mutex - running bool - lastPRD *PRD + path string + watcher *fsnotify.Watcher + events chan WatcherEvent + done chan struct{} + mu sync.Mutex + running bool + lastPRD *PRD } // NewWatcher creates a new Watcher for the given PRD file path. diff --git a/internal/tui/app.go b/internal/tui/app.go index 694ec8d..8fc6f98 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -84,10 +84,10 @@ type mergeResultMsg struct { // cleanResultMsg is sent when a clean operation completes. type cleanResultMsg struct { - prdName string - success bool - message string - clearBranch bool + prdName string + success bool + message string + clearBranch bool } // autoActionResultMsg is sent when a post-completion auto-action (push/PR) completes. @@ -151,21 +151,22 @@ const ( // App is the main Bubble Tea model for the Chief TUI. type App struct { - prd *prd.PRD - prdPath string - prdName string - state AppState - iteration int - startTime time.Time - selectedIndex int + prd *prd.PRD + prdPath string + prdName string + state AppState + iteration int + startTime time.Time + selectedIndex int storiesScrollOffset int - width int - height int - err error + width int + height int + err error // Loop manager for parallel PRD execution - manager *loop.Manager - maxIter int + manager *loop.Manager + provider loop.Provider + maxIter int // Activity tracking lastActivity string @@ -197,8 +198,8 @@ type App struct { previousViewMode ViewMode // View to return to when closing help // Branch warning dialog - branchWarning *BranchWarning - pendingStartPRD string // PRD name waiting to start after branch decision + branchWarning *BranchWarning + pendingStartPRD string // PRD name waiting to start after branch decision pendingWorktreePath string // Absolute worktree path for pending PRD // Worktree setup spinner @@ -208,8 +209,8 @@ type App struct { completionScreen *CompletionScreen // Story timing tracking - storyTimings []StoryTiming - currentStoryID string + storyTimings []StoryTiming + currentStoryID string currentStoryStart time.Time // Settings overlay @@ -239,13 +240,13 @@ const ( ) // NewApp creates a new App with the given PRD. -func NewApp(prdPath string) (*App, error) { - return NewAppWithOptions(prdPath, 10) // default max iterations +func NewApp(prdPath string, provider loop.Provider) (*App, error) { + return NewAppWithOptions(prdPath, 10, provider) } // NewAppWithOptions creates a new App with the given PRD and options. // If maxIter <= 0, it will be calculated dynamically based on remaining stories. -func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { +func NewAppWithOptions(prdPath string, maxIter int, provider loop.Provider) (*App, error) { p, err := prd.LoadPRD(prdPath) if err != nil { return nil, err @@ -302,7 +303,7 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { progress, _ := prd.ParseProgress(prd.ProgressPath(prdPath)) // Create loop manager for parallel PRD execution - manager := loop.NewManager(maxIter) + manager := loop.NewManager(maxIter, provider) manager.SetBaseDir(baseDir) manager.SetConfig(cfg) @@ -316,30 +317,31 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { picker := NewPRDPicker(baseDir, prdName, manager) return &App{ - prd: p, - prdPath: prdPath, - prdName: prdName, - state: StateReady, - iteration: 0, - selectedIndex: 0, - maxIter: maxIter, - manager: manager, - watcher: watcher, - progressWatcher: progressWatcher, - progress: progress, - viewMode: ViewDashboard, - logViewer: NewLogViewer(), - diffViewer: NewDiffViewer(baseDir), - tabBar: tabBar, - picker: picker, - baseDir: baseDir, - config: cfg, + prd: p, + prdPath: prdPath, + prdName: prdName, + state: StateReady, + iteration: 0, + selectedIndex: 0, + maxIter: maxIter, + manager: manager, + provider: provider, + watcher: watcher, + progressWatcher: progressWatcher, + progress: progress, + viewMode: ViewDashboard, + logViewer: NewLogViewer(), + diffViewer: NewDiffViewer(baseDir), + tabBar: tabBar, + picker: picker, + baseDir: baseDir, + config: cfg, helpOverlay: NewHelpOverlay(), branchWarning: NewBranchWarning(), worktreeSpinner: NewWorktreeSpinner(), completionScreen: NewCompletionScreen(), settingsOverlay: NewSettingsOverlay(), - quitConfirm: NewQuitConfirmation(), + quitConfirm: NewQuitConfirmation(), }, nil } diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index 8f431b5..106ca0c 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -11,10 +11,10 @@ import ( type BranchWarningOption int const ( - BranchOptionCreateWorktree BranchWarningOption = iota // Create worktree + branch - BranchOptionCreateBranch // Create branch only (no worktree) - BranchOptionContinue // Continue on current branch / run in same directory - BranchOptionCancel // Cancel + BranchOptionCreateWorktree BranchWarningOption = iota // Create worktree + branch + BranchOptionCreateBranch // Create branch only (no worktree) + BranchOptionContinue // Continue on current branch / run in same directory + BranchOptionCancel // Cancel ) // DialogContext determines which set of options to show. diff --git a/internal/tui/completion.go b/internal/tui/completion.go index 4d14b75..23d738e 100644 --- a/internal/tui/completion.go +++ b/internal/tui/completion.go @@ -30,11 +30,11 @@ type CompletionScreen struct { width int height int - prdName string - completed int - total int - branch string - commitCount int + prdName string + completed int + total int + branch string + commitCount int hasAutoActions bool // Whether push/PR auto-actions are configured // Duration data diff --git a/internal/tui/confetti.go b/internal/tui/confetti.go index 2da38be..4f5c468 100644 --- a/internal/tui/confetti.go +++ b/internal/tui/confetti.go @@ -55,13 +55,13 @@ func NewConfetti(width, height int) *Confetti { for i := range c.particles { c.particles[i] = Particle{ - x: rand.Float64() * float64(width), - y: rand.Float64()*float64(height+10) - float64(height/2), // stagger: some above screen, some mid - vx: (rand.Float64() - 0.5) * 0.6, // lateral drift -0.3 to 0.3 - vy: 0.2 + rand.Float64()*0.4, // falling 0.2-0.6 - char: confettiChars[rand.Intn(len(confettiChars))], + x: rand.Float64() * float64(width), + y: rand.Float64()*float64(height+10) - float64(height/2), // stagger: some above screen, some mid + vx: (rand.Float64() - 0.5) * 0.6, // lateral drift -0.3 to 0.3 + vy: 0.2 + rand.Float64()*0.4, // falling 0.2-0.6 + char: confettiChars[rand.Intn(len(confettiChars))], color: confettiColors[rand.Intn(len(confettiColors))], - life: 80 + rand.Intn(120), // 80-200 ticks + life: 80 + rand.Intn(120), // 80-200 ticks } } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index e19fd66..a347f92 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -552,7 +552,11 @@ func (a *App) renderErrorPanel(width, height int) string { content.WriteString(DividerStyle.Render(strings.Repeat("─", width-4))) content.WriteString("\n\n") hintStyle := lipgloss.NewStyle().Foreground(WarningColor) - content.WriteString(hintStyle.Render("💡 Tip: Check claude.log in the PRD directory for full error details.")) + logName := "claude.log" + if a.provider != nil { + logName = a.provider.LogFileName() + } + content.WriteString(hintStyle.Render(fmt.Sprintf("💡 Tip: Check %s in the PRD directory for full error details.", logName))) content.WriteString("\n\n") // Retry instructions diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 66716f9..60d2e39 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -11,9 +11,9 @@ import ( // newTestApp creates a minimal App for testing scroll and rendering. func newTestApp(stories []prd.UserStory, width, height int) *App { return &App{ - prd: &prd.PRD{UserStories: stories}, - width: width, - height: height, + prd: &prd.PRD{UserStories: stories}, + width: width, + height: height, viewMode: ViewDashboard, } } diff --git a/internal/tui/diff.go b/internal/tui/diff.go index 5549234..0415f26 100644 --- a/internal/tui/diff.go +++ b/internal/tui/diff.go @@ -9,16 +9,16 @@ import ( // DiffViewer displays git diffs with syntax highlighting and scrolling. type DiffViewer struct { - lines []string - offset int - width int - height int - stats string - baseDir string - storyID string // Story ID whose commit diff is being shown (empty = full branch diff) - noCommit bool // True when no commit was found for the selected story - err error - loaded bool + lines []string + offset int + width int + height int + stats string + baseDir string + storyID string // Story ID whose commit diff is being shown (empty = full branch diff) + noCommit bool // True when no commit was found for the selected story + err error + loaded bool } // NewDiffViewer creates a new diff viewer. @@ -236,4 +236,3 @@ func (d *DiffViewer) styleLine(line string) string { return line } } - diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index b82f610..8dfcd07 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/minicodemonkey/chief/internal/agent" "github.com/minicodemonkey/chief/internal/loop" ) @@ -217,7 +218,7 @@ func TestGetWorktreeInfo_NoBranch(t *testing.T) { } func TestGetWorktreeInfo_WithBranch(t *testing.T) { - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") app := &App{prdName: "auth", manager: mgr} @@ -232,7 +233,7 @@ func TestGetWorktreeInfo_WithBranch(t *testing.T) { func TestGetWorktreeInfo_WithBranchNoWorktree(t *testing.T) { // Branch set but no worktree dir (branch-only mode) - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "", "chief/auth") app := &App{prdName: "auth", manager: mgr} @@ -247,7 +248,7 @@ func TestGetWorktreeInfo_WithBranchNoWorktree(t *testing.T) { func TestGetWorktreeInfo_RegisteredNoBranch(t *testing.T) { // Registered without worktree - should return empty (backward compatible) - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.Register("auth", "/tmp/prd.json") app := &App{prdName: "auth", manager: mgr} @@ -265,7 +266,7 @@ func TestHasWorktreeInfo(t *testing.T) { } // With branch - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") app.manager = mgr if !app.hasWorktreeInfo() { @@ -281,7 +282,7 @@ func TestEffectiveHeaderHeight_NoBranch(t *testing.T) { } func TestEffectiveHeaderHeight_WithBranch(t *testing.T) { - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") app := &App{prdName: "auth", manager: mgr} @@ -298,7 +299,7 @@ func TestRenderWorktreeInfoLine_NoBranch(t *testing.T) { } func TestRenderWorktreeInfoLine_WithBranch(t *testing.T) { - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "/tmp/.chief/worktrees/auth", "chief/auth") app := &App{prdName: "auth", manager: mgr} @@ -321,7 +322,7 @@ func TestRenderWorktreeInfoLine_WithBranch(t *testing.T) { } func TestRenderWorktreeInfoLine_BranchNoWorktree(t *testing.T) { - mgr := loop.NewManager(10) + mgr := loop.NewManager(10, agent.NewClaudeProvider("")) mgr.RegisterWithWorktree("auth", "/tmp/prd.json", "", "chief/auth") app := &App{prdName: "auth", manager: mgr} diff --git a/internal/tui/log_perf_test.go b/internal/tui/log_perf_test.go index 3c17ded..bf5eb18 100644 --- a/internal/tui/log_perf_test.go +++ b/internal/tui/log_perf_test.go @@ -85,10 +85,10 @@ func TestTotalLineCount_AccurateAcrossEventTypes(t *testing.T) { lv.SetSize(100, 30) // Add diverse events - lv.AddEvent(makeStoryEvent("US-1")) // 5 lines (blank, divider, title, divider, blank) + lv.AddEvent(makeStoryEvent("US-1")) // 5 lines (blank, divider, title, divider, blank) lv.AddEvent(makeToolStartEvent("Read", map[string]interface{}{"file_path": "/test.go"})) // 1 line - lv.AddEvent(makeToolResultEvent("some output")) // 1 line - lv.AddEvent(makeTextEvent("Hello")) // 1 line + lv.AddEvent(makeToolResultEvent("some output")) // 1 line + lv.AddEvent(makeTextEvent("Hello")) // 1 line // Count actual cached lines actualTotal := 0 diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 0db9334..6392356 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -47,10 +47,10 @@ const ( // CleanConfirmation holds the state of the clean confirmation dialog. type CleanConfirmation struct { - EntryName string // Name of the PRD being cleaned - Branch string // Branch name to display - WorktreeDir string // Worktree path to display - SelectedIdx int // Selected option index (0-2) + EntryName string // Name of the PRD being cleaned + Branch string // Branch name to display + WorktreeDir string // Worktree path to display + SelectedIdx int // Selected option index (0-2) } // CleanResult holds the result of a clean operation for display. @@ -61,18 +61,18 @@ type CleanResult struct { // PRDPicker manages the PRD picker modal state. type PRDPicker struct { - entries []PRDEntry - selectedIndex int - width int - height int - basePath string // Base path where .chief/prds/ is located - currentPRD string // Name of the currently active PRD - inputMode bool // Whether we're in input mode for new PRD name - inputValue string // The current input value for new PRD name - manager *loop.Manager // Reference to the loop manager for status updates - mergeResult *MergeResult // Result of the last merge operation (nil = none) - cleanConfirmation *CleanConfirmation // Active clean confirmation dialog (nil = none) - cleanResult *CleanResult // Result of the last clean operation (nil = none) + entries []PRDEntry + selectedIndex int + width int + height int + basePath string // Base path where .chief/prds/ is located + currentPRD string // Name of the currently active PRD + inputMode bool // Whether we're in input mode for new PRD name + inputValue string // The current input value for new PRD name + manager *loop.Manager // Reference to the loop manager for status updates + mergeResult *MergeResult // Result of the last merge operation (nil = none) + cleanConfirmation *CleanConfirmation // Active clean confirmation dialog (nil = none) + cleanResult *CleanResult // Result of the last clean operation (nil = none) } // NewPRDPicker creates a new PRD picker. diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 8365fe0..455a104 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -12,17 +12,17 @@ import ( type SettingsItemType int const ( - SettingsItemBool SettingsItemType = iota + SettingsItemBool SettingsItemType = iota SettingsItemString ) // SettingsItem represents a single editable setting. type SettingsItem struct { - Section string - Label string - Key string // config key for identification - Type SettingsItemType - BoolVal bool + Section string + Label string + Key string // config key for identification + Type SettingsItemType + BoolVal bool StringVal string } @@ -39,7 +39,7 @@ type SettingsOverlay struct { editBuffer string // GH CLI validation error - ghError string + ghError string showGHError bool } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 31a552a..56f3fae 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -21,8 +21,8 @@ var ( TextBrightColor = lipgloss.Color("#FFFFFF") // Bright white - emphasis // Background colors - BgColor = lipgloss.Color("#1E1E2E") // Dark background - BgSelectedColor = lipgloss.Color("#313244") // Selected item background + BgColor = lipgloss.Color("#1E1E2E") // Dark background + BgSelectedColor = lipgloss.Color("#313244") // Selected item background BgHighlightColor = lipgloss.Color("#45475A") // Highlight background ) diff --git a/internal/update/update.go b/internal/update/update.go index d74f0ba..ee21f1d 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -37,8 +37,8 @@ type Asset struct { // CheckResult contains the result of a version check. type CheckResult struct { - CurrentVersion string - LatestVersion string + CurrentVersion string + LatestVersion string UpdateAvailable bool }