From 3148fdfa92a691a02f5a210e727f95476163d2aa Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 14:11:57 -0400 Subject: [PATCH] M2: ai claude and codex CLI backends with selector Co-Authored-By: Claude Opus 4.8 --- internal/ai/backend.go | 101 ++++++++++++++++++++++++++++++++++++ internal/ai/backend_test.go | 47 +++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 internal/ai/backend.go create mode 100644 internal/ai/backend_test.go diff --git a/internal/ai/backend.go b/internal/ai/backend.go new file mode 100644 index 0000000..c83376e --- /dev/null +++ b/internal/ai/backend.go @@ -0,0 +1,101 @@ +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 "", fmt.Errorf("claude: %w: %s", err, strings.TrimSpace(errb.String())) + } + return out.String(), nil +} + +// 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 "", fmt.Errorf("codex: %w: %s", err, strings.TrimSpace(errb.String())) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("codex: read output: %w", err) + } + 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) + } +} diff --git a/internal/ai/backend_test.go b/internal/ai/backend_test.go new file mode 100644 index 0000000..383dcd7 --- /dev/null +++ b/internal/ai/backend_test.go @@ -0,0 +1,47 @@ +package ai + +import ( + "slices" + "testing" +) + +func TestNewBackendSelector(t *testing.T) { + for _, name := range []string{"", "claude"} { + b, err := NewBackend(name) + if err != nil { + t.Fatalf("NewBackend(%q): %v", name, err) + } + if b.Name() != "claude" { + t.Fatalf("NewBackend(%q) name = %q", name, b.Name()) + } + } + b, err := NewBackend("codex") + if err != nil || b.Name() != "codex" { + t.Fatalf("codex backend: %v / %q", err, b.Name()) + } + if _, err := NewBackend("bogus"); err == nil { + t.Fatal("unknown backend should error") + } +} + +func TestClaudeArgs(t *testing.T) { + want := []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"} + if got := newClaudeBackend().args; !slices.Equal(got, want) { + t.Fatalf("claude args = %v want %v", got, want) + } +} + +func TestCodexArgsCarryOutfileAndStdinMarker(t *testing.T) { + got := newCodexBackend().args("/tmp/out.txt") + if got[len(got)-1] != "-" { + t.Fatalf("codex args must end with stdin marker '-', got %v", got) + } + if !slices.Contains(got, "-o") || !slices.Contains(got, "/tmp/out.txt") { + t.Fatalf("codex args must include -o /tmp/out.txt, got %v", got) + } + for _, want := range []string{"exec", "--ignore-user-config", "--ignore-rules", "read-only", "never"} { + if !slices.Contains(got, want) { + t.Fatalf("codex args missing %q, got %v", want, got) + } + } +}