From 78557661e20479ebbaf38f428f9454538890b510 Mon Sep 17 00:00:00 2001 From: "fanxiyao.3" Date: Tue, 14 Apr 2026 21:54:07 +0800 Subject: [PATCH] feat: add Codex CLI backend support Add OpenAI Codex CLI as a new backend option. The codex backend invokes `codex -q --approval-mode suggest ` for non-interactive streaming output, following the same pattern as the existing Claude CLI backend. - New file: internal/backend/codex.go (CodexBackend implementation) - Config: CodexPath field + INLINE_CLI_CODEX_PATH env var - Daemon: "codex" case in createBackend switch --- internal/backend/codex.go | 100 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 6 +++ internal/daemon/server.go | 4 +- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 internal/backend/codex.go diff --git a/internal/backend/codex.go b/internal/backend/codex.go new file mode 100644 index 0000000..e6b332b --- /dev/null +++ b/internal/backend/codex.go @@ -0,0 +1,100 @@ +package backend + +import ( + "bufio" + "fmt" + "os/exec" + "strings" +) + +// CodexBackend uses the OpenAI Codex CLI tool as the backend. +// It execs `codex -q ` and streams stdout. +type CodexBackend struct { + binaryPath string +} + +// NewCodexBackend creates a backend that delegates to the Codex CLI. +// If binaryPath is empty, it looks for "codex" in PATH. +func NewCodexBackend(binaryPath string) (*CodexBackend, error) { + if binaryPath == "" { + path, err := exec.LookPath("codex") + if err != nil { + return nil, fmt.Errorf("codex CLI not found in PATH: %w", err) + } + binaryPath = path + } + return &CodexBackend{binaryPath: binaryPath}, nil +} + +func (b *CodexBackend) Query(messages []Message, model string, onChunk func(text string)) (string, error) { + // Build the prompt: use the last user message as the primary prompt. + // Pass conversation history as context prepended to the prompt. + var prompt string + var history []string + + for i, m := range messages { + if i == len(messages)-1 && m.Role == "user" { + prompt = m.Content + } else { + prefix := "User" + if m.Role == "assistant" { + prefix = "Assistant" + } + history = append(history, fmt.Sprintf("%s: %s", prefix, m.Content)) + } + } + + if prompt == "" { + return "", fmt.Errorf("no user message found") + } + + // Codex CLI uses -q (--quiet) for non-interactive mode that streams to stdout, + // and --approval-mode suggest for safe operation (suggest changes, don't auto-apply). + args := []string{"-q", "--approval-mode", "suggest"} + + // Pass model if specified. + if model != "" { + args = append(args, "--model", model) + } + + // Prepend conversation history to the prompt for context. + fullPrompt := prompt + if len(history) > 0 { + fullPrompt = "Previous conversation:\n" + strings.Join(history, "\n") + "\n\nCurrent request:\n" + prompt + } + + args = append(args, fullPrompt) + + cmd := exec.Command(b.binaryPath, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("failed to start codex CLI: %w", err) + } + + var fullResponse strings.Builder + scanner := bufio.NewScanner(stdout) + scanner.Split(scanChunks) + + for scanner.Scan() { + chunk := scanner.Text() + fullResponse.WriteString(chunk) + if onChunk != nil { + onChunk(chunk) + } + } + + if err := cmd.Wait(); err != nil { + // If we already got some output, return it with the error. + if fullResponse.Len() > 0 { + return fullResponse.String(), fmt.Errorf("codex CLI exited with error: %w", err) + } + return "", fmt.Errorf("codex CLI failed: %w", err) + } + + return fullResponse.String(), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 43fdd47..449271e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,9 @@ type Config struct { // CLI backend settings CLIPath string `toml:"cli_path"` // Path to claude binary (default: auto-detect) + // Codex backend settings + CodexPath string `toml:"codex_path"` // Path to codex binary (default: auto-detect) + // General settings SocketPath string `toml:"socket_path"` PIDFile string `toml:"pid_file"` @@ -85,6 +88,9 @@ func Load() (Config, error) { if v := os.Getenv("INLINE_CLI_CLI_PATH"); v != "" { cfg.CLIPath = v } + if v := os.Getenv("INLINE_CLI_CODEX_PATH"); v != "" { + cfg.CodexPath = v + } if v := os.Getenv("INLINE_CLI_MAX_IDLE"); v != "" { if n, err := strconv.Atoi(v); err == nil { cfg.MaxSessionIdleMinutes = n diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 3fbcd19..9024cc3 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -44,12 +44,14 @@ func createBackend(cfg config.Config) (backend.Backend, error) { switch cfg.Backend { case "cli": return backend.NewCLIBackend(cfg.CLIPath) + case "codex": + return backend.NewCodexBackend(cfg.CodexPath) case "acp": return backend.NewACPBackend() case "api", "": return backend.NewAPIBackend(cfg.APIKey, cfg.APIBaseURL) default: - return nil, fmt.Errorf("unknown backend: %q (supported: api, cli, acp)", cfg.Backend) + return nil, fmt.Errorf("unknown backend: %q (supported: api, cli, codex, acp)", cfg.Backend) } }