Files
antidrift/internal/ai/backend.go
T
felixm 25a33f027a M2: surface codex empty-output and clean CLI error messages
Addresses code-review follow-ups: codex now returns a specific error when
its -o file is empty rather than degrading to ErrEmptyResponse downstream;
CLI errors omit a dangling stderr suffix when stderr is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:14:04 -04:00

117 lines
3.3 KiB
Go

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("", "antidrift-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)
}
}