Skip to content

Commit 3ee16e2

Browse files
committed
refactor(cli,ai,container,sandbox): extract AIRuntimeOptions and improve error handling
Extract shared AI runtime configuration into AIRuntimeOptions struct to enable reuse by non-prompt commands (e.g., gavel's ai-fix loop) without inheriting prompt-specific fields. Update ToRequest() to accept prompt parameters explicitly rather than reading from struct fields. Add proper error handling for artifact writes and log syncs in fixture runner, capturing and returning errors instead of silently ignoring them. Remove unused functions: printLeft() in history_render, stageCategoryFiles/copyFile/copyDir in container/generate, hasOAuth/extractOAuthToken in container/sandbox, and quoteShellArg/quoteShellArgs in sandbox/sandbox_utils. Add isolateSavedAI() test helper to prevent ~/.captain.yaml from leaking into test expectations. Add TestAIRuntimeOptions_ToRequest_OverlaysSaved to verify saved config overlays work correctly for non-prompt callers. Fix struct field alignment in SandboxConfig.
1 parent a0d0a61 commit 3ee16e2

7 files changed

Lines changed: 160 additions & 184 deletions

File tree

pkg/ai/fixture/runner.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,12 +382,16 @@ func executeRun(parent context.Context, fixtureDir string, run Run, opts Options
382382
defer stopHeartbeat()
383383

384384
inflight := map[string]*pendingCall{}
385+
var artifactErr error
385386

386387
for scanner.Scan() {
387388
line := scanner.Bytes()
388-
if artifact != nil {
389-
artifact.Write(line)
390-
artifact.Write([]byte{'\n'})
389+
if artifact != nil && artifactErr == nil {
390+
if _, err := artifact.Write(line); err != nil {
391+
artifactErr = fmt.Errorf("writing artifact for %q: %w", run.Name, err)
392+
} else if _, err := artifact.Write([]byte{'\n'}); err != nil {
393+
artifactErr = fmt.Errorf("writing artifact for %q: %w", run.Name, err)
394+
}
391395
}
392396
ev, ok := ParseLine(line)
393397
if !ok {
@@ -402,15 +406,26 @@ func executeRun(parent context.Context, fixtureDir string, run Run, opts Options
402406

403407
runErr := cmd.Wait()
404408
flushPendingCalls(inflight, &summary)
409+
var syncErr error
405410
if kubectlLog != nil {
406-
kubectlLog.Sync()
411+
if err := kubectlLog.Sync(); err != nil {
412+
syncErr = fmt.Errorf("syncing kubectl api log: %w", err)
413+
}
407414
}
408415
if mcpLog != nil {
409-
mcpLog.Sync()
416+
if err := mcpLog.Sync(); err != nil && syncErr == nil {
417+
syncErr = fmt.Errorf("syncing mcp api log: %w", err)
418+
}
410419
}
411420
if runErr != nil {
412421
return summary, claudeRunError(run.Name, stderrBuf, summary, runErr)
413422
}
423+
if artifactErr != nil {
424+
return summary, artifactErr
425+
}
426+
if syncErr != nil {
427+
return summary, syncErr
428+
}
414429
if kubectlLog != nil {
415430
summary.KubectlAPILog = readKubectlAPILog(env.kubectlLogPath)
416431
summary.KubectlAPICalls = len(summary.KubectlAPILog)

pkg/cli/ai.go

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,21 @@ func (o AIProviderOptions) ToConfig() ai.Config {
6868
return cfg
6969
}
7070

71-
type AIPromptOptions struct {
71+
// AIRuntimeOptions binds the per-invocation knobs every AI command shares —
72+
// model selection (via embedded AIProviderOptions), generation parameters
73+
// (max tokens, temperature, timeout, reasoning), permission/sandbox toggles
74+
// (edit, allowed/disallowed tools, permission mode), and ambient-context
75+
// toggles (mcp/hooks/skills/user/project/memory/bare). It deliberately
76+
// omits the user-prompt fields so non-prompt commands (e.g. gavel's lint
77+
// --ai-fix loop) can embed it without inheriting a required --prompt flag.
78+
//
79+
// AIPromptOptions embeds this struct and adds Prompt/System/AppendSystem/
80+
// NoStream on top.
81+
type AIRuntimeOptions struct {
7282
AIProviderOptions
73-
Prompt string `flag:"prompt" help:"Prompt text" short:"p" required:"true" stdin:"true"`
74-
System string `flag:"system" help:"System prompt" short:"s"`
75-
AppendSystem string `flag:"append-system" help:"Append text to the default system prompt"`
76-
MaxTokens int `flag:"max-tokens" help:"Maximum output tokens" default:"4096"`
77-
Temperature string `flag:"temperature" help:"Sampling temperature" default:"0"`
78-
Timeout string `flag:"timeout" help:"Request timeout" default:"120s"`
7983

80-
NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"`
84+
MaxTokens int `flag:"max-tokens" help:"Maximum output tokens" default:"4096"`
85+
Temperature string `flag:"temperature" help:"Sampling temperature" default:"0"`
8186

8287
Edit bool `flag:"edit" help:"Safe defaults: acceptEdits + Read/Edit/Write/Glob/Grep allowlist"`
8388
AllowedTools []string `flag:"allowed-tools" help:"Override --edit's built-in allowlist (claude only)"`
@@ -94,6 +99,16 @@ type AIPromptOptions struct {
9499
Bare bool `flag:"bare" help:"Skip hooks, skills, memory, and ambient settings"`
95100
}
96101

102+
type AIPromptOptions struct {
103+
AIRuntimeOptions
104+
105+
Prompt string `flag:"prompt" help:"Prompt text" short:"p" required:"true" stdin:"true"`
106+
System string `flag:"system" help:"System prompt" short:"s"`
107+
AppendSystem string `flag:"append-system" help:"Append text to the default system prompt"`
108+
Timeout string `flag:"timeout" help:"Request timeout" default:"120s"`
109+
NoStream bool `flag:"no-stream" help:"Disable streaming; print only the final text to stdout"`
110+
}
111+
97112
type AIPromptResult struct {
98113
Text string `json:"text" pretty:"label=Response"`
99114
Model string `json:"model" pretty:"label=Model"`
@@ -104,25 +119,29 @@ type AIPromptResult struct {
104119
Duration string `json:"duration" pretty:"label=Duration"`
105120
}
106121

107-
// ToRequest translates the user-facing AIPromptOptions into the typed
108-
// ai.Request. Truthy flags like --mcp/--hooks/--memory invert into the
109-
// No*-style fields the providers consume. When --bare is set, it implicitly
110-
// strips memory/hooks/skills/user/project regardless of those flags' values
111-
// (claude --bare composes them) so we let the provider decide the final argv.
122+
// ToRequest translates the runtime knobs into the typed ai.Request. Truthy
123+
// flags like --mcp/--hooks/--memory invert into the No*-style fields the
124+
// providers consume. When --bare is set, it implicitly strips memory/hooks/
125+
// skills/user/project regardless of those flags' values (claude --bare
126+
// composes them) so we let the provider decide the final argv.
127+
//
128+
// systemPrompt / appendSystemPrompt / userPrompt are passed explicitly so
129+
// non-prompt callers (gavel's ai-fix loop) can build them per-iteration
130+
// without leaking those fields into the shared runtime struct.
112131
//
113132
// Saved defaults from ~/.captain.yaml overlay onto unset fields. For the
114-
// boolean toggles, "saved off" wins over "flag default on" because clicky does
115-
// not yet expose a Changed() bit.
133+
// boolean toggles, "saved off" wins over "flag default on" because clicky
134+
// does not yet expose a Changed() bit.
116135
//
117-
// WORKAROUND(no-flag-changed-bit): The boolean toggles default to true, so we
118-
// cannot tell whether --mcp=true was passed explicitly or inherited from the
119-
// default. As a result, when the saved config has NoMCP=true the user cannot
120-
// force MCP back on from the command line by passing --mcp=true alone — they
121-
// must edit ~/.captain.yaml or rerun `captain configure`.
136+
// WORKAROUND(no-flag-changed-bit): The boolean toggles default to true, so
137+
// we cannot tell whether --mcp=true was passed explicitly or inherited from
138+
// the default. As a result, when the saved config has NoMCP=true the user
139+
// cannot force MCP back on from the command line by passing --mcp=true alone
140+
// — they must edit ~/.captain.yaml or rerun `captain configure`.
122141
// Correct fix: thread clicky's per-flag Changed() bit (or a tri-state flag
123-
// type) through AIPromptOptions so we can distinguish "explicitly true" from
124-
// "default true". Ref: discussed with user 2026-05-07.
125-
func (o AIPromptOptions) ToRequest() ai.Request {
142+
// type) through AIRuntimeOptions so we can distinguish "explicitly true"
143+
// from "default true". Ref: discussed with user 2026-05-07.
144+
func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt string) ai.Request {
126145
saved := loadSavedAI()
127146
temperature, _ := strconv.ParseFloat(o.Temperature, 64)
128147

@@ -134,9 +153,9 @@ func (o AIPromptOptions) ToRequest() ai.Request {
134153
effort := saved.ReasoningEffort
135154

136155
return ai.Request{
137-
SystemPrompt: o.System,
138-
AppendSystemPrompt: o.AppendSystem,
139-
Prompt: o.Prompt,
156+
SystemPrompt: systemPrompt,
157+
AppendSystemPrompt: appendSystemPrompt,
158+
Prompt: userPrompt,
140159
MaxTokens: maxTokens,
141160
Temperature: temperature,
142161
ReasoningEffort: effort,
@@ -155,6 +174,12 @@ func (o AIPromptOptions) ToRequest() ai.Request {
155174
}
156175
}
157176

177+
// ToRequest delegates to AIRuntimeOptions.ToRequest, lifting the prompt
178+
// fields the prompt-shaped command owns onto the typed request.
179+
func (o AIPromptOptions) ToRequest() ai.Request {
180+
return o.AIRuntimeOptions.ToRequest(o.System, o.AppendSystem, o.Prompt)
181+
}
182+
158183
func RunAIPrompt(opts AIPromptOptions) (any, error) {
159184
if opts.Prompt == "" {
160185
return nil, fmt.Errorf("prompt text required (use --prompt or pipe via stdin)")

pkg/cli/ai_test.go

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
package cli
22

33
import (
4+
"os"
5+
"path/filepath"
46
"reflect"
57
"testing"
8+
9+
"github.com/flanksource/captain/pkg/captainconfig"
610
)
711

12+
// isolateSavedAI redirects captainconfig.Path() to an empty file inside
13+
// t.TempDir() so loadSavedAI() returns zero defaults rather than leaking
14+
// the developer's real ~/.captain.yaml into table-test expectations.
15+
func isolateSavedAI(t *testing.T) {
16+
t.Helper()
17+
p := filepath.Join(t.TempDir(), ".captain.yaml")
18+
if err := os.WriteFile(p, []byte(""), 0o644); err != nil {
19+
t.Fatalf("seed empty captain config: %v", err)
20+
}
21+
captainconfig.SetPathForTesting(p)
22+
t.Cleanup(func() { captainconfig.SetPathForTesting("") })
23+
}
24+
825
func TestAIPromptOptions_ToRequest_Defaults(t *testing.T) {
26+
isolateSavedAI(t)
927
opts := defaultPromptOptions(t)
1028
req := opts.ToRequest()
1129

@@ -29,6 +47,7 @@ func TestAIPromptOptions_ToRequest_Defaults(t *testing.T) {
2947
}
3048

3149
func TestAIPromptOptions_ToRequest_TruthyInversion(t *testing.T) {
50+
isolateSavedAI(t)
3251
cases := []struct {
3352
name string
3453
mutate func(*AIPromptOptions)
@@ -101,6 +120,7 @@ func TestAIPromptOptions_ToRequest_TruthyInversion(t *testing.T) {
101120
}
102121

103122
func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) {
123+
isolateSavedAI(t)
104124
opts := defaultPromptOptions(t)
105125
opts.System = "be careful"
106126
opts.AppendSystem = "also be brief"
@@ -146,19 +166,66 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) {
146166
}
147167
}
148168

169+
// TestAIRuntimeOptions_ToRequest_OverlaysSaved verifies the path gavel (and any
170+
// other embedder) takes: AIRuntimeOptions with zero/default flag values should
171+
// still pick up NoMCP/NoHooks/MaxTokens/ReasoningEffort from ~/.captain.yaml.
172+
func TestAIRuntimeOptions_ToRequest_OverlaysSaved(t *testing.T) {
173+
tmp := filepath.Join(t.TempDir(), ".captain.yaml")
174+
saved := []byte("ai:\n noMCP: true\n noHooks: true\n noSkills: true\n noUser: true\n noProject: true\n noMemory: true\n maxTokens: 16000\n reasoningEffort: high\n")
175+
if err := os.WriteFile(tmp, saved, 0o644); err != nil {
176+
t.Fatalf("seed captain config: %v", err)
177+
}
178+
captainconfig.SetPathForTesting(tmp)
179+
t.Cleanup(func() { captainconfig.SetPathForTesting("") })
180+
181+
opts := AIRuntimeOptions{
182+
MaxTokens: 4096, // sentinel default — should be overridden by saved 16000
183+
MCP: true, // flag-default true; saved NoMCP=true must still win
184+
Hooks: true,
185+
Skills: true,
186+
User: true,
187+
Project: true,
188+
Memory: true,
189+
}
190+
req := opts.ToRequest("sys", "", "user")
191+
192+
if req.SystemPrompt != "sys" {
193+
t.Errorf("SystemPrompt = %q, want sys", req.SystemPrompt)
194+
}
195+
if req.Prompt != "user" {
196+
t.Errorf("Prompt = %q, want user", req.Prompt)
197+
}
198+
if req.MaxTokens != 16000 {
199+
t.Errorf("MaxTokens = %d, want 16000 (saved overlay)", req.MaxTokens)
200+
}
201+
if req.ReasoningEffort != "high" {
202+
t.Errorf("ReasoningEffort = %q, want high", req.ReasoningEffort)
203+
}
204+
for name, got := range map[string]bool{
205+
"NoMCP": req.NoMCP, "NoHooks": req.NoHooks, "NoSkills": req.NoSkills,
206+
"NoUser": req.NoUser, "NoProject": req.NoProject, "NoMemory": req.NoMemory,
207+
} {
208+
if !got {
209+
t.Errorf("%s = false, want true (from saved config)", name)
210+
}
211+
}
212+
}
213+
149214
func defaultPromptOptions(t *testing.T) AIPromptOptions {
150215
t.Helper()
151216
return AIPromptOptions{
152-
Prompt: "hello",
153-
MaxTokens: 4096,
154-
Temperature: "0",
155-
Timeout: "120s",
156-
MCP: true,
157-
Hooks: true,
158-
Skills: true,
159-
User: true,
160-
Project: true,
161-
Memory: true,
217+
AIRuntimeOptions: AIRuntimeOptions{
218+
MaxTokens: 4096,
219+
Temperature: "0",
220+
MCP: true,
221+
Hooks: true,
222+
Skills: true,
223+
User: true,
224+
Project: true,
225+
Memory: true,
226+
},
227+
Timeout: "120s",
228+
Prompt: "hello",
162229
}
163230
}
164231

pkg/cli/history_render.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,6 @@ func toLineEntry(t tools.Tool, compact bool, width, toolWidth int) lineEntry {
189189
return e
190190
}
191191

192-
func printLeft(e lineEntry, toolWidth int) {
193-
printLeftTo(os.Stdout, e, toolWidth)
194-
}
195-
196192
func printLeftTo(w io.Writer, e lineEntry, toolWidth int) {
197193
timeStr := e.Time
198194
if timeStr != "" {

pkg/container/generate.go

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ package container
22

33
import (
44
"fmt"
5-
"io"
65
"os"
76
"path/filepath"
8-
"strings"
97
"text/template"
108
)
119

@@ -224,72 +222,3 @@ func renderDockerfile(contextDir, tmplStr string, data templateData) (string, er
224222
}
225223
return dockerfilePath, nil
226224
}
227-
228-
func stageCategoryFiles(contextDir string, cat Category, items []Component) ([]copyBlock, []string, bool, error) {
229-
var blocks []copyBlock
230-
var hookFiles []string
231-
hasDotfiles := false
232-
233-
for _, item := range items {
234-
relPath := filepath.Join(string(cat), item.Name)
235-
destInContext := filepath.Join(contextDir, relPath)
236-
237-
if err := os.MkdirAll(filepath.Dir(destInContext), 0o755); err != nil {
238-
return nil, nil, false, err
239-
}
240-
241-
if item.IsDir {
242-
if err := copyDir(item.SourcePath, destInContext); err != nil {
243-
return nil, nil, false, fmt.Errorf("copying dir %s: %w", item.Name, err)
244-
}
245-
} else {
246-
if err := copyFile(item.SourcePath, destInContext); err != nil {
247-
return nil, nil, false, fmt.Errorf("copying file %s: %w", item.Name, err)
248-
}
249-
}
250-
251-
blocks = append(blocks, copyBlock{
252-
Comment: fmt.Sprintf("%s: %s", cat, item.Name),
253-
Instruction: fmt.Sprintf("COPY %s %s", relPath, item.TargetPath),
254-
})
255-
256-
if cat == CategoryHooks {
257-
hookFiles = append(hookFiles, item.Name)
258-
}
259-
if strings.Contains(item.TargetPath, ".dotfiles") {
260-
hasDotfiles = true
261-
}
262-
}
263-
return blocks, hookFiles, hasDotfiles, nil
264-
}
265-
266-
func copyFile(src, dst string) error {
267-
in, err := os.Open(src)
268-
if err != nil {
269-
return err
270-
}
271-
defer in.Close() //nolint:errcheck
272-
273-
out, err := os.Create(dst)
274-
if err != nil {
275-
return err
276-
}
277-
defer out.Close() //nolint:errcheck
278-
279-
_, err = io.Copy(out, in)
280-
return err
281-
}
282-
283-
func copyDir(src, dst string) error {
284-
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
285-
if err != nil {
286-
return err
287-
}
288-
rel, _ := filepath.Rel(src, path)
289-
target := filepath.Join(dst, rel)
290-
if info.IsDir() {
291-
return os.MkdirAll(target, 0o755)
292-
}
293-
return copyFile(path, target)
294-
})
295-
}

0 commit comments

Comments
 (0)