From 96fd2f61880eefe2265d1ff113be675514fd11c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=89E?= <48557087+Simon-BEE@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:38:04 +0100 Subject: [PATCH 01/15] Support multiple agent CLIs (Claude & Codex) Introduce an agent abstraction to support Claude and OpenAI Codex CLIs. Added internal/agent providers (ClaudeProvider, CodexProvider), resolution and installation checks (Resolve, CheckInstalled), and tests. Wire provider selection via --agent / --agent-path flags, CHIEF_AGENT env vars, and .chief/config.yaml (agent.provider, agent.cliPath). Propagate the provider into TUI, new, edit, and convert flows; convert and fix-json now run through the provider. Added Codex JSONL parser for the loop output and accompanying tests. Updated config struct, docs (README, installation, configuration, troubleshooting), and various command code to use the provider abstraction. Key new files: internal/agent/*.go, internal/cmd/convert.go, internal/loop/codex_parser*.go and tests; updated main.go, cmd handlers, prd conversion wiring and docs to reflect multi-agent support. --- README.md | 12 +- cmd/chief/main.go | 108 ++++++++++++++---- docs/guide/installation.md | 25 +++- docs/reference/configuration.md | 23 +++- docs/troubleshooting/common-issues.md | 36 +++--- internal/agent/claude.go | 70 ++++++++++++ internal/agent/codex.go | 86 ++++++++++++++ internal/agent/codex_test.go | 134 ++++++++++++++++++++++ internal/agent/resolve.go | 49 ++++++++ internal/agent/resolve_test.go | 148 ++++++++++++++++++++++++ internal/cmd/convert.go | 81 +++++++++++++ internal/cmd/edit.go | 25 ++-- internal/cmd/new.go | 54 +++++---- internal/config/config.go | 7 ++ internal/loop/codex_parser.go | 132 ++++++++++++++++++++++ internal/loop/codex_parser_test.go | 157 ++++++++++++++++++++++++++ internal/loop/loop.go | 62 +++++----- internal/loop/loop_test.go | 55 +++++++-- internal/loop/manager.go | 24 ++-- internal/loop/manager_test.go | 54 ++++----- internal/loop/provider.go | 29 +++++ internal/prd/generator.go | 88 ++++----------- internal/tui/app.go | 14 ++- internal/tui/dashboard.go | 6 +- internal/tui/layout_test.go | 15 +-- 25 files changed, 1259 insertions(+), 235 deletions(-) create mode 100644 internal/agent/claude.go create mode 100644 internal/agent/codex.go create mode 100644 internal/agent/codex_test.go create mode 100644 internal/agent/resolve.go create mode 100644 internal/agent/resolve_test.go create mode 100644 internal/cmd/convert.go create mode 100644 internal/loop/codex_parser.go create mode 100644 internal/loop/codex_parser_test.go create mode 100644 internal/loop/provider.go diff --git a/README.md b/README.md index 564a747..76165bb 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)** or **[Codex CLI](https://developers.openai.com/codex/cli/reference)** installed and authenticated + +Use Claude by default, or configure Codex in `.chief/config.yaml`: + +```yaml +agent: + provider: codex + cliPath: /usr/local/bin/codex # optional +``` + +Or run with `chief --agent codex` or set `CHIEF_AGENT=codex`. ## License diff --git a/cmd/chief/main.go b/cmd/chief/main.go index b0796bc..5a2389d 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -8,6 +8,7 @@ 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" @@ -26,6 +27,8 @@ type TUIOptions struct { Merge bool Force bool NoRetry bool + Agent string // --agent claude|codex + AgentPath string // --agent-path } func main() { @@ -147,6 +150,20 @@ 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] + } + 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] + } + 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,7 +227,6 @@ func parseTUIFlags() *TUIOptions { func runNew() { opts := cmd.NewOptions{} - // Parse arguments: chief new [name] [context...] if len(os.Args) > 2 { opts.Name = os.Args[2] @@ -218,6 +234,29 @@ func runNew() { if len(os.Args) > 3 { opts.Context = strings.Join(os.Args[3:], " ") } + // Resolve provider (support --agent/--agent-path after "new") + cwd, _ := os.Getwd() + cfg, _ := config.Load(cwd) + flagAgent, flagPath := "", "" + for i := 2; i < len(os.Args); i++ { + switch os.Args[i] { + case "--agent": + if i+1 < len(os.Args) { + i++ + flagAgent = os.Args[i] + } + case "--agent-path": + if i+1 < len(os.Args) { + i++ + flagPath = os.Args[i] + } + } + } + opts.Provider = agent.Resolve(flagAgent, flagPath, cfg) + if err := agent.CheckInstalled(opts.Provider); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } if err := cmd.RunNew(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -227,8 +266,8 @@ func runNew() { func runEdit() { opts := cmd.EditOptions{} - - // Parse arguments: chief edit [name] [--merge] [--force] + flagAgent, flagPath := "", "" + // Parse arguments: chief edit [name] [--merge] [--force] [--agent] [--agent-path] for i := 2; i < len(os.Args); i++ { arg := os.Args[i] switch arg { @@ -236,13 +275,29 @@ func runEdit() { opts.Merge = true case "--force": opts.Force = true + case "--agent": + if i+1 < len(os.Args) { + i++ + flagAgent = os.Args[i] + } + case "--agent-path": + if i+1 < len(os.Args) { + i++ + flagPath = os.Args[i] + } default: - // If not a flag, treat as PRD name (first non-flag arg) if opts.Name == "" && !strings.HasPrefix(arg, "-") { opts.Name = arg } } } + cwd, _ := os.Getwd() + cfg, _ := config.Load(cwd) + opts.Provider = agent.Resolve(flagAgent, flagPath, cfg) + if err := agent.CheckInstalled(opts.Provider); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } if err := cmd.RunEdit(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -283,6 +338,15 @@ func runList() { } func runTUIWithOptions(opts *TUIOptions) { + // Resolve agent provider early (used for conversion, app, new, edit) + cwd, _ := os.Getwd() + cfg, _ := config.Load(cwd) + provider := agent.Resolve(opts.Agent, opts.AgentPath, cfg) + if err := agent.CheckInstalled(provider); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + prdPath := opts.PRDPath // If no PRD specified, try to find one @@ -322,7 +386,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 +409,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 +468,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 +482,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 +514,11 @@ Commands: help Show this help message Global Options: + --agent Agent CLI to use: claude (default) or codex + --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 +539,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..827d28b 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) or **Codex**. Install at least one and authenticate. + +### Option A: Claude Code CLI (default) ::: code-group @@ -27,8 +29,20 @@ 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. ::: ### Optional: GitHub CLI (`gh`) @@ -238,11 +252,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..b50cc57 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" + 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` or `codex` | +| `agent.cliPath` | string | `""` | Optional path to the agent binary (e.g. `/usr/local/bin/codex`). 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` or `codex` | 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) or **Codex CLI** as the agent. Choose via: + +- **Config:** `agent.provider: codex` and optionally `agent.cliPath: /path/to/codex` in `.chief/config.yaml` +- **Environment:** `CHIEF_AGENT=codex`, `CHIEF_AGENT_PATH=/path/to/codex` +- **CLI:** `chief --agent codex --agent-path /path/to/codex` + ## 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..0b6798c 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -6,23 +6,33 @@ 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 or Codex) 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 ``` -**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`). ## Permission Denied @@ -42,7 +52,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` or `codex.log` in the PRD directory): ```bash tail -100 .chief/prds/your-prd/claude.log ``` @@ -66,7 +76,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` or `codex.log`) for what the agent is doing: ```bash tail -f .chief/prds/your-prd/claude.log ``` @@ -97,7 +107,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` or `codex.log`) - Unclear acceptance criteria? Clarify them ## "No PRD Found" @@ -239,4 +249,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` or `codex.log`) diff --git a/internal/agent/claude.go b/internal/agent/claude.go new file mode 100644 index 0000000..f161e33 --- /dev/null +++ b/internal/agent/claude.go @@ -0,0 +1,70 @@ +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) { + cmd := exec.Command(p.cliPath, "-p", "--tools", "") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputStdout, "" +} + +// FixJSONCommand implements loop.Provider. +func (p *ClaudeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string) { + cmd := exec.Command(p.cliPath, "-p", prompt) + return cmd, loop.OutputStdout, "" +} + +// 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" } diff --git a/internal/agent/codex.go b/internal/agent/codex.go new file mode 100644 index 0000000..eb0c846 --- /dev/null +++ b/internal/agent/codex.go @@ -0,0 +1,86 @@ +package agent + +import ( + "context" + "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) { + f, err := os.CreateTemp("", "chief-codex-convert-*.txt") + if err != nil { + // Caller will fail when running cmd; return empty path + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", "", "-") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, "" + } + outPath := f.Name() + f.Close() + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", outPath, "-") + cmd.Dir = workDir + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, outPath +} + +// FixJSONCommand implements loop.Provider. +func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string) { + f, err := os.CreateTemp("", "chief-codex-fixjson-*.txt") + if err != nil { + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", "", "-") + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, "" + } + outPath := f.Name() + f.Close() + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", outPath, "-") + cmd.Stdin = strings.NewReader(prompt) + return cmd, loop.OutputFromFile, outPath +} + +// 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" } diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go new file mode 100644 index 0000000..7986708 --- /dev/null +++ b/internal/agent/codex_test.go @@ -0,0 +1,134 @@ +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 := p.ConvertCommand("/prd/dir", "convert prompt") + 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) + } + // Should have -o outPath + 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 := p.FixJSONCommand("fix prompt") + if mode != loop.OutputFromFile { + t.Errorf("FixJSONCommand mode = %v, want OutputFromFile", mode) + } + if outPath == "" { + t.Error("FixJSONCommand outPath should be non-empty temp file") + } + // -o outPath present + 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/resolve.go b/internal/agent/resolve.go new file mode 100644 index 0000000..83f526a --- /dev/null +++ b/internal/agent/resolve.go @@ -0,0 +1,49 @@ +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). +func Resolve(flagAgent, flagPath string, cfg *config.Config) loop.Provider { + 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 "codex": + return NewCodexProvider(cliPath) + default: + return NewClaudeProvider(cliPath) + } +} + +// 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..e3e021a --- /dev/null +++ b/internal/agent/resolve_test.go @@ -0,0 +1,148 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/minicodemonkey/chief/internal/config" +) + +func TestResolve_priority(t *testing.T) { + // Default: no flag, no env, nil config -> Claude + got := Resolve("", "", 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 = Resolve("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 = Resolve("", "", 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 = Resolve("claude", "", cfg) + if got.Name() != "Claude" { + t.Errorf("Resolve(claude, _, config codex) name = %q, want Claude", got.Name()) + } + // flag path overrides config path + got = Resolve("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 := Resolve("", "", 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 = Resolve("", "", 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) { + // Case and spaces + got := Resolve(" CODEX ", "", nil) + if got.Name() != "Codex" { + t.Errorf("Resolve(' CODEX ') name = %q, want Codex", got.Name()) + } +} + +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 := Resolve("", "", 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..b841482 --- /dev/null +++ b/internal/cmd/convert.go @@ -0,0 +1,81 @@ +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) { + content, err := os.ReadFile(filepath.Join(absPRDDir, "prd.md")) + if err != nil { + return "", fmt.Errorf("failed to read prd.md: %w", err) + } + prompt := embed.GetConvertPrompt(string(content)) + cmd, mode, outPath := provider.ConvertCommand(absPRDDir, prompt) + + 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 { + 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 := provider.FixJSONCommand(prompt) + + 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 { + 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..03bcf9b 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. @@ -47,22 +49,23 @@ func RunEdit(opts EditOptions) error { // Get the edit prompt with the PRD directory path prompt := embed.GetEditPrompt(prdDir) - // 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/new.go b/internal/cmd/new.go index 3d7b6db..73ba091 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -6,18 +6,19 @@ 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. @@ -54,13 +55,13 @@ func RunNew(opts NewOptions) error { // Get the init prompt with the PRD directory path prompt := embed.GetInitPrompt(prdDir, opts.Context) - // 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 @@ -72,7 +73,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) } @@ -80,37 +81,44 @@ 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 { + 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 string) (string, error) { + return runConversionWithProvider(provider, absPRDDir) + }, + RunFixJSON: func(prompt string) (string, error) { + return runFixJSONWithProvider(provider, prompt) + }, }) } diff --git a/internal/config/config.go b/internal/config/config.go index 6f43ec0..6bbacc0 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 vs Codex). +type AgentConfig struct { + Provider string `yaml:"provider"` // "claude" (default) | "codex" + 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 c3b36b5..8ff850a 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -35,7 +35,7 @@ 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 @@ -43,7 +43,8 @@ type Loop struct { maxIter int iteration int events chan Event - claudeCmd *exec.Cmd + provider Provider + agentCmd *exec.Cmd logFile *os.File mu sync.Mutex stopped bool @@ -52,11 +53,12 @@ type Loop struct { } // 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(), } @@ -64,12 +66,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(), } @@ -77,9 +80,9 @@ func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int) *Lo // NewLoopWithEmbeddedPrompt creates a new Loop instance using the embedded agent prompt. // The PRD path placeholder in the prompt is automatically substituted. -func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int) *Loop { +func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int, provider Provider) *Loop { prompt := embed.GetPrompt(prdPath, prd.ProgressPath(prdPath)) - return NewLoop(prdPath, prompt, maxIter) + return NewLoop(prdPath, prompt, maxIter, provider) } // Events returns the channel for receiving events from the loop. @@ -98,7 +101,7 @@ func (l *Loop) Iteration() int { func (l *Loop) Run(ctx context.Context) error { // 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 { @@ -210,7 +213,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 @@ -256,34 +259,28 @@ 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 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) } // Process stdout in a separate goroutine @@ -305,7 +302,7 @@ func (l *Loop) runIteration(ctx context.Context) error { wg.Wait() // 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() @@ -317,11 +314,11 @@ func (l *Loop) runIteration(ctx context.Context) error { if stopped { return nil } - 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 @@ -341,7 +338,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() @@ -365,16 +362,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() } } @@ -406,7 +402,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 != "" { @@ -415,11 +411,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 037d802..46c49be 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "testing" "time" @@ -11,6 +12,36 @@ 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) ConvertCommand(_, _ string) (*exec.Cmd, OutputMode, string) { return exec.Command("true"), OutputStdout, "" } +func (m *mockProvider) FixJSONCommand(_ string) (*exec.Cmd, OutputMode, string) { return exec.Command("true"), OutputStdout, "" } +func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) } +func (m *mockProvider) LogFileName() string { return "claude.log" } + +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 +} + +// 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() @@ -56,7 +87,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) @@ -73,7 +104,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) @@ -93,7 +124,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) @@ -101,7 +132,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 { @@ -110,7 +141,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()) @@ -123,7 +154,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() @@ -159,7 +190,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) @@ -241,7 +272,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 @@ -287,7 +318,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") @@ -306,7 +337,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) @@ -347,7 +378,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()) @@ -377,7 +408,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 { diff --git a/internal/loop/manager.go b/internal/loop/manager.go index 003405c..83e90ac 100644 --- a/internal/loop/manager.go +++ b/internal/loop/manager.go @@ -67,25 +67,27 @@ 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 - mu sync.RWMutex - wg sync.WaitGroup - onComplete func(prdName string) // Callback when a PRD completes + 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 onPostComplete func(prdName, branch, workDir string) // Callback for post-completion actions (push, PR) } // 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, } } @@ -232,7 +234,7 @@ func (m *Manager) Start(name string) error { workDir = m.baseDir m.mu.RUnlock() } - instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, prompt, m.maxIter) + instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, prompt, m.maxIter, m.provider) m.mu.RLock() instance.Loop.SetRetryConfig(m.retryConfig) m.mu.RUnlock() diff --git a/internal/loop/manager_test.go b/internal/loop/manager_test.go index d9b23b9..e60df6c 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 { @@ -226,7 +226,7 @@ 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 +267,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 +298,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 +318,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 +332,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 +360,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 +396,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 +425,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 +454,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 +487,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 +508,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 +526,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 +537,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 +561,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 +572,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 +593,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/provider.go b/internal/loop/provider.go new file mode 100644 index 0000000..0431c58 --- /dev/null +++ b/internal/loop/provider.go @@ -0,0 +1,29 @@ +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) + FixJSONCommand(prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string) + ParseLine(line string) *Event + LogFileName() string +} diff --git a/internal/prd/generator.go b/internal/prd/generator.go index d34c711..3b26467 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). @@ -55,9 +54,13 @@ var waitingJokes = []string{ // ConvertOptions contains configuration for PRD conversion. type ConvertOptions struct { - PRDDir string // Directory containing prd.md - Merge bool // Auto-merge progress on conversion conflicts - Force bool // Auto-overwrite on conversion conflicts + 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 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 @@ -104,8 +110,8 @@ func Convert(opts ConvertOptions) error { hasProgress = HasProgress(existing) } - // Run Claude to convert prd.md → JSON string - rawJSON, err := runClaudeConversion(absPRDDir) + // Run agent to convert prd.md → JSON string + rawJSON, err := opts.RunConversion(absPRDDir) if err != nil { return err } @@ -116,10 +122,10 @@ func Convert(opts ConvertOptions) error { // 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) } @@ -180,58 +186,14 @@ func Convert(opts ConvertOptions) error { return nil } -// runClaudeConversion reads prd.md, sends content inline to Claude, and returns the JSON output. -func runClaudeConversion(absPRDDir string) (string, error) { - content, err := os.ReadFile(filepath.Join(absPRDDir, "prd.md")) - if err != nil { - return "", fmt.Errorf("failed to read prd.md: %w", err) - } - - prompt := embed.GetConvertPrompt(string(content)) - - cmd := exec.Command("claude", "-p", "--tools", "") - 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. @@ -464,8 +426,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() @@ -498,10 +461,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() diff --git a/internal/tui/app.go b/internal/tui/app.go index 2a26dcc..5119887 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -163,8 +163,9 @@ type App struct { 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 @@ -238,13 +239,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 @@ -301,7 +302,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) @@ -323,6 +324,7 @@ func NewAppWithOptions(prdPath string, maxIter int) (*App, error) { selectedIndex: 0, maxIter: maxIter, manager: manager, + provider: provider, watcher: watcher, progressWatcher: progressWatcher, progress: progress, diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index a931485..d5c22ca 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -507,7 +507,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/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} From 8313c65025ccc1834efe2bf33b291b9bcf282bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=89E?= <48557087+Simon-BEE@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:58:57 +0100 Subject: [PATCH 02/15] Add errors to Provider convert/fix and resolver Propagate errors from provider commands and improve provider resolution flow. - Extended loop.Provider ConvertCommand and FixJSONCommand to return an error and updated implementations (Claude, Codex) to follow the new signature. Codex now returns explicit errors when temp files cannot be created. - Updated callers (internal/cmd/convert.go, runFixJSONWithProvider, loop tests, agent tests) to handle the new error return values. - Changed Resolve to return (loop.Provider, error) and make unknown providers produce a clear error; added mustResolve helper in tests and a TestResolve_unknownProvider. - Added resolveProvider helper in cmd/chief to load config and resolve the provider (exiting on error), and improved CLI flag parsing for --agent/--agent-path (support both --flag value and --flag=value). Minor test and docstring tweaks to reflect agent-agnostic language. --- cmd/chief/main.go | 85 ++++++++++++++++++++-------------- internal/agent/claude.go | 8 ++-- internal/agent/codex.go | 19 +++----- internal/agent/codex_test.go | 12 +++-- internal/agent/resolve.go | 9 ++-- internal/agent/resolve_test.go | 39 ++++++++++++---- internal/cmd/convert.go | 10 +++- internal/cmd/new.go | 2 +- internal/loop/loop_test.go | 20 +++++--- internal/loop/provider.go | 4 +- 10 files changed, 129 insertions(+), 79 deletions(-) diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 5a2389d..8f94144 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -227,37 +228,41 @@ func parseTUIFlags() *TUIOptions { func runNew() { opts := cmd.NewOptions{} - // Parse arguments: chief new [name] [context...] - if len(os.Args) > 2 { - opts.Name = os.Args[2] - } - if len(os.Args) > 3 { - opts.Context = strings.Join(os.Args[3:], " ") - } - // Resolve provider (support --agent/--agent-path after "new") - cwd, _ := os.Getwd() - cfg, _ := config.Load(cwd) flagAgent, flagPath := "", "" + var positional []string + + // Parse arguments: chief new [name] [context...] [--agent X] [--agent-path X] for i := 2; i < len(os.Args); i++ { - switch os.Args[i] { - case "--agent": + arg := os.Args[i] + switch { + case arg == "--agent": if i+1 < len(os.Args) { i++ flagAgent = os.Args[i] } - case "--agent-path": + 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] } + case strings.HasPrefix(arg, "--agent-path="): + flagPath = strings.TrimPrefix(arg, "--agent-path=") + case strings.HasPrefix(arg, "-"): + // skip unknown flags + default: + positional = append(positional, arg) } } - opts.Provider = agent.Resolve(flagAgent, flagPath, cfg) - if err := agent.CheckInstalled(opts.Provider); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + 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) @@ -267,38 +272,37 @@ func runNew() { func runEdit() { opts := cmd.EditOptions{} flagAgent, flagPath := "", "" - // Parse arguments: chief edit [name] [--merge] [--force] [--agent] [--agent-path] + + // 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 "--agent": + case arg == "--agent": if i+1 < len(os.Args) { i++ flagAgent = os.Args[i] } - case "--agent-path": + 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] } + case strings.HasPrefix(arg, "--agent-path="): + flagPath = strings.TrimPrefix(arg, "--agent-path=") default: if opts.Name == "" && !strings.HasPrefix(arg, "-") { opts.Name = arg } } } - cwd, _ := os.Getwd() - cfg, _ := config.Load(cwd) - opts.Provider = agent.Resolve(flagAgent, flagPath, cfg) - if err := agent.CheckInstalled(opts.Provider); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } + opts.Provider = resolveProvider(flagAgent, flagPath) if err := cmd.RunEdit(opts); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -337,15 +341,28 @@ func runList() { } } -func runTUIWithOptions(opts *TUIOptions) { - // Resolve agent provider early (used for conversion, app, new, edit) - cwd, _ := os.Getwd() +// 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, _ := config.Load(cwd) - provider := agent.Resolve(opts.Agent, opts.AgentPath, cfg) + 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 diff --git a/internal/agent/claude.go b/internal/agent/claude.go index f161e33..c80ad86 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -48,17 +48,17 @@ func (p *ClaudeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { } // ConvertCommand implements loop.Provider. -func (p *ClaudeProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string) { +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, "" + return cmd, loop.OutputStdout, "", nil } // FixJSONCommand implements loop.Provider. -func (p *ClaudeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string) { +func (p *ClaudeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { cmd := exec.Command(p.cliPath, "-p", prompt) - return cmd, loop.OutputStdout, "" + return cmd, loop.OutputStdout, "", nil } // ParseLine implements loop.Provider. diff --git a/internal/agent/codex.go b/internal/agent/codex.go index eb0c846..22e46dd 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -2,6 +2,7 @@ package agent import ( "context" + "fmt" "os" "os/exec" "strings" @@ -45,36 +46,30 @@ func (p *CodexProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { } // ConvertCommand implements loop.Provider. -func (p *CodexProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string) { +func (p *CodexProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { f, err := os.CreateTemp("", "chief-codex-convert-*.txt") if err != nil { - // Caller will fail when running cmd; return empty path - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", "", "-") - cmd.Dir = workDir - cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, "" + 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", "--output-last-message", "-o", outPath, "-") cmd.Dir = workDir cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, outPath + return cmd, loop.OutputFromFile, outPath, nil } // FixJSONCommand implements loop.Provider. -func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string) { +func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { f, err := os.CreateTemp("", "chief-codex-fixjson-*.txt") if err != nil { - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", "", "-") - cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, "" + 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", "--output-last-message", "-o", outPath, "-") cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, outPath + return cmd, loop.OutputFromFile, outPath, nil } // ParseLine implements loop.Provider. diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index 7986708..3a9d2c0 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -62,7 +62,10 @@ func TestCodexProvider_LoopCommand(t *testing.T) { func TestCodexProvider_ConvertCommand(t *testing.T) { p := NewCodexProvider("codex") - cmd, mode, outPath := p.ConvertCommand("/prd/dir", "convert prompt") + 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) } @@ -72,7 +75,6 @@ func TestCodexProvider_ConvertCommand(t *testing.T) { if !strings.Contains(cmd.Path, "codex") { t.Errorf("ConvertCommand Path = %q", cmd.Path) } - // Should have -o outPath foundO := false for i, a := range cmd.Args { if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { @@ -90,14 +92,16 @@ func TestCodexProvider_ConvertCommand(t *testing.T) { func TestCodexProvider_FixJSONCommand(t *testing.T) { p := NewCodexProvider("codex") - cmd, mode, outPath := p.FixJSONCommand("fix prompt") + 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") } - // -o outPath present foundO := false for i, a := range cmd.Args { if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { diff --git a/internal/agent/resolve.go b/internal/agent/resolve.go index 83f526a..ff7ece9 100644 --- a/internal/agent/resolve.go +++ b/internal/agent/resolve.go @@ -12,7 +12,8 @@ import ( // 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). -func Resolve(flagAgent, flagPath string, cfg *config.Config) loop.Provider { +// 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)) @@ -32,10 +33,12 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) loop.Provider { } switch providerName { + case "claude": + return NewClaudeProvider(cliPath), nil case "codex": - return NewCodexProvider(cliPath) + return NewCodexProvider(cliPath), nil default: - return NewClaudeProvider(cliPath) + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\" or \"codex\"", providerName) } } diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go index e3e021a..b5b3f28 100644 --- a/internal/agent/resolve_test.go +++ b/internal/agent/resolve_test.go @@ -8,11 +8,21 @@ import ( "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 := Resolve("", "", nil) + got := mustResolve(t, "", "", nil) if got.Name() != "Claude" { t.Errorf("Resolve(_, _, nil) name = %q, want Claude", got.Name()) } @@ -21,7 +31,7 @@ func TestResolve_priority(t *testing.T) { } // Flag overrides everything - got = Resolve("codex", "", nil) + got = mustResolve(t, "codex", "", nil) if got.Name() != "Codex" { t.Errorf("Resolve(codex, _, nil) name = %q, want Codex", got.Name()) } @@ -30,7 +40,7 @@ func TestResolve_priority(t *testing.T) { cfg := &config.Config{} cfg.Agent.Provider = "codex" cfg.Agent.CLIPath = "/usr/local/bin/codex" - got = Resolve("", "", cfg) + got = mustResolve(t, "", "", cfg) if got.Name() != "Codex" { t.Errorf("Resolve(_, _, config codex) name = %q, want Codex", got.Name()) } @@ -39,12 +49,12 @@ func TestResolve_priority(t *testing.T) { } // Flag overrides config - got = Resolve("claude", "", cfg) + 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 = Resolve("codex", "/opt/codex", cfg) + 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()) } @@ -73,7 +83,7 @@ func TestResolve_env(t *testing.T) { // Env provider when no flag os.Setenv(keyAgent, "codex") - got := Resolve("", "", nil) + got := mustResolve(t, "", "", nil) if got.Name() != "Codex" { t.Errorf("with CHIEF_AGENT=codex, name = %q, want Codex", got.Name()) } @@ -82,7 +92,7 @@ func TestResolve_env(t *testing.T) { // Env path when no flag path os.Setenv(keyAgent, "codex") os.Setenv(keyPath, "/env/codex") - got = Resolve("", "", nil) + got = mustResolve(t, "", "", nil) if got.CLIPath() != "/env/codex" { t.Errorf("with CHIEF_AGENT_PATH, CLIPath = %q, want /env/codex", got.CLIPath()) } @@ -91,13 +101,22 @@ func TestResolve_env(t *testing.T) { } func TestResolve_normalize(t *testing.T) { - // Case and spaces - got := Resolve(" CODEX ", "", nil) + got := mustResolve(t, " CODEX ", "", nil) if got.Name() != "Codex" { t.Errorf("Resolve(' CODEX ') name = %q, want Codex", got.Name()) } } +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") @@ -141,7 +160,7 @@ agent: if err != nil { t.Fatal(err) } - got := Resolve("", "", cfg) + 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 index b841482..c6911c8 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -18,7 +18,10 @@ func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string return "", fmt.Errorf("failed to read prd.md: %w", err) } prompt := embed.GetConvertPrompt(string(content)) - cmd, mode, outPath := provider.ConvertCommand(absPRDDir, prompt) + 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 { @@ -50,7 +53,10 @@ func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string // runFixJSONWithProvider runs the agent to fix invalid JSON. func runFixJSONWithProvider(provider loop.Provider, prompt string) (string, error) { - cmd, mode, outPath := provider.FixJSONCommand(prompt) + 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 { diff --git a/internal/cmd/new.go b/internal/cmd/new.go index 73ba091..b56bcff 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -21,7 +21,7 @@ type NewOptions struct { 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 == "" { diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 46c49be..b016693 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -17,13 +17,19 @@ 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) ConvertCommand(_, _ string) (*exec.Cmd, OutputMode, string) { return exec.Command("true"), OutputStdout, "" } -func (m *mockProvider) FixJSONCommand(_ string) (*exec.Cmd, OutputMode, string) { return exec.Command("true"), OutputStdout, "" } -func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) } -func (m *mockProvider) LogFileName() string { return "claude.log" } +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 != "" { diff --git a/internal/loop/provider.go b/internal/loop/provider.go index 0431c58..8e89727 100644 --- a/internal/loop/provider.go +++ b/internal/loop/provider.go @@ -22,8 +22,8 @@ type Provider interface { 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) - FixJSONCommand(prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string) + 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) ParseLine(line string) *Event LogFileName() string } From 95d9e86f791aa437614163f25bbb9f3d99c1f7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=89E?= <48557087+Simon-BEE@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:13:30 +0100 Subject: [PATCH 03/15] Add provider validation and flag error handling Add runtime validation and better error handling across CLI and loop components. Key changes: - cmd/chief: validate presence of values for --agent and --agent-path flags (print error and exit) and handle config.Load errors with a clear message. - internal/cmd: enforce non-nil Provider for RunNew, RunEdit and runInteractiveAgent (return explicit errors) and add corresponding tests that assert provider is required. Minor comment/spacing cleanups in option structs. - internal/cmd/convert: ensure temporary output files are removed on failed command start to avoid leftover artifacts. - internal/loop: return an error if Loop.Run is invoked without a configured provider; require provider for Manager.Start; add tests for both failure cases. Also minor struct field alignment and test provider method formatting. These changes make failures explicit and fail fast with clearer messages, and ensure temporary files are cleaned up on early command failures. --- cmd/chief/main.go | 24 +++++++++++++++++++++++- internal/cmd/convert.go | 6 ++++++ internal/cmd/edit.go | 11 +++++++---- internal/cmd/edit_test.go | 25 +++++++++++++++++++++++++ internal/cmd/new.go | 18 ++++++++++++------ internal/cmd/new_test.go | 16 ++++++++++++++++ internal/loop/loop.go | 8 ++++++-- internal/loop/loop_test.go | 21 ++++++++++++++++----- internal/loop/manager.go | 24 ++++++++++++++---------- internal/loop/manager_test.go | 18 ++++++++++++++++++ 10 files changed, 143 insertions(+), 28 deletions(-) diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 8f94144..136cfab 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -155,6 +155,9 @@ func parseTUIFlags() *TUIOptions { 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=") @@ -162,6 +165,9 @@ func parseTUIFlags() *TUIOptions { 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=") @@ -239,6 +245,9 @@ func runNew() { 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=") @@ -246,6 +255,9 @@ func runNew() { 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=") @@ -285,6 +297,9 @@ func runEdit() { 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=") @@ -292,6 +307,9 @@ func runEdit() { 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=") @@ -348,7 +366,11 @@ func resolveProvider(flagAgent, flagPath string) loop.Provider { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - cfg, _ := config.Load(cwd) + 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) diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index c6911c8..c005af8 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -32,6 +32,9 @@ func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string 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 { @@ -67,6 +70,9 @@ func runFixJSONWithProvider(provider loop.Provider, prompt string) (string, erro 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 { diff --git a/internal/cmd/edit.go b/internal/cmd/edit.go index 03bcf9b..83a8134 100644 --- a/internal/cmd/edit.go +++ b/internal/cmd/edit.go @@ -11,10 +11,10 @@ import ( // 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) } @@ -48,6 +48,9 @@ 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 agent session fmt.Printf("Editing PRD at %s...\n", prdDir) 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 b56bcff..f7144ef 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -15,9 +15,9 @@ import ( // NewOptions contains configuration for the new command. type NewOptions struct { - 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) + 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) } @@ -54,6 +54,9 @@ 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 agent session fmt.Printf("Creating PRD in %s...\n", prdDir) @@ -83,6 +86,9 @@ func RunNew(opts NewOptions) error { // 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 @@ -92,9 +98,9 @@ func runInteractiveAgent(provider loop.Provider, workDir, prompt string) error { // 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 } diff --git a/internal/cmd/new_test.go b/internal/cmd/new_test.go index 79592f0..d426d19 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" ) @@ -103,3 +104,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/loop/loop.go b/internal/loop/loop.go index 8ff850a..ccd9231 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -21,9 +21,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) } // DefaultRetryConfig returns the default retry configuration. @@ -99,6 +99,10 @@ 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, l.provider.LogFileName()) diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index b016693..5431e16 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -17,11 +17,11 @@ 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) 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 @@ -439,3 +439,14 @@ func TestLoop_SetRetryConfig(t *testing.T) { t.Errorf("Expected MaxRetries 5, got %d", l.retryConfig.MaxRetries) } } + +func TestLoop_RunRequiresProvider(t *testing.T) { + l := NewLoop("/test/prd.json", "test", 1, nil) + err := l.Run(context.Background()) + if err == nil { + t.Fatal("expected provider validation error") + } + if err.Error() != "loop provider is not configured" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/loop/manager.go b/internal/loop/manager.go index 83e90ac..bb6eec5 100644 --- a/internal/loop/manager.go +++ b/internal/loop/manager.go @@ -67,16 +67,16 @@ 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 - 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 + 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 onPostComplete func(prdName, branch, workDir string) // Callback for post-completion actions (push, PR) } @@ -210,6 +210,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() diff --git a/internal/loop/manager_test.go b/internal/loop/manager_test.go index e60df6c..f2f65fb 100644 --- a/internal/loop/manager_test.go +++ b/internal/loop/manager_test.go @@ -222,6 +222,24 @@ 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") From 4dcb3edc689be6830ab9c54e8bd488368fa865d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=89E?= <48557087+Simon-BEE@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:29:48 +0100 Subject: [PATCH 04/15] Remove --output-last-message flag; generalize error msg Remove the deprecated --output-last-message option from CodexProvider CLI invocations and simplify error messages in WaitWithSpinner and WaitWithPanel from "Claude failed" to the more generic "agent failed" for compatibility and clearer messaging. Changes in internal/agent/codex.go and internal/prd/generator.go. --- internal/agent/codex.go | 4 ++-- internal/prd/generator.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/agent/codex.go b/internal/agent/codex.go index 22e46dd..a3405eb 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -53,7 +53,7 @@ func (p *CodexProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop. } outPath := f.Name() f.Close() - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", outPath, "-") + 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 @@ -67,7 +67,7 @@ func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMod } outPath := f.Name() f.Close() - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "--output-last-message", "-o", outPath, "-") + cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "-o", outPath, "-") cmd.Stdin = strings.NewReader(prompt) return cmd, loop.OutputFromFile, outPath, nil } diff --git a/internal/prd/generator.go b/internal/prd/generator.go index 3b26467..691449e 100644 --- a/internal/prd/generator.go +++ b/internal/prd/generator.go @@ -451,7 +451,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: @@ -491,7 +491,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: From 451c475048a10f4044e0c71551a116c7f2a42e4b Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 06:03:39 +0000 Subject: [PATCH 05/15] initial draft of opencode support using opencode free models --- README.md | 10 +-- docs/guide/installation.md | 14 ++- docs/reference/configuration.md | 16 ++-- docs/troubleshooting/common-issues.md | 21 +++-- internal/agent/opencode.go | 55 ++++++++++++ internal/agent/opencode_test.go | 108 ++++++++++++++++++++++ internal/agent/resolve.go | 4 +- internal/agent/resolve_test.go | 29 ++++++ internal/config/config.go | 6 +- internal/loop/opencode_parser.go | 125 ++++++++++++++++++++++++++ internal/loop/opencode_parser_test.go | 90 +++++++++++++++++++ 11 files changed, 455 insertions(+), 23 deletions(-) create mode 100644 internal/agent/opencode.go create mode 100644 internal/agent/opencode_test.go create mode 100644 internal/loop/opencode_parser.go create mode 100644 internal/loop/opencode_parser_test.go diff --git a/README.md b/README.md index 76165bb..84a976d 100644 --- a/README.md +++ b/README.md @@ -44,17 +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)** or **[Codex CLI](https://developers.openai.com/codex/cli/reference)** 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 in `.chief/config.yaml`: +Use Claude by default, or configure Codex or OpenCode in `.chief/config.yaml`: ```yaml agent: - provider: codex - cliPath: /usr/local/bin/codex # optional + provider: opencode + cliPath: /usr/local/bin/opencode # optional ``` -Or run with `chief --agent codex` or set `CHIEF_AGENT=codex`. +Or run with `chief --agent opencode` or set `CHIEF_AGENT=opencode`. ## License diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 827d28b..c2bce13 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -8,7 +8,7 @@ Chief is distributed as a single binary with no runtime dependencies. Choose you ## Prerequisites -Chief needs an agent CLI: **Claude Code** (default) or **Codex**. Install at least one and authenticate. +Chief needs an agent CLI: **Claude Code** (default), **Codex**, or **OpenCode**. Install at least one and authenticate. ### Option A: Claude Code CLI (default) @@ -45,6 +45,18 @@ To use [OpenAI Codex CLI](https://developers.openai.com/codex/cli/reference) ins 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`) If you want Chief to automatically create pull requests when a PRD completes, install the [GitHub CLI](https://cli.github.com/): diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b50cc57..36d0786 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -14,7 +14,7 @@ Chief stores project-level settings in `.chief/config.yaml`. This file is create ```yaml agent: - provider: claude # or "codex" + provider: claude # or "codex" or "opencode" cliPath: "" # optional path to CLI binary worktree: setup: "npm install" @@ -27,8 +27,8 @@ onComplete: | Key | Type | Default | Description | |-----|------|---------|-------------| -| `agent.provider` | string | `"claude"` | Agent CLI to use: `claude` or `codex` | -| `agent.cliPath` | string | `""` | Optional path to the agent binary (e.g. `/usr/local/bin/codex`). If empty, Chief uses the provider name from PATH. | +| `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) | @@ -88,7 +88,7 @@ These settings are saved to `.chief/config.yaml` and can be changed at any time | Flag | Description | Default | |------|-------------|---------| -| `--agent ` | Agent CLI to use: `claude` or `codex` | From config / env / `claude` | +| `--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 agent crashes | `false` | @@ -102,11 +102,11 @@ When `--max-iterations` is not specified, Chief calculates a dynamic limit based ## Agent -Chief can use **Claude Code** (default) or **Codex CLI** as the agent. Choose via: +Chief can use **Claude Code** (default), **Codex CLI**, or **OpenCode CLI** as the agent. Choose via: -- **Config:** `agent.provider: codex` and optionally `agent.cliPath: /path/to/codex` in `.chief/config.yaml` -- **Environment:** `CHIEF_AGENT=codex`, `CHIEF_AGENT_PATH=/path/to/codex` -- **CLI:** `chief --agent codex --agent-path /path/to/codex` +- **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 diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index 0b6798c..79b56e2 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -8,7 +8,7 @@ Solutions to frequently encountered problems. ## Agent CLI Not Found -**Symptom:** Error that the agent CLI (Claude or Codex) is not found. +**Symptom:** Error that the agent CLI (Claude, Codex, or OpenCode) is not found. ``` Error: Claude CLI not found in PATH. Install it or set agent.cliPath in .chief/config.yaml @@ -17,6 +17,10 @@ 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:** The chosen agent CLI isn't installed or isn't in your PATH. @@ -33,6 +37,13 @@ Error: Codex CLI not found in PATH. Install it or set agent.cliPath in .chief/co 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 @@ -52,7 +63,7 @@ Chief automatically runs Claude with permission prompts disabled for autonomous **Solution:** -1. Check the agent log for errors (e.g. `claude.log` or `codex.log` in the PRD directory): +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 ``` @@ -76,7 +87,7 @@ Chief automatically runs Claude with permission prompts disabled for autonomous **Solution:** -1. Check the agent log (e.g. `claude.log` or `codex.log`) for what the agent 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 ``` @@ -107,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 the agent log (`claude.log` or `codex.log`) + - Stuck in a loop? Check the agent log (`claude.log`, `codex.log`, or `opencode.log`) - Unclear acceptance criteria? Clarify them ## "No PRD Found" @@ -249,4 +260,4 @@ If none of these solutions help: 3. Open a new issue with: - Chief version (`chief --version`) - Your `prd.json` (sanitized) - - Relevant agent log excerpts (e.g. `claude.log` or `codex.log`) + - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, or `opencode.log`) diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go new file mode 100644 index 0000000..ea23482 --- /dev/null +++ b/internal/agent/opencode.go @@ -0,0 +1,55 @@ +package agent + +import ( + "context" + "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) + 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" } diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go new file mode 100644 index 0000000..929ec2e --- /dev/null +++ b/internal/agent/opencode_test.go @@ -0,0 +1,108 @@ +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) + } +} + +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) + } +} + +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 index ff7ece9..c2649c1 100644 --- a/internal/agent/resolve.go +++ b/internal/agent/resolve.go @@ -37,8 +37,10 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, err 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\" or \"codex\"", providerName) + return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", or \"opencode\"", providerName) } } diff --git a/internal/agent/resolve_test.go b/internal/agent/resolve_test.go index b5b3f28..fa199f8 100644 --- a/internal/agent/resolve_test.go +++ b/internal/agent/resolve_test.go @@ -107,6 +107,35 @@ func TestResolve_normalize(t *testing.T) { } } +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 { diff --git a/internal/config/config.go b/internal/config/config.go index 6bbacc0..1c758e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,10 +16,10 @@ type Config struct { Agent AgentConfig `yaml:"agent"` } -// AgentConfig holds agent CLI settings (Claude vs Codex). +// AgentConfig holds agent CLI settings (Claude, Codex, or OpenCode). type AgentConfig struct { - Provider string `yaml:"provider"` // "claude" (default) | "codex" - CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary + 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/opencode_parser.go b/internal/loop/opencode_parser.go new file mode 100644 index 0000000..fad70f0 --- /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) + } + } +} From 130f90205789d50f3c206f290fdc833d650a33e6 Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 17:40:08 +0000 Subject: [PATCH 06/15] Point install.sh to tpaulshippy/chief releases --- install.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index d85d6f2..d49cdb3 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,12 @@ #!/bin/sh # Chief Install Script -# https://github.com/minicodemonkey/chief +# https://github.com/tpaulshippy/chief # # Usage: -# curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh # # Or with a specific version: -# curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 +# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 # # This script: # - Detects OS (darwin/linux) and architecture (amd64/arm64) @@ -33,7 +33,7 @@ else fi # Configuration -GITHUB_REPO="minicodemonkey/chief" +GITHUB_REPO="tpaulshippy/chief" BINARY_NAME="chief" VERSION="" @@ -215,8 +215,8 @@ parse_args() { Chief Install Script Usage: - curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh - curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 + curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 Options: --version, -v VERSION Install a specific version (e.g., v0.1.0) From a733414bfd3a837c393af26b1374c4e3483b3de6 Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 17:43:02 +0000 Subject: [PATCH 07/15] Point install.sh to feature/opencode-support-v2 branch --- install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index d49cdb3..1c76a58 100755 --- a/install.sh +++ b/install.sh @@ -3,10 +3,10 @@ # https://github.com/tpaulshippy/chief # # Usage: -# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh # # Or with a specific version: -# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 +# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh -s -- --version v0.1.0 # # This script: # - Detects OS (darwin/linux) and architecture (amd64/arm64) @@ -215,8 +215,8 @@ parse_args() { Chief Install Script Usage: - curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh - curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 + curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh -s -- --version v0.1.0 Options: --version, -v VERSION Install a specific version (e.g., v0.1.0) From 617ea599946dc1e3bf808aaa6287003c61cfb21e Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 17:47:06 +0000 Subject: [PATCH 08/15] Add opencode to --agent help text --- cmd/chief/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 136cfab..ee1fcd9 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -553,7 +553,7 @@ Commands: help Show this help message Global Options: - --agent Agent CLI to use: claude (default) or codex + --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 agent crashes From 55f09afc3dee77c8b1c3a819f1f9f75cfb64238e Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 17:57:16 +0000 Subject: [PATCH 09/15] Fix OpenCode interactive command to use --prompt flag --- internal/agent/opencode.go | 2 +- internal/agent/opencode_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go index ea23482..2f9ce6d 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -30,7 +30,7 @@ func (p *OpenCodeProvider) LoopCommand(ctx context.Context, prompt, workDir stri } func (p *OpenCodeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { - cmd := exec.Command(p.cliPath) + cmd := exec.Command(p.cliPath, "--prompt", prompt) cmd.Dir = workDir return cmd } diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go index 929ec2e..75db98a 100644 --- a/internal/agent/opencode_test.go +++ b/internal/agent/opencode_test.go @@ -86,6 +86,9 @@ func TestOpenCodeProvider_FixJSONCommand(t *testing.T) { 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) { @@ -94,6 +97,9 @@ func TestOpenCodeProvider_InteractiveCommand(t *testing.T) { 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) { From 1712bec79e7d4806cfc6baad086360afb268e421 Mon Sep 17 00:00:00 2001 From: Assistant Date: Wed, 4 Mar 2026 18:03:53 +0000 Subject: [PATCH 10/15] Disable homebrew in release build --- .goreleaser.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 280cc57..fbf3411 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -81,6 +81,7 @@ release: # Homebrew tap configuration brews: - name: chief + enabled: false # Repository to push the formula to repository: owner: minicodemonkey From 479db7ac3edf2b349bc0cc348a31e50c4ef22819 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Wed, 4 Mar 2026 19:30:09 +0000 Subject: [PATCH 11/15] Fix opencode PRD conversion by extracting JSON from NDJSON output --- internal/agent/claude.go | 3 +++ internal/agent/codex.go | 3 +++ internal/agent/opencode.go | 30 ++++++++++++++++++++++++++++++ internal/cmd/new.go | 12 ++++++++++-- internal/loop/provider.go | 3 +++ internal/prd/generator.go | 18 +++++++++--------- internal/prd/generator_test.go | 6 +++--- 7 files changed, 61 insertions(+), 14 deletions(-) diff --git a/internal/agent/claude.go b/internal/agent/claude.go index c80ad86..bd25cb9 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -68,3 +68,6 @@ func (p *ClaudeProvider) ParseLine(line string) *loop.Event { // 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 index a3405eb..cbd7482 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -79,3 +79,6 @@ func (p *CodexProvider) ParseLine(line string) *loop.Event { // 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/opencode.go b/internal/agent/opencode.go index 2f9ce6d..77f1795 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "os/exec" "strings" @@ -53,3 +54,32 @@ func (p *OpenCodeProvider) ParseLine(line string) *loop.Event { } 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/cmd/new.go b/internal/cmd/new.go index f7144ef..8354d4e 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -120,10 +120,18 @@ func RunConvertWithOptions(opts ConvertOptions) error { Merge: opts.Merge, Force: opts.Force, RunConversion: func(absPRDDir string) (string, error) { - return runConversionWithProvider(provider, absPRDDir) + raw, err := runConversionWithProvider(provider, absPRDDir) + if err != nil { + return "", err + } + return provider.CleanOutput(raw), nil }, RunFixJSON: func(prompt string) (string, error) { - return runFixJSONWithProvider(provider, prompt) + raw, err := runFixJSONWithProvider(provider, prompt) + if err != nil { + return "", err + } + return provider.CleanOutput(raw), nil }, }) } diff --git a/internal/loop/provider.go b/internal/loop/provider.go index 8e89727..2fc0d75 100644 --- a/internal/loop/provider.go +++ b/internal/loop/provider.go @@ -24,6 +24,9 @@ type Provider interface { 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 691449e..c3a0f1d 100644 --- a/internal/prd/generator.go +++ b/internal/prd/generator.go @@ -54,9 +54,9 @@ var waitingJokes = []string{ // ConvertOptions contains configuration for PRD conversion. type ConvertOptions struct { - PRDDir string // Directory containing prd.md - Merge bool // Auto-merge progress on conversion conflicts - Force bool // Auto-overwrite on conversion conflicts + 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 string) (string, error) // RunFixJSON runs the agent to fix invalid JSON. Required. @@ -117,7 +117,7 @@ func Convert(opts ConvertOptions) error { } // Clean up output (strip markdown fences if any) - cleanedJSON := cleanJSONOutput(rawJSON) + cleanedJSON := stripMarkdownFences(rawJSON) // Parse and validate newPRD, err := parseAndValidatePRD(cleanedJSON) @@ -130,7 +130,7 @@ func Convert(opts ConvertOptions) error { 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) @@ -555,9 +555,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 @@ -573,7 +573,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..163c063 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) } }) } From 0be136be11fe9b06d02e2a6f7740cd62b98c4528 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Wed, 4 Mar 2026 19:34:12 +0000 Subject: [PATCH 12/15] Disable homebrew in release build --- .goreleaser.yaml | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fbf3411..23982eb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -78,24 +78,20 @@ release: prerelease: auto name_template: "Chief v{{.Version}}" -# Homebrew tap configuration -brews: - - name: chief - enabled: false - # Repository to push the formula to - repository: - owner: minicodemonkey - name: homebrew-chief - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - directory: Formula - homepage: "https://minicodemonkey.github.io/chief/" - description: "Autonomous agent loop for working through PRDs with Claude Code" - license: "MIT" - # Custom install script - install: | - bin.install "chief" - # Test block - test: | - assert_match "chief", shell_output("#{bin}/chief --version") - # Skip upload on snapshot builds - skip_upload: auto +# Homebrew tap configuration (disabled for now) +# To enable: uncomment and configure repository token +# brews: +# - name: chief +# repository: +# owner: minicodemonkey +# name: homebrew-chief +# token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" +# directory: Formula +# homepage: "https://minicodemonkey.github.io/chief/" +# description: "Autonomous agent loop for working through PRDs with Claude Code" +# license: "MIT" +# install: | +# bin.install "chief" +# test: | +# assert_match "chief", shell_output("#{bin}/chief --version") +# skip_upload: auto From 5c4f1023a2b3b871074ef958eb942ffe02f90303 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Wed, 4 Mar 2026 19:39:57 +0000 Subject: [PATCH 13/15] Update install script URL in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 84a976d..b384fd8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ brew install minicodemonkey/chief/chief Or via install script: ```bash -curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh ``` ## Usage From b9cb4d379eca5fab2323b868cad37d30af07a56a Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Fri, 6 Mar 2026 12:45:44 +0000 Subject: [PATCH 14/15] Fix merge conflicts and format code --- internal/cmd/convert.go | 6 +-- internal/cmd/new.go | 2 +- internal/config/config.go | 4 +- internal/loop/loop_test.go | 81 +++++++++++++++++++++++--------- internal/loop/opencode_parser.go | 46 +++++++++--------- internal/prd/generator.go | 2 +- internal/prd/generator_test.go | 8 ++-- internal/prd/watcher.go | 14 +++--- internal/tui/app.go | 76 +++++++++++++++--------------- internal/tui/branch_warning.go | 8 ++-- internal/tui/completion.go | 10 ++-- internal/tui/confetti.go | 12 ++--- internal/tui/dashboard_test.go | 6 +-- internal/tui/diff.go | 21 ++++----- internal/tui/log_perf_test.go | 6 +-- internal/tui/picker.go | 32 ++++++------- internal/tui/settings.go | 14 +++--- internal/tui/styles.go | 4 +- internal/update/update.go | 4 +- 19 files changed, 195 insertions(+), 161 deletions(-) diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index c005af8..e25fcc0 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -13,11 +13,7 @@ import ( // runConversionWithProvider runs the agent to convert prd.md to JSON. func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string, error) { - content, err := os.ReadFile(filepath.Join(absPRDDir, "prd.md")) - if err != nil { - return "", fmt.Errorf("failed to read prd.md: %w", err) - } - prompt := embed.GetConvertPrompt(string(content)) + 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) diff --git a/internal/cmd/new.go b/internal/cmd/new.go index 6b4b83d..bd01446 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -121,7 +121,7 @@ func RunConvertWithOptions(opts ConvertOptions) error { PRDDir: opts.PRDDir, Merge: opts.Merge, Force: opts.Force, - RunConversion: func(absPRDDir string) (string, error) { + RunConversion: func(absPRDDir, idPrefix string) (string, error) { raw, err := runConversionWithProvider(provider, absPRDDir) if err != nil { return "", err diff --git a/internal/config/config.go b/internal/config/config.go index 1c758e3..6bad1f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,13 +13,13 @@ const configFile = ".chief/config.yaml" type Config struct { Worktree WorktreeConfig `yaml:"worktree"` OnComplete OnCompleteConfig `yaml:"onComplete"` - Agent AgentConfig `yaml:"agent"` + 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 + CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary } // WorktreeConfig holds worktree-related settings. 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/opencode_parser.go b/internal/loop/opencode_parser.go index fad70f0..ca0c861 100644 --- a/internal/loop/opencode_parser.go +++ b/internal/loop/opencode_parser.go @@ -7,32 +7,32 @@ import ( ) type opencodeEvent struct { - Type string `json:"type"` - Timestamp int64 `json:"timestamp"` - SessionID string `json:"sessionID"` + 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"` + 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"` + 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 { @@ -41,10 +41,10 @@ type opencodeTime struct { } type opencodeTokens struct { - Input int `json:"input"` - Output int `json:"output"` - Reasoning int `json:"reasoning"` - Cache *opencodeCacheTokens `json:"cache,omitempty"` + Input int `json:"input"` + Output int `json:"output"` + Reasoning int `json:"reasoning"` + Cache *opencodeCacheTokens `json:"cache,omitempty"` } type opencodeCacheTokens struct { @@ -53,7 +53,7 @@ type opencodeCacheTokens struct { } type opencodeError struct { - Name string `json:"name"` + Name string `json:"name"` Data *opencodeErrorData `json:"data,omitempty"` } diff --git a/internal/prd/generator.go b/internal/prd/generator.go index aa96061..ec77a6c 100644 --- a/internal/prd/generator.go +++ b/internal/prd/generator.go @@ -58,7 +58,7 @@ type ConvertOptions struct { 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 string) (string, error) + RunConversion func(absPRDDir, idPrefix string) (string, error) // RunFixJSON runs the agent to fix invalid JSON. Required. RunFixJSON func(prompt string) (string, error) } diff --git a/internal/prd/generator_test.go b/internal/prd/generator_test.go index 163c063..dd7b0fd 100644 --- a/internal/prd/generator_test.go +++ b/internal/prd/generator_test.go @@ -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 5e0c49c..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,17 +151,17 @@ 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 @@ -198,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 @@ -209,8 +209,8 @@ type App struct { completionScreen *CompletionScreen // Story timing tracking - storyTimings []StoryTiming - currentStoryID string + storyTimings []StoryTiming + currentStoryID string currentStoryStart time.Time // Settings overlay @@ -317,31 +317,31 @@ func NewAppWithOptions(prdPath string, maxIter int, provider loop.Provider) (*Ap picker := NewPRDPicker(baseDir, prdName, manager) return &App{ - 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, + 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_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/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 } From bde875d4b83e465eb2539427bc19bb44630dc1a3 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Fri, 6 Mar 2026 12:56:36 +0000 Subject: [PATCH 15/15] Revert URLs to point to main repo --- README.md | 2 +- install.sh | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b384fd8..84a976d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ brew install minicodemonkey/chief/chief Or via install script: ```bash -curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh ``` ## Usage diff --git a/install.sh b/install.sh index 1c76a58..d85d6f2 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,12 @@ #!/bin/sh # Chief Install Script -# https://github.com/tpaulshippy/chief +# https://github.com/minicodemonkey/chief # # Usage: -# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh # # Or with a specific version: -# curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh -s -- --version v0.1.0 +# curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 # # This script: # - Detects OS (darwin/linux) and architecture (amd64/arm64) @@ -33,7 +33,7 @@ else fi # Configuration -GITHUB_REPO="tpaulshippy/chief" +GITHUB_REPO="minicodemonkey/chief" BINARY_NAME="chief" VERSION="" @@ -215,8 +215,8 @@ parse_args() { Chief Install Script Usage: - curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh - curl -fsSL https://raw.githubusercontent.com/tpaulshippy/chief/refs/heads/feature/opencode-support-v2/install.sh | sh -s -- --version v0.1.0 + curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/MiniCodeMonkey/chief/refs/heads/main/install.sh | sh -s -- --version v0.1.0 Options: --version, -v VERSION Install a specific version (e.g., v0.1.0)