package ai import ( "bytes" "context" "fmt" "os" "os/exec" "strings" ) // claudeBackend shells out to `claude --print` with the prompt on stdin and the // answer on stdout. type claudeBackend struct { cmd string args []string } func newClaudeBackend() claudeBackend { return claudeBackend{ cmd: "claude", args: []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"}, } } func (b claudeBackend) Name() string { return "claude" } func (b claudeBackend) Run(ctx context.Context, prompt string) (string, error) { cmd := exec.CommandContext(ctx, b.cmd, b.args...) cmd.Stdin = strings.NewReader(prompt) var out, errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb if err := cmd.Run(); err != nil { return "", cmdError("claude", err, errb.String()) } return out.String(), nil } // cmdError wraps a CLI failure, appending stderr only when it is non-empty so // the message does not end in a dangling ": ". func cmdError(name string, err error, stderr string) error { if s := strings.TrimSpace(stderr); s != "" { return fmt.Errorf("%s: %w: %s", name, err, s) } return fmt.Errorf("%s: %w", name, err) } // codexBackend shells out to `codex exec` headlessly. codex's stdout includes // session preamble, so the final answer is written to a temp file via -o and // read back. The --ignore-* and read-only flags stop it from running shell // commands driven by local config. type codexBackend struct { cmd string baseArgs []string } func newCodexBackend() codexBackend { return codexBackend{ cmd: "codex", baseArgs: []string{ "exec", "--skip-git-repo-check", "--ignore-user-config", "--ignore-rules", "-s", "read-only", "-a", "never", "--ephemeral", }, } } func (b codexBackend) Name() string { return "codex" } // args builds the full argument list, writing the answer to outfile and reading // the prompt from stdin (trailing "-"). func (b codexBackend) args(outfile string) []string { out := append([]string{}, b.baseArgs...) return append(out, "-o", outfile, "-") } func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) { f, err := os.CreateTemp("", "keel-codex-*.out") if err != nil { return "", fmt.Errorf("codex: temp file: %w", err) } path := f.Name() _ = f.Close() defer os.Remove(path) cmd := exec.CommandContext(ctx, b.cmd, b.args(path)...) cmd.Stdin = strings.NewReader(prompt) var errb bytes.Buffer cmd.Stderr = &errb // stdout is noisy and intentionally discarded if err := cmd.Run(); err != nil { return "", cmdError("codex", err, errb.String()) } data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("codex: read output: %w", err) } // A zero-exit run that wrote nothing means the -o file was never populated // (e.g. flag/version skew); surface that as a codex error instead of letting // it masquerade downstream as an empty model answer. if len(bytes.TrimSpace(data)) == 0 { return "", fmt.Errorf("codex: empty output file: %s", strings.TrimSpace(errb.String())) } return string(data), nil } // NewBackend returns the named backend. "" defaults to "claude". func NewBackend(name string) (Backend, error) { switch name { case "", "claude": return newClaudeBackend(), nil case "codex": return newCodexBackend(), nil default: return nil, fmt.Errorf("ai: unknown backend %q", name) } }