Skip to content

Commit 31cfeac

Browse files
refactor(agent): deduplicate flag parsing and convert helpers, improve test coverage
- Extract parseAgentFlags() in main.go to eliminate triple-duplicated --agent/--agent-path parsing across parseTUIFlags, runNew, and runEdit - Extract runAgentCommand() in convert.go to share stdout/file output handling between runConversionWithProvider and runFixJSONWithProvider - Improve OpenCode CleanOutput to use JSON parsing instead of fragile string matching heuristics - Add Claude provider tests (claude_test.go) covering all Provider methods - Add OpenCode CleanOutput tests for NDJSON extraction and edge cases - Add OpenCode parser tests for tool_use start/no-state/completed events - Update troubleshooting docs to clarify provider-specific log file names
1 parent f10f14c commit 31cfeac

6 files changed

Lines changed: 315 additions & 131 deletions

File tree

cmd/chief/main.go

Lines changed: 53 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,40 @@ func listAvailablePRDs() []string {
122122
return names
123123
}
124124

125+
// parseAgentFlags extracts --agent and --agent-path from args[startIdx:],
126+
// returning the agent name, agent path, remaining args (with agent flags removed),
127+
// and the updated index offsets. It exits on missing values.
128+
func parseAgentFlags(args []string, startIdx int) (agentName, agentPath string, remaining []string) {
129+
for i := startIdx; i < len(args); i++ {
130+
arg := args[i]
131+
switch {
132+
case arg == "--agent":
133+
if i+1 < len(args) {
134+
i++
135+
agentName = args[i]
136+
} else {
137+
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, or opencode)\n")
138+
os.Exit(1)
139+
}
140+
case strings.HasPrefix(arg, "--agent="):
141+
agentName = strings.TrimPrefix(arg, "--agent=")
142+
case arg == "--agent-path":
143+
if i+1 < len(args) {
144+
i++
145+
agentPath = args[i]
146+
} else {
147+
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
148+
os.Exit(1)
149+
}
150+
case strings.HasPrefix(arg, "--agent-path="):
151+
agentPath = strings.TrimPrefix(arg, "--agent-path=")
152+
default:
153+
remaining = append(remaining, arg)
154+
}
155+
}
156+
return
157+
}
158+
125159
// parseTUIFlags parses command-line flags for TUI mode
126160
func parseTUIFlags() *TUIOptions {
127161
opts := &TUIOptions{
@@ -133,6 +167,9 @@ func parseTUIFlags() *TUIOptions {
133167
NoRetry: false,
134168
}
135169

170+
// Pre-extract agent flags so they don't interfere with positional arg parsing
171+
opts.Agent, opts.AgentPath, _ = parseAgentFlags(os.Args, 1)
172+
136173
for i := 1; i < len(os.Args); i++ {
137174
arg := os.Args[i]
138175

@@ -151,26 +188,10 @@ func parseTUIFlags() *TUIOptions {
151188
opts.Force = true
152189
case arg == "--no-retry":
153190
opts.NoRetry = true
154-
case arg == "--agent":
155-
if i+1 < len(os.Args) {
156-
i++
157-
opts.Agent = os.Args[i]
158-
} else {
159-
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n")
160-
os.Exit(1)
161-
}
162-
case strings.HasPrefix(arg, "--agent="):
163-
opts.Agent = strings.TrimPrefix(arg, "--agent=")
164-
case arg == "--agent-path":
165-
if i+1 < len(os.Args) {
166-
i++
167-
opts.AgentPath = os.Args[i]
168-
} else {
169-
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
170-
os.Exit(1)
171-
}
172-
case strings.HasPrefix(arg, "--agent-path="):
173-
opts.AgentPath = strings.TrimPrefix(arg, "--agent-path=")
191+
case arg == "--agent" || arg == "--agent-path":
192+
i++ // skip value (already parsed by parseAgentFlags)
193+
case strings.HasPrefix(arg, "--agent=") || strings.HasPrefix(arg, "--agent-path="):
194+
// already parsed by parseAgentFlags
174195
case arg == "--max-iterations" || arg == "-n":
175196
// Next argument should be the number
176197
if i+1 < len(os.Args) {
@@ -234,44 +255,21 @@ func parseTUIFlags() *TUIOptions {
234255

235256
func runNew() {
236257
opts := cmd.NewOptions{}
237-
flagAgent, flagPath := "", ""
238-
var positional []string
239258

240259
// Parse arguments: chief new [name] [context...] [--agent X] [--agent-path X]
241-
for i := 2; i < len(os.Args); i++ {
242-
arg := os.Args[i]
243-
switch {
244-
case arg == "--agent":
245-
if i+1 < len(os.Args) {
246-
i++
247-
flagAgent = os.Args[i]
248-
} else {
249-
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n")
250-
os.Exit(1)
251-
}
252-
case strings.HasPrefix(arg, "--agent="):
253-
flagAgent = strings.TrimPrefix(arg, "--agent=")
254-
case arg == "--agent-path":
255-
if i+1 < len(os.Args) {
256-
i++
257-
flagPath = os.Args[i]
258-
} else {
259-
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
260-
os.Exit(1)
261-
}
262-
case strings.HasPrefix(arg, "--agent-path="):
263-
flagPath = strings.TrimPrefix(arg, "--agent-path=")
264-
case strings.HasPrefix(arg, "-"):
265-
// skip unknown flags
266-
default:
267-
positional = append(positional, arg)
260+
flagAgent, flagPath, positional := parseAgentFlags(os.Args, 2)
261+
// Filter out remaining flags, keep only positional args
262+
var args []string
263+
for _, a := range positional {
264+
if !strings.HasPrefix(a, "-") {
265+
args = append(args, a)
268266
}
269267
}
270-
if len(positional) > 0 {
271-
opts.Name = positional[0]
268+
if len(args) > 0 {
269+
opts.Name = args[0]
272270
}
273-
if len(positional) > 1 {
274-
opts.Context = strings.Join(positional[1:], " ")
271+
if len(args) > 1 {
272+
opts.Context = strings.Join(args[1:], " ")
275273
}
276274

277275
opts.Provider = resolveProvider(flagAgent, flagPath)
@@ -283,36 +281,15 @@ func runNew() {
283281

284282
func runEdit() {
285283
opts := cmd.EditOptions{}
286-
flagAgent, flagPath := "", ""
287284

288285
// Parse arguments: chief edit [name] [--merge] [--force] [--agent X] [--agent-path X]
289-
for i := 2; i < len(os.Args); i++ {
290-
arg := os.Args[i]
286+
flagAgent, flagPath, remaining := parseAgentFlags(os.Args, 2)
287+
for _, arg := range remaining {
291288
switch {
292289
case arg == "--merge":
293290
opts.Merge = true
294291
case arg == "--force":
295292
opts.Force = true
296-
case arg == "--agent":
297-
if i+1 < len(os.Args) {
298-
i++
299-
flagAgent = os.Args[i]
300-
} else {
301-
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n")
302-
os.Exit(1)
303-
}
304-
case strings.HasPrefix(arg, "--agent="):
305-
flagAgent = strings.TrimPrefix(arg, "--agent=")
306-
case arg == "--agent-path":
307-
if i+1 < len(os.Args) {
308-
i++
309-
flagPath = os.Args[i]
310-
} else {
311-
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
312-
os.Exit(1)
313-
}
314-
case strings.HasPrefix(arg, "--agent-path="):
315-
flagPath = strings.TrimPrefix(arg, "--agent-path=")
316293
default:
317294
if opts.Name == "" && !strings.HasPrefix(arg, "-") {
318295
opts.Name = arg

docs/troubleshooting/common-issues.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ Chief automatically runs Claude with permission prompts disabled for autonomous
5757

5858
## PRD Not Updating
5959

60-
**Symptom:** Stories stay incomplete even though Claude seems to finish.
60+
**Symptom:** Stories stay incomplete even though the agent seems to finish.
6161

62-
**Cause:** Claude didn't output the completion signal, or file watching failed.
62+
**Cause:** The agent didn't output the completion signal, or file watching failed.
6363

6464
**Solution:**
6565

66-
1. Check the agent log for errors (e.g. `claude.log`, `codex.log`, or `opencode.log` in the PRD directory):
66+
1. Check the agent log for errors (the log file matches your agent: `claude.log`, `codex.log`, or `opencode.log`):
6767
```bash
68-
tail -100 .chief/prds/your-prd/claude.log
68+
tail -100 .chief/prds/your-prd/claude.log # or codex.log / opencode.log
6969
```
7070

7171
2. Manually mark story complete if appropriate:
@@ -83,13 +83,13 @@ Chief automatically runs Claude with permission prompts disabled for autonomous
8383

8484
**Symptom:** Chief runs but doesn't make progress on stories.
8585

86-
**Cause:** Various—Claude may be stuck, context too large, or PRD unclear.
86+
**Cause:** Various—the agent may be stuck, context too large, or PRD unclear.
8787

8888
**Solution:**
8989

90-
1. Check the agent log (e.g. `claude.log`, `codex.log`, or `opencode.log`) for what the agent is doing:
90+
1. Check the agent log for what the agent is doing:
9191
```bash
92-
tail -f .chief/prds/your-prd/claude.log
92+
tail -f .chief/prds/your-prd/claude.log # or codex.log / opencode.log
9393
```
9494

9595
2. Simplify the current story's acceptance criteria

internal/agent/claude_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/minicodemonkey/chief/internal/loop"
8+
)
9+
10+
func TestClaudeProvider_Name(t *testing.T) {
11+
p := NewClaudeProvider("")
12+
if p.Name() != "Claude" {
13+
t.Errorf("Name() = %q, want Claude", p.Name())
14+
}
15+
}
16+
17+
func TestClaudeProvider_CLIPath(t *testing.T) {
18+
p := NewClaudeProvider("")
19+
if p.CLIPath() != "claude" {
20+
t.Errorf("CLIPath() empty arg = %q, want claude", p.CLIPath())
21+
}
22+
p2 := NewClaudeProvider("/usr/local/bin/claude")
23+
if p2.CLIPath() != "/usr/local/bin/claude" {
24+
t.Errorf("CLIPath() custom = %q, want /usr/local/bin/claude", p2.CLIPath())
25+
}
26+
}
27+
28+
func TestClaudeProvider_LogFileName(t *testing.T) {
29+
p := NewClaudeProvider("")
30+
if p.LogFileName() != "claude.log" {
31+
t.Errorf("LogFileName() = %q, want claude.log", p.LogFileName())
32+
}
33+
}
34+
35+
func TestClaudeProvider_LoopCommand(t *testing.T) {
36+
ctx := context.Background()
37+
p := NewClaudeProvider("/bin/claude")
38+
cmd := p.LoopCommand(ctx, "hello world", "/work/dir")
39+
40+
if cmd.Path != "/bin/claude" {
41+
t.Errorf("LoopCommand Path = %q, want /bin/claude", cmd.Path)
42+
}
43+
wantArgs := []string{"/bin/claude", "--dangerously-skip-permissions", "-p", "hello world", "--output-format", "stream-json", "--verbose"}
44+
if len(cmd.Args) != len(wantArgs) {
45+
t.Fatalf("LoopCommand Args len = %d, want %d: %v", len(cmd.Args), len(wantArgs), cmd.Args)
46+
}
47+
for i, w := range wantArgs {
48+
if cmd.Args[i] != w {
49+
t.Errorf("LoopCommand Args[%d] = %q, want %q", i, cmd.Args[i], w)
50+
}
51+
}
52+
if cmd.Dir != "/work/dir" {
53+
t.Errorf("LoopCommand Dir = %q, want /work/dir", cmd.Dir)
54+
}
55+
}
56+
57+
func TestClaudeProvider_ConvertCommand(t *testing.T) {
58+
p := NewClaudeProvider("/bin/claude")
59+
cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt")
60+
if err != nil {
61+
t.Fatalf("ConvertCommand unexpected error: %v", err)
62+
}
63+
if mode != loop.OutputStdout {
64+
t.Errorf("ConvertCommand mode = %v, want OutputStdout", mode)
65+
}
66+
if outPath != "" {
67+
t.Errorf("ConvertCommand outPath = %q, want empty string", outPath)
68+
}
69+
if cmd.Dir != "/prd/dir" {
70+
t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir)
71+
}
72+
// Should use -p flag with stdin
73+
wantArgs := []string{"/bin/claude", "-p"}
74+
if len(cmd.Args) != len(wantArgs) {
75+
t.Fatalf("ConvertCommand Args = %v, want %v", cmd.Args, wantArgs)
76+
}
77+
for i, w := range wantArgs {
78+
if cmd.Args[i] != w {
79+
t.Errorf("ConvertCommand Args[%d] = %q, want %q", i, cmd.Args[i], w)
80+
}
81+
}
82+
if cmd.Stdin == nil {
83+
t.Error("ConvertCommand Stdin must be set (prompt via stdin)")
84+
}
85+
}
86+
87+
func TestClaudeProvider_FixJSONCommand(t *testing.T) {
88+
p := NewClaudeProvider("/bin/claude")
89+
cmd, mode, outPath, err := p.FixJSONCommand("fix prompt")
90+
if err != nil {
91+
t.Fatalf("FixJSONCommand unexpected error: %v", err)
92+
}
93+
if mode != loop.OutputStdout {
94+
t.Errorf("FixJSONCommand mode = %v, want OutputStdout", mode)
95+
}
96+
if outPath != "" {
97+
t.Errorf("FixJSONCommand outPath = %q, want empty string", outPath)
98+
}
99+
// Should pass prompt as arg to -p
100+
wantArgs := []string{"/bin/claude", "-p", "fix prompt"}
101+
if len(cmd.Args) != len(wantArgs) {
102+
t.Fatalf("FixJSONCommand Args = %v, want %v", cmd.Args, wantArgs)
103+
}
104+
}
105+
106+
func TestClaudeProvider_InteractiveCommand(t *testing.T) {
107+
p := NewClaudeProvider("/bin/claude")
108+
cmd := p.InteractiveCommand("/work", "my prompt")
109+
if cmd.Dir != "/work" {
110+
t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir)
111+
}
112+
if len(cmd.Args) != 2 || cmd.Args[0] != "/bin/claude" || cmd.Args[1] != "my prompt" {
113+
t.Errorf("InteractiveCommand Args = %v, want [/bin/claude my prompt]", cmd.Args)
114+
}
115+
}
116+
117+
func TestClaudeProvider_ParseLine(t *testing.T) {
118+
p := NewClaudeProvider("")
119+
// Valid assistant text event
120+
line := `{"type":"assistant","message":{"type":"assistant","content":[{"type":"text","text":"hello"}]}}`
121+
e := p.ParseLine(line)
122+
if e == nil {
123+
t.Fatal("ParseLine(assistant text) returned nil")
124+
}
125+
if e.Type != loop.EventAssistantText {
126+
t.Errorf("ParseLine(assistant text) Type = %v, want EventAssistantText", e.Type)
127+
}
128+
}
129+
130+
func TestClaudeProvider_CleanOutput(t *testing.T) {
131+
p := NewClaudeProvider("")
132+
input := "some output"
133+
if p.CleanOutput(input) != input {
134+
t.Errorf("CleanOutput should return input unchanged")
135+
}
136+
}

0 commit comments

Comments
 (0)