Skip to main content

ai_memory/
llm_cli_wrap.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-CLI-binary `WrapStrategy` table for the `ai-memory wrap <agent>`
5//! subcommand.
6//!
7//! # Why this module exists (#1183, split out of #1174 PR4)
8//!
9//! The `ai-memory wrap` subcommand spawns *external CLI binaries* (the
10//! `codex`, `aider`, `gemini`, `ollama`, … executables on `$PATH`) and
11//! prepends a system-message envelope onto each invocation. Each
12//! downstream CLI has its own ABI for accepting that envelope —
13//! `--system "<msg>"` vs. `OLLAMA_SYSTEM=<msg>` vs. `--message-file
14//! <path>` — and the table that maps "agent binary name" → "delivery
15//! strategy" is the canonical knowledge of those ABIs.
16//!
17//! That table is **adjacent to** the LLM-backend alias tables in
18//! [`crate::llm`] (the `default_base_url_for_alias` / vendor URL +
19//! Bearer-key map used by the HTTP LLM client), but is *not* the same
20//! concern:
21//!
22//! - [`crate::llm`] is the **HTTP LLM client** — `POST
23//!   /v1/chat/completions`, Bearer auth, circuit breaker. The agent
24//!   name here means "wire-shape selector" (`xai`, `openai`, …).
25//! - This module is the **CLI process-spawning wrapper** —
26//!   `std::process::Command::new(<binary>)`, `Stdio::inherit`,
27//!   tempfile cleanup. The agent name here means "binary on `$PATH`"
28//!   (`codex`, `aider`, …).
29//!
30//! Keeping the two tables in sibling modules at the crate root
31//! preserves both concerns at one substrate level without conflating
32//! them into the HTTP client module. The CLI-binary-name detection
33//! logic that PICKS a `WrapStrategy` stays in [`crate::cli::wrap`]
34//! (it's CLI-specific); only the per-vendor TABLE lives here.
35
36/// Strategy for delivering the assembled system message to the wrapped
37/// agent. Each variant maps to a distinct CLI ABI an agent might
38/// expose.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum WrapStrategy {
41    /// Pass the system message as the value of a CLI flag, e.g.
42    /// `codex --system "<msg>" <args...>`.
43    SystemFlag {
44        /// The flag name including any leading dashes — e.g. `--system`,
45        /// `--system-prompt`, `-s`.
46        flag: String,
47    },
48    /// Set the system message as an environment variable for the child
49    /// process. e.g. `OLLAMA_SYSTEM=<msg> ollama run hermes3:8b`.
50    SystemEnv {
51        /// The env var name, e.g. `OLLAMA_SYSTEM`.
52        name: String,
53    },
54    /// Write the system message to a tempfile and pass the path via a
55    /// CLI flag. e.g. `aider --message-file <path> <args...>`. Used by
56    /// agents whose system-message length exceeds shell argv limits or
57    /// whose CLI explicitly takes a file path.
58    MessageFile {
59        /// The flag that takes the file path, e.g. `--message-file`.
60        flag: String,
61    },
62    /// Resolve the strategy at runtime from [`default_strategy`].
63    /// This is the natural mode when the user hasn't passed any of the
64    /// strategy override flags.
65    Auto,
66}
67
68/// Built-in agent → strategy lookup. The list is small by design — we
69/// only encode strategies for agents we've actually verified. Anything
70/// not in the table falls through to `--system <msg>` because that's
71/// the most common contract across OpenAI-compatible CLIs.
72///
73/// PR-7 may extend this map; the matrix is intentionally tabular so
74/// adding a row is a one-line change.
75///
76/// **Substrate note (#1183).** This table sits next to
77/// [`crate::llm`]'s alias tables (`default_base_url_for_alias`,
78/// `alias_api_key_env_vars`) at the crate root so the "per-vendor
79/// behavior" substrate has one home per concern: HTTP wire shape in
80/// `llm.rs`, CLI ABI in `llm_cli_wrap.rs`. The agent-name strings here
81/// are CLI **binary names** on `$PATH`, NOT the
82/// `AI_MEMORY_LLM_BACKEND` wire-shape selector — overlap is
83/// coincidental (e.g. `ollama` is both a CLI binary AND a backend
84/// selector, but the two columns are independent).
85#[must_use]
86pub fn default_strategy(agent: &str) -> WrapStrategy {
87    match agent {
88        // OpenAI Codex CLI. The flag name varies between Codex variants
89        // (`--system`, `--system-prompt`, `OPENAI_CLI_SYSTEM`) but
90        // `--system` is the documented form on the upstream codex-cli
91        // crate (PR-1 recipe + Codex CLI README). Users running a
92        // variant that exposes a different flag can override with
93        // `--system-flag <flag>`.
94        "codex" | "codex-cli" => WrapStrategy::SystemFlag {
95            flag: "--system".into(),
96        },
97        // Anthropic Claude Code CLI (#1238). The canonical flag for
98        // appending a system prompt onto a `claude` invocation is
99        // `--append-system-prompt "<msg>"` per the upstream
100        // `@anthropic-ai/claude-code` CLI docs. The env-var equivalent
101        // is `CLAUDE_SYSTEM_PROMPT` but the flag form is preferred —
102        // it composes with `claude -p` (one-shot mode) without forcing
103        // operators to leak the prompt into the child's env block.
104        // Pre-#1238 this fell through to the generic `--system`
105        // fallback, which `claude` either rejects or silently
106        // ignores — the project's own primary use case sat in the
107        // unverified fallback.
108        "claude" | "claude-cli" => WrapStrategy::SystemFlag {
109            flag: "--append-system-prompt".into(),
110        },
111        // Aider takes its system / instructions input from a file via
112        // `--message-file`. Aider's CLI explicitly recommends this for
113        // anything longer than a one-liner because it doesn't shell-quote
114        // the arg-form for newlines reliably.
115        "aider" => WrapStrategy::MessageFile {
116            flag: "--message-file".into(),
117        },
118        // Google Gemini CLI. `--system` is the documented prepend form.
119        "gemini" => WrapStrategy::SystemFlag {
120            flag: "--system".into(),
121        },
122        // Ollama uses an env var because `ollama run <model>` doesn't
123        // expose a `--system` flag at the CLI level — it expects the
124        // system prompt either inside the prompt body or via the
125        // `OLLAMA_SYSTEM` env var (also the form `ollama serve` reads).
126        "ollama" => WrapStrategy::SystemEnv {
127            name: "OLLAMA_SYSTEM".into(),
128        },
129        // Default: most OpenAI-compatible CLIs accept `--system <msg>`.
130        // If that's wrong, users override with `--system-flag` /
131        // `--system-env` / `--message-file-flag`.
132        //
133        // # Documented gaps (#1238)
134        //
135        // The following vendor CLI binaries are intentionally LEFT in
136        // the generic fallback rather than pinned to a flag, because
137        // upstream documentation does not surface a canonical
138        // `--system`-equivalent flag the way the entries above do:
139        //
140        // - `gpt` — no canonical first-party OpenAI CLI binary uses
141        //   that name; multiple community wrappers exist with
142        //   incompatible flag shapes. Falls through to `--system`.
143        // - `grok` — xAI ships no first-party CLI binary at v0.7.0
144        //   (the Grok surface is API-only); falls through to
145        //   `--system` for any community wrapper that defaults to it.
146        // - `anthropic-cli` — there is no first-party CLI binary
147        //   named `anthropic-cli` at v0.7.0 (Anthropic ships the
148        //   Python SDK + the `claude` Claude Code CLI handled above);
149        //   falls through to `--system` for any third-party tool.
150        //
151        // Operators running a vendor CLI in any of the three gaps
152        // above pass `--system-flag <flag>` (or `--system-env <name>`
153        // / `--message-file-flag <flag>`) explicitly. If a canonical
154        // upstream form lands for any of these later, add a row
155        // here + extend `default_strategy_per_known_agent_pins_1183`.
156        _ => WrapStrategy::SystemFlag {
157            flag: "--system".into(),
158        },
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    /// Byte-for-byte preservation pin for the per-agent CLI-ABI table
167    /// after the #1183 move. If any of these assertions break, the
168    /// `ai-memory wrap` runtime contract with downstream agent CLIs is
169    /// broken too — the inputs to `Command::new(<agent>)` change shape
170    /// and existing integrations rely on the exact `--system` /
171    /// `OLLAMA_SYSTEM` / `--message-file` argv shape.
172    #[test]
173    fn default_strategy_per_known_agent_pins_1183() {
174        assert_eq!(
175            default_strategy("codex"),
176            WrapStrategy::SystemFlag {
177                flag: "--system".into()
178            }
179        );
180        assert_eq!(
181            default_strategy("codex-cli"),
182            WrapStrategy::SystemFlag {
183                flag: "--system".into()
184            }
185        );
186        assert_eq!(
187            default_strategy("aider"),
188            WrapStrategy::MessageFile {
189                flag: "--message-file".into()
190            }
191        );
192        assert_eq!(
193            default_strategy("gemini"),
194            WrapStrategy::SystemFlag {
195                flag: "--system".into()
196            }
197        );
198        assert_eq!(
199            default_strategy("ollama"),
200            WrapStrategy::SystemEnv {
201                name: "OLLAMA_SYSTEM".into()
202            }
203        );
204        // #1238 — Claude Code CLI uses --append-system-prompt; the
205        // pre-#1238 default fall-through to `--system` is wrong for
206        // `claude` and `claude-cli` (the project's own primary
207        // wrapped agent).
208        assert_eq!(
209            default_strategy("claude"),
210            WrapStrategy::SystemFlag {
211                flag: "--append-system-prompt".into()
212            }
213        );
214        assert_eq!(
215            default_strategy("claude-cli"),
216            WrapStrategy::SystemFlag {
217                flag: "--append-system-prompt".into()
218            }
219        );
220        // #1238 — documented gaps. `gpt`, `grok`, `anthropic-cli`
221        // have no canonical first-party CLI flag at v0.7.0 ship;
222        // they intentionally fall through to the generic --system
223        // default. If a canonical form lands for any of these
224        // later, add a row in `default_strategy` AND extend this
225        // assertion.
226        for gap in ["gpt", "grok", "anthropic-cli"] {
227            assert_eq!(
228                default_strategy(gap),
229                WrapStrategy::SystemFlag {
230                    flag: "--system".into()
231                },
232                "documented #1238 gap `{gap}` must fall through to the generic --system \
233                 default until a canonical upstream form is verifiable"
234            );
235        }
236        // Unknown agent → fall through to --system.
237        assert_eq!(
238            default_strategy("some-future-cli"),
239            WrapStrategy::SystemFlag {
240                flag: "--system".into()
241            }
242        );
243    }
244}