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}