M2: ai claude and codex CLI backends with selector
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user