diff --git a/shell/llm/claudecode.go b/shell/llm/claudecode.go index 95c7e87..50d8217 100644 --- a/shell/llm/claudecode.go +++ b/shell/llm/claudecode.go @@ -59,24 +59,7 @@ func NewClaudeCodeComplete(cfg config.ClaudeCodeCfg, log *slog.Logger) coretypes } // Resolve working directory once at init time. - workDir := cfg.WorkingDir - if workDir == "" { - tmp, err := os.MkdirTemp("", "claude-agent-*") - if err != nil { - log.Error("claude-code: failed to create temp working dir", "err", err) - // Fall through — cmd.Dir will remain empty (inherits CWD). - } else { - workDir = tmp - log.Warn("claude-code working_dir is empty, using temporary directory", - "dir", workDir, - ) - } - } else { - // Ensure configured directory exists. - if err := os.MkdirAll(workDir, 0o755); err != nil { - log.Error("claude-code: failed to create working dir", "dir", workDir, "err", err) - } - } + workDir := resolveWorkDir(cfg.WorkingDir, log) return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { ctx, cancel := context.WithTimeout(ctx, timeout) @@ -141,6 +124,29 @@ func NewClaudeCodeComplete(cfg config.ClaudeCodeCfg, log *slog.Logger) coretypes } } +// resolveWorkDir determines the working directory for the claude subprocess. +// If configured is empty, it creates a temporary directory to avoid inheriting the launcher's CWD. +// If configured is non-empty, it ensures the directory exists. +func resolveWorkDir(configured string, log *slog.Logger) string { + if configured == "" { + tmp, err := os.MkdirTemp("", "claude-agent-*") + if err != nil { + log.Error("claude-code: failed to create temp working dir", "err", err) + return "" // Fall through — cmd.Dir will remain empty (inherits CWD). + } + log.Warn("claude-code working_dir is empty, using temporary directory", + "dir", tmp, + ) + return tmp + } + + // Ensure configured directory exists. + if err := os.MkdirAll(configured, 0o755); err != nil { + log.Error("claude-code: failed to create working dir", "dir", configured, "err", err) + } + return configured +} + // buildClaudeArgs constructs the CLI arguments for claude -p. func buildClaudeArgs(cfg config.ClaudeCodeCfg, req coretypes.CompletionRequest) []string { args := []string{"--print", "--output-format", "json"} diff --git a/shell/llm/claudecode_test.go b/shell/llm/claudecode_test.go index a8d414d..07c87d0 100644 --- a/shell/llm/claudecode_test.go +++ b/shell/llm/claudecode_test.go @@ -5,6 +5,9 @@ import ( "errors" "io" "log/slog" + "os" + "path/filepath" + "strings" "testing" "time" @@ -318,6 +321,56 @@ func TestFilterEnv_PrefixSafety(t *testing.T) { } } +// ── resolveWorkDir ────────────────────────────────────────────────────── + +func TestResolveWorkDir_EmptyCreatesTempDir(t *testing.T) { + dir := resolveWorkDir("", discardLog) + if dir == "" { + t.Fatal("expected a temp directory, got empty string") + } + defer os.RemoveAll(dir) + + if !strings.Contains(dir, "claude-agent-") { + t.Errorf("temp dir %q should contain 'claude-agent-' prefix", dir) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("temp dir should exist: %v", err) + } + if !info.IsDir() { + t.Error("temp dir should be a directory") + } +} + +func TestResolveWorkDir_ConfiguredValueUsed(t *testing.T) { + want := filepath.Join(t.TempDir(), "custom-workdir") + + got := resolveWorkDir(want, discardLog) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + info, err := os.Stat(got) + if err != nil { + t.Fatalf("configured dir should be created: %v", err) + } + if !info.IsDir() { + t.Error("configured dir should be a directory") + } +} + +func TestResolveWorkDir_ConfiguredAlreadyExists(t *testing.T) { + want := t.TempDir() // already exists + + got := resolveWorkDir(want, discardLog) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + // ── helpers ────────────────────────────────────────────────────────────── func contains(s, substr string) bool {