Skip to content

Commit 95d9e86

Browse files
committed
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.
1 parent 8313c65 commit 95d9e86

10 files changed

Lines changed: 143 additions & 28 deletions

File tree

cmd/chief/main.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,19 @@ func parseTUIFlags() *TUIOptions {
155155
if i+1 < len(os.Args) {
156156
i++
157157
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)
158161
}
159162
case strings.HasPrefix(arg, "--agent="):
160163
opts.Agent = strings.TrimPrefix(arg, "--agent=")
161164
case arg == "--agent-path":
162165
if i+1 < len(os.Args) {
163166
i++
164167
opts.AgentPath = os.Args[i]
168+
} else {
169+
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
170+
os.Exit(1)
165171
}
166172
case strings.HasPrefix(arg, "--agent-path="):
167173
opts.AgentPath = strings.TrimPrefix(arg, "--agent-path=")
@@ -239,13 +245,19 @@ func runNew() {
239245
if i+1 < len(os.Args) {
240246
i++
241247
flagAgent = os.Args[i]
248+
} else {
249+
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n")
250+
os.Exit(1)
242251
}
243252
case strings.HasPrefix(arg, "--agent="):
244253
flagAgent = strings.TrimPrefix(arg, "--agent=")
245254
case arg == "--agent-path":
246255
if i+1 < len(os.Args) {
247256
i++
248257
flagPath = os.Args[i]
258+
} else {
259+
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
260+
os.Exit(1)
249261
}
250262
case strings.HasPrefix(arg, "--agent-path="):
251263
flagPath = strings.TrimPrefix(arg, "--agent-path=")
@@ -285,13 +297,19 @@ func runEdit() {
285297
if i+1 < len(os.Args) {
286298
i++
287299
flagAgent = os.Args[i]
300+
} else {
301+
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude or codex)\n")
302+
os.Exit(1)
288303
}
289304
case strings.HasPrefix(arg, "--agent="):
290305
flagAgent = strings.TrimPrefix(arg, "--agent=")
291306
case arg == "--agent-path":
292307
if i+1 < len(os.Args) {
293308
i++
294309
flagPath = os.Args[i]
310+
} else {
311+
fmt.Fprintf(os.Stderr, "Error: --agent-path requires a value\n")
312+
os.Exit(1)
295313
}
296314
case strings.HasPrefix(arg, "--agent-path="):
297315
flagPath = strings.TrimPrefix(arg, "--agent-path=")
@@ -348,7 +366,11 @@ func resolveProvider(flagAgent, flagPath string) loop.Provider {
348366
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
349367
os.Exit(1)
350368
}
351-
cfg, _ := config.Load(cwd)
369+
cfg, err := config.Load(cwd)
370+
if err != nil {
371+
fmt.Fprintf(os.Stderr, "Error: failed to load .chief/config.yaml: %v\n", err)
372+
os.Exit(1)
373+
}
352374
provider, err := agent.Resolve(flagAgent, flagPath, cfg)
353375
if err != nil {
354376
fmt.Fprintf(os.Stderr, "Error: %v\n", err)

internal/cmd/convert.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string
3232
cmd.Stderr = &stderr
3333

3434
if err := cmd.Start(); err != nil {
35+
if outPath != "" {
36+
_ = os.Remove(outPath)
37+
}
3538
return "", fmt.Errorf("failed to start %s: %w", provider.Name(), err)
3639
}
3740
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
6770
cmd.Stderr = &stderr
6871

6972
if err := cmd.Start(); err != nil {
73+
if outPath != "" {
74+
_ = os.Remove(outPath)
75+
}
7076
return "", fmt.Errorf("failed to start %s: %w", provider.Name(), err)
7177
}
7278
if err := prd.WaitWithSpinner(cmd, "Fixing JSON", "Fixing prd.json...", &stderr); err != nil {

internal/cmd/edit.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import (
1111

1212
// EditOptions contains configuration for the edit command.
1313
type EditOptions struct {
14-
Name string // PRD name (default: "main")
15-
BaseDir string // Base directory for .chief/prds/ (default: current directory)
16-
Merge bool // Auto-merge without prompting on conversion conflicts
17-
Force bool // Auto-overwrite without prompting on conversion conflicts
14+
Name string // PRD name (default: "main")
15+
BaseDir string // Base directory for .chief/prds/ (default: current directory)
16+
Merge bool // Auto-merge without prompting on conversion conflicts
17+
Force bool // Auto-overwrite without prompting on conversion conflicts
1818
Provider loop.Provider // Agent CLI provider (Claude or Codex)
1919
}
2020

@@ -48,6 +48,9 @@ func RunEdit(opts EditOptions) error {
4848

4949
// Get the edit prompt with the PRD directory path
5050
prompt := embed.GetEditPrompt(prdDir)
51+
if opts.Provider == nil {
52+
return fmt.Errorf("edit command requires Provider to be set")
53+
}
5154

5255
// Launch interactive agent session
5356
fmt.Printf("Editing PRD at %s...\n", prdDir)

internal/cmd/edit_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,31 @@ func TestEditOptionsDefaults(t *testing.T) {
120120
}
121121
}
122122

123+
func TestRunEditRequiresProvider(t *testing.T) {
124+
tmpDir := t.TempDir()
125+
prdDir := filepath.Join(tmpDir, ".chief", "prds", "main")
126+
if err := os.MkdirAll(prdDir, 0755); err != nil {
127+
t.Fatalf("Failed to create directory: %v", err)
128+
}
129+
prdMdPath := filepath.Join(prdDir, "prd.md")
130+
if err := os.WriteFile(prdMdPath, []byte("# Main PRD"), 0644); err != nil {
131+
t.Fatalf("Failed to create prd.md: %v", err)
132+
}
133+
134+
opts := EditOptions{
135+
Name: "main",
136+
BaseDir: tmpDir,
137+
}
138+
139+
err := RunEdit(opts)
140+
if err == nil {
141+
t.Fatal("expected provider validation error")
142+
}
143+
if !contains(err.Error(), "Provider") {
144+
t.Fatalf("expected error to mention Provider, got: %v", err)
145+
}
146+
}
147+
123148
// Helper function to check if a string contains a substring
124149
func contains(s, substr string) bool {
125150
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))

internal/cmd/new.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import (
1515

1616
// NewOptions contains configuration for the new command.
1717
type NewOptions struct {
18-
Name string // PRD name (default: "main")
19-
Context string // Optional context to pass to the agent
20-
BaseDir string // Base directory for .chief/prds/ (default: current directory)
18+
Name string // PRD name (default: "main")
19+
Context string // Optional context to pass to the agent
20+
BaseDir string // Base directory for .chief/prds/ (default: current directory)
2121
Provider loop.Provider // Agent CLI provider (Claude or Codex)
2222
}
2323

@@ -54,6 +54,9 @@ func RunNew(opts NewOptions) error {
5454

5555
// Get the init prompt with the PRD directory path
5656
prompt := embed.GetInitPrompt(prdDir, opts.Context)
57+
if opts.Provider == nil {
58+
return fmt.Errorf("new command requires Provider to be set")
59+
}
5760

5861
// Launch interactive agent session
5962
fmt.Printf("Creating PRD in %s...\n", prdDir)
@@ -83,6 +86,9 @@ func RunNew(opts NewOptions) error {
8386

8487
// runInteractiveAgent launches an interactive agent session in the specified directory.
8588
func runInteractiveAgent(provider loop.Provider, workDir, prompt string) error {
89+
if provider == nil {
90+
return fmt.Errorf("interactive agent requires Provider to be set")
91+
}
8692
cmd := provider.InteractiveCommand(workDir, prompt)
8793
cmd.Stdin = os.Stdin
8894
cmd.Stdout = os.Stdout
@@ -92,9 +98,9 @@ func runInteractiveAgent(provider loop.Provider, workDir, prompt string) error {
9298

9399
// ConvertOptions contains configuration for the conversion command.
94100
type ConvertOptions struct {
95-
PRDDir string // PRD directory containing prd.md
96-
Merge bool // Auto-merge without prompting on conversion conflicts
97-
Force bool // Auto-overwrite without prompting on conversion conflicts
101+
PRDDir string // PRD directory containing prd.md
102+
Merge bool // Auto-merge without prompting on conversion conflicts
103+
Force bool // Auto-overwrite without prompting on conversion conflicts
98104
Provider loop.Provider // Agent CLI provider for conversion
99105
}
100106

internal/cmd/new_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -103,3 +104,18 @@ func TestRunNewRejectsExistingPRD(t *testing.T) {
103104
t.Error("Expected error for existing PRD")
104105
}
105106
}
107+
108+
func TestRunNewRequiresProvider(t *testing.T) {
109+
opts := NewOptions{
110+
Name: "main",
111+
BaseDir: t.TempDir(),
112+
}
113+
114+
err := RunNew(opts)
115+
if err == nil {
116+
t.Fatal("expected provider validation error")
117+
}
118+
if !strings.Contains(err.Error(), "Provider") {
119+
t.Fatalf("expected error to mention Provider, got: %v", err)
120+
}
121+
}

internal/loop/loop.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import (
2121

2222
// RetryConfig configures automatic retry behavior on Claude crashes.
2323
type RetryConfig struct {
24-
MaxRetries int // Maximum number of retry attempts (default: 3)
24+
MaxRetries int // Maximum number of retry attempts (default: 3)
2525
RetryDelays []time.Duration // Delays between retries (default: 0s, 5s, 15s)
26-
Enabled bool // Whether retry is enabled (default: true)
26+
Enabled bool // Whether retry is enabled (default: true)
2727
}
2828

2929
// DefaultRetryConfig returns the default retry configuration.
@@ -99,6 +99,10 @@ func (l *Loop) Iteration() int {
9999

100100
// Run executes the agent loop until completion or max iterations.
101101
func (l *Loop) Run(ctx context.Context) error {
102+
if l.provider == nil {
103+
return fmt.Errorf("loop provider is not configured")
104+
}
105+
102106
// Open log file in PRD directory
103107
prdDir := filepath.Dir(l.prdPath)
104108
logPath := filepath.Join(prdDir, l.provider.LogFileName())

internal/loop/loop_test.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ type mockProvider struct {
1717
cliPath string // if set, used as CLI path; otherwise "claude"
1818
}
1919

20-
func (m *mockProvider) Name() string { return "Test" }
21-
func (m *mockProvider) CLIPath() string { return m.path() }
22-
func (m *mockProvider) InteractiveCommand(_, _ string) *exec.Cmd { return exec.Command("true") }
23-
func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) }
24-
func (m *mockProvider) LogFileName() string { return "claude.log" }
20+
func (m *mockProvider) Name() string { return "Test" }
21+
func (m *mockProvider) CLIPath() string { return m.path() }
22+
func (m *mockProvider) InteractiveCommand(_, _ string) *exec.Cmd { return exec.Command("true") }
23+
func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) }
24+
func (m *mockProvider) LogFileName() string { return "claude.log" }
2525

2626
func (m *mockProvider) ConvertCommand(_, _ string) (*exec.Cmd, OutputMode, string, error) {
2727
return exec.Command("true"), OutputStdout, "", nil
@@ -439,3 +439,14 @@ func TestLoop_SetRetryConfig(t *testing.T) {
439439
t.Errorf("Expected MaxRetries 5, got %d", l.retryConfig.MaxRetries)
440440
}
441441
}
442+
443+
func TestLoop_RunRequiresProvider(t *testing.T) {
444+
l := NewLoop("/test/prd.json", "test", 1, nil)
445+
err := l.Run(context.Background())
446+
if err == nil {
447+
t.Fatal("expected provider validation error")
448+
}
449+
if err.Error() != "loop provider is not configured" {
450+
t.Fatalf("unexpected error: %v", err)
451+
}
452+
}

internal/loop/manager.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,16 @@ type ManagerEvent struct {
6767

6868
// Manager manages multiple Loop instances for parallel PRD execution.
6969
type Manager struct {
70-
instances map[string]*LoopInstance
71-
events chan ManagerEvent
72-
maxIter int
73-
retryConfig RetryConfig
74-
provider Provider
75-
baseDir string // Project root directory (for CLAUDE.md etc.)
76-
config *config.Config // Project config for post-completion actions
77-
mu sync.RWMutex
78-
wg sync.WaitGroup
79-
onComplete func(prdName string) // Callback when a PRD completes
70+
instances map[string]*LoopInstance
71+
events chan ManagerEvent
72+
maxIter int
73+
retryConfig RetryConfig
74+
provider Provider
75+
baseDir string // Project root directory (for CLAUDE.md etc.)
76+
config *config.Config // Project config for post-completion actions
77+
mu sync.RWMutex
78+
wg sync.WaitGroup
79+
onComplete func(prdName string) // Callback when a PRD completes
8080
onPostComplete func(prdName, branch, workDir string) // Callback for post-completion actions (push, PR)
8181
}
8282

@@ -210,6 +210,10 @@ func (m *Manager) Unregister(name string) error {
210210

211211
// Start starts the loop for a specific PRD.
212212
func (m *Manager) Start(name string) error {
213+
if m.provider == nil {
214+
return fmt.Errorf("manager provider is not configured")
215+
}
216+
213217
m.mu.Lock()
214218
instance, exists := m.instances[name]
215219
m.mu.Unlock()

internal/loop/manager_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,24 @@ func TestManagerStartNonExistent(t *testing.T) {
222222
}
223223
}
224224

225+
func TestManagerStartRequiresProvider(t *testing.T) {
226+
tmpDir := t.TempDir()
227+
prdPath := createTestPRDWithName(t, tmpDir, "test-prd")
228+
229+
m := NewManager(10, nil)
230+
if err := m.Register("test-prd", prdPath); err != nil {
231+
t.Fatalf("register failed: %v", err)
232+
}
233+
234+
err := m.Start("test-prd")
235+
if err == nil {
236+
t.Fatal("expected provider validation error")
237+
}
238+
if err.Error() != "manager provider is not configured" {
239+
t.Fatalf("unexpected error: %v", err)
240+
}
241+
}
242+
225243
func TestManagerConcurrentAccess(t *testing.T) {
226244
tmpDir := t.TempDir()
227245
prdPath := createTestPRDWithName(t, tmpDir, "test-prd")

0 commit comments

Comments
 (0)