agentmux_srv\backend/
providers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Static provider registry — Rust equivalent of
5//! `frontend/app/view/agent/providers/index.ts`.
6//!
7//! All string data is `&'static str` / `&'static [&'static str]` so lookups
8//! are zero-allocation.  The registry is initialised once via `LazyLock` and
9//! then read-only for the lifetime of the process.
10
11use std::collections::HashMap;
12use std::sync::LazyLock;
13
14// ─── Controller type ─────────────────────────────────────────────────────────
15
16/// How the provider process is managed.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ControllerType {
19    /// A single long-running process; input is streamed to stdin.
20    Persistent,
21    /// A fresh subprocess is spawned for every turn; prior sessions are
22    /// resumed via `resume_flag`.
23    Subprocess,
24    /// Agent Client Protocol (ACP): JSON-RPC 2.0 over stdio.
25    /// Sessions are managed by the protocol — no resume flags needed.
26    Acp,
27}
28
29// ─── ProviderConfig ──────────────────────────────────────────────────────────
30
31/// All configuration needed to launch, authenticate, and stream output from
32/// a provider CLI.
33#[derive(Debug)]
34pub struct ProviderConfig {
35    /// Canonical provider identifier (e.g. `"claude"`).
36    pub id: &'static str,
37    /// Human-readable name shown in the UI.
38    pub display_name: &'static str,
39    /// Executable name on PATH (e.g. `"claude"`).
40    pub cli_command: &'static str,
41    /// Whether the provider keeps a persistent subprocess or spawns per turn.
42    pub controller_type: ControllerType,
43    /// Complete CLI args for a single-turn (subprocess) invocation.
44    /// The user prompt is written to the process's stdin.
45    pub launch_args: &'static [&'static str],
46    /// CLI args for persistent (long-running) mode.
47    /// `None` when `controller_type` is `Subprocess`.
48    pub persistent_launch_args: Option<&'static [&'static str]>,
49    /// Flag passed to resume a prior session, e.g. `"--resume"`.
50    /// `None` when the provider does not support simple-flag resume.
51    pub resume_flag: Option<&'static str>,
52    /// JSON field name in the CLI's init event that carries the session /
53    /// thread ID, e.g. `"session_id"` or `"thread_id"`.
54    pub session_id_field: &'static str,
55    /// Output format produced by the CLI in styled / streaming mode.
56    pub styled_output_format: &'static str,
57    // ── Auth isolation ───────────────────────────────────────────────────────
58    /// Environment variable that redirects the provider's config / auth
59    /// directory, e.g. `"CLAUDE_CONFIG_DIR"`.
60    pub auth_config_dir_env_var: &'static str,
61    /// Sub-directory name under `{dataDir}/auth/`, e.g. `"claude"`.
62    pub auth_dir_name: &'static str,
63    /// Extra environment variables required for auth isolation.
64    /// Each entry is a `(key, value)` pair.
65    pub auth_extra_env: &'static [(&'static str, &'static str)],
66    /// Environment variables that must be *unset* before launching the CLI
67    /// (guards against nested-session issues, etc.).
68    pub unset_env: &'static [&'static str],
69    // ── npm install ──────────────────────────────────────────────────────────
70    /// npm package name used for local installation, e.g.
71    /// `"@anthropic-ai/claude-code"`.
72    pub npm_package: &'static str,
73    /// Version string passed to `npm install`, e.g. `"latest"` or `"0.116.0"`.
74    pub pinned_version: &'static str,
75    // ── Misc ─────────────────────────────────────────────────────────────────
76    /// Icon identifier used by the frontend.
77    pub icon: &'static str,
78    /// URL of the provider's documentation.
79    pub docs_url: &'static str,
80}
81
82impl ProviderConfig {
83    /// Return the controller type as the string used in block metadata.
84    pub fn controller_type_str(&self) -> &'static str {
85        match self.controller_type {
86            ControllerType::Persistent => "persistent",
87            ControllerType::Subprocess => "subprocess",
88            ControllerType::Acp => "acp",
89        }
90    }
91}
92
93// ─── Provider definitions ────────────────────────────────────────────────────
94
95static CLAUDE: ProviderConfig = ProviderConfig {
96    id: "claude",
97    display_name: "Claude Code",
98    cli_command: "claude",
99    controller_type: ControllerType::Subprocess,
100    launch_args: &[
101        "-p",
102        "--output-format",
103        "stream-json",
104        "--verbose",
105        "--include-partial-messages",
106        "--dangerously-skip-permissions",
107    ],
108    persistent_launch_args: Some(&[
109        "--input-format",
110        "stream-json",
111        "--output-format",
112        "stream-json",
113        "--verbose",
114        "--include-partial-messages",
115        "--dangerously-skip-permissions",
116    ]),
117    resume_flag: Some("--resume"),
118    session_id_field: "session_id",
119    styled_output_format: "claude-stream-json",
120    auth_config_dir_env_var: "CLAUDE_CONFIG_DIR",
121    auth_dir_name: "claude",
122    auth_extra_env: &[],
123    unset_env: &["CLAUDECODE"],
124    npm_package: "@anthropic-ai/claude-code",
125    pinned_version: "latest",
126    icon: "sparkles",
127    docs_url: "https://docs.anthropic.com/claude-code",
128};
129
130static CODEX: ProviderConfig = ProviderConfig {
131    id: "codex",
132    display_name: "Codex CLI",
133    cli_command: "codex",
134    controller_type: ControllerType::Subprocess,
135    // exec subcommand runs non-interactively; --json emits NDJSON events; - reads prompt from stdin
136    launch_args: &[
137        "exec",
138        "--json",
139        "--dangerously-bypass-approvals-and-sandbox",
140        "-",
141    ],
142    // Codex resume requires a subcommand change (exec resume <id>), not a simple flag.
143    // Multi-turn is handled by re-running exec; None disables automatic --resume append.
144    persistent_launch_args: None,
145    resume_flag: None,
146    session_id_field: "thread_id",
147    styled_output_format: "codex-json",
148    auth_config_dir_env_var: "CODEX_HOME",
149    auth_dir_name: "codex",
150    auth_extra_env: &[],
151    unset_env: &[],
152    npm_package: "@openai/codex",
153    pinned_version: "0.116.0",
154    icon: "robot",
155    docs_url: "https://platform.openai.com/docs/codex",
156};
157
158static GEMINI: ProviderConfig = ProviderConfig {
159    id: "gemini",
160    display_name: "Gemini CLI",
161    cli_command: "gemini",
162    controller_type: ControllerType::Subprocess,
163    // --output-format stream-json: NDJSON events; --yolo: auto-approve all tools;
164    // -p "": enable headless/non-interactive mode (prompt comes from stdin)
165    launch_args: &["--output-format", "stream-json", "--yolo", "-p", ""],
166    persistent_launch_args: None,
167    resume_flag: Some("-r"),
168    session_id_field: "session_id",
169    styled_output_format: "gemini-json",
170    auth_config_dir_env_var: "GEMINI_CLI_HOME",
171    auth_dir_name: "gemini",
172    auth_extra_env: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
173    unset_env: &[],
174    npm_package: "@google/gemini-cli",
175    pinned_version: "0.32.1",
176    icon: "diamond",
177    docs_url: "https://ai.google.dev/gemini-cli",
178};
179
180static KIMI: ProviderConfig = ProviderConfig {
181    id: "kimi",
182    display_name: "Kimi Code CLI",
183    cli_command: "kimi",
184    controller_type: ControllerType::Subprocess,
185    launch_args: &[
186        "--print",
187        "--output-format",
188        "stream-json",
189        "--yolo",
190        "-p",
191        "",
192    ],
193    persistent_launch_args: None,
194    resume_flag: None,
195    session_id_field: "session_id",
196    styled_output_format: "kimi-stream-json",
197    auth_config_dir_env_var: "KIMI_SHARE_DIR",
198    auth_dir_name: "kimi",
199    auth_extra_env: &[],
200    unset_env: &[],
201    npm_package: "",
202    pinned_version: "",
203    icon: "moon",
204    docs_url: "https://moonshotai.github.io/kimi-cli/",
205};
206
207static OPENCLAW: ProviderConfig = ProviderConfig {
208    id: "openclaw",
209    display_name: "OpenClaw",
210    // `openclaw acp` runs OpenClaw's ACP bridge — speaks ACP over stdio
211    // for IDE/tool clients (us) and forwards turns to the local
212    // OpenClaw Gateway over WebSocket. The Gateway is OpenClaw's own
213    // daemon (`openclaw gateway`) and MUST be running before this
214    // bridge can establish a session — surfaced to the user as an
215    // onboarding requirement in SPEC_OPENCLAW_AGENT_2026_05_17.md §6β.
216    //
217    // The previous scaffold pointed at `acpx` / `@openclaw/acpx`,
218    // which is not a real package. The canonical binary is `openclaw`
219    // (npm: `openclaw`) and the ACP subcommand is `openclaw acp`.
220    // Verified against docs.openclaw.ai/cli/acp + GitHub README.
221    cli_command: "openclaw",
222    controller_type: ControllerType::Acp,
223    launch_args: &["acp"],
224    persistent_launch_args: None,
225    // ACP handles sessions natively — no resume flag or session ID parsing needed
226    resume_flag: None,
227    session_id_field: "sessionId",
228    styled_output_format: "acp",
229    auth_config_dir_env_var: "OPENCLAW_HOME",
230    auth_dir_name: "openclaw",
231    auth_extra_env: &[],
232    unset_env: &[],
233    npm_package: "openclaw",
234    pinned_version: "latest",
235    icon: "lobster",
236    docs_url: "https://docs.openclaw.ai",
237};
238
239static PI: ProviderConfig = ProviderConfig {
240    id: "pi",
241    display_name: "Pi",
242    cli_command: "pi",
243    controller_type: ControllerType::Acp,
244    launch_args: &["--json"],
245    persistent_launch_args: None,
246    resume_flag: None,
247    session_id_field: "sessionId",
248    styled_output_format: "acp",
249    auth_config_dir_env_var: "PI_HOME",
250    auth_dir_name: "pi",
251    auth_extra_env: &[],
252    unset_env: &[],
253    npm_package: "@mariozechner/pi-coding-agent",
254    pinned_version: "latest",
255    icon: "terminal",
256    docs_url: "https://github.com/badlogic/pi-mono",
257};
258
259// GitHub Copilot CLI — Microsoft's coding agent. Runs in ACP mode via
260// `--acp` so the existing ACP controller drives it. Non-interactive
261// `-p`/`--prompt` doesn't accept stdin prompts (github/copilot-cli#96,
262// #1046), hence ACP.
263static COPILOT: ProviderConfig = ProviderConfig {
264    id: "copilot",
265    display_name: "GitHub Copilot CLI",
266    cli_command: "copilot",
267    controller_type: ControllerType::Acp,
268    launch_args: &["--acp"],
269    persistent_launch_args: None,
270    resume_flag: None,
271    session_id_field: "sessionId",
272    styled_output_format: "acp",
273    auth_config_dir_env_var: "COPILOT_HOME",
274    auth_dir_name: "copilot",
275    auth_extra_env: &[],
276    unset_env: &[],
277    npm_package: "@github/copilot",
278    pinned_version: "latest",
279    icon: "github",
280    docs_url: "https://docs.github.com/copilot/concepts/agents/about-copilot-cli",
281};
282
283// ─── Static registry ─────────────────────────────────────────────────────────
284
285static REGISTRY: LazyLock<HashMap<&'static str, &'static ProviderConfig>> = LazyLock::new(|| {
286    let mut m = HashMap::new();
287    m.insert(CLAUDE.id, &CLAUDE);
288    m.insert(CODEX.id, &CODEX);
289    m.insert(GEMINI.id, &GEMINI);
290    m.insert(KIMI.id, &KIMI);
291    m.insert(OPENCLAW.id, &OPENCLAW);
292    m.insert(PI.id, &PI);
293    m.insert(COPILOT.id, &COPILOT);
294    m
295});
296
297// Aliases for provider IDs from older databases or alternate naming.
298static ALIASES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
299    let mut m = HashMap::new();
300    m.insert("claude-code", "claude");
301    m.insert("claude_code", "claude");
302    m.insert("codex-cli", "codex");
303    m.insert("gemini-cli", "gemini");
304    m.insert("kimi-cli", "kimi");
305    m.insert("kimi_code", "kimi");
306    m.insert("openclaw-cli", "openclaw");
307    m.insert("open-claw", "openclaw");
308    m.insert("copilot-cli", "copilot");
309    m.insert("github-copilot", "copilot");
310    m.insert("copilot_cli", "copilot");
311    m
312});
313
314// ─── Public API ──────────────────────────────────────────────────────────────
315
316/// Resolve a provider alias to its canonical ID.
317///
318/// Returns `id` unchanged if it is not a known alias.
319pub fn resolve_provider_alias(id: &str) -> &'static str {
320    ALIASES.get(id).copied().unwrap_or_else(|| {
321        // If the id itself is a canonical key return the interned key, otherwise
322        // return a best-effort static ref. The caller should treat the return
323        // value as a lookup key only.
324        REGISTRY
325            .get_key_value(id)
326            .map(|(k, _)| *k)
327            .unwrap_or("") // unknown — get_provider will return None
328    })
329}
330
331/// Look up a provider by canonical ID or alias.
332///
333/// Returns `None` when the ID (and any resolved alias) does not match a known
334/// provider.
335pub fn get_provider(id: &str) -> Option<&'static ProviderConfig> {
336    // Direct lookup first.
337    if let Some(p) = REGISTRY.get(id) {
338        return Some(p);
339    }
340    // Fall back to alias resolution.
341    let canonical = ALIASES.get(id).copied()?;
342    REGISTRY.get(canonical).copied()
343}
344
345/// Return an iterator over all registered providers in insertion order.
346pub fn get_provider_list() -> impl Iterator<Item = &'static ProviderConfig> {
347    // Stable canonical order matches the TypeScript PROVIDERS object order.
348    static ORDER: &[&str] = &["claude", "codex", "gemini", "kimi", "openclaw", "pi", "copilot"];
349    ORDER.iter().filter_map(|id| REGISTRY.get(*id).copied())
350}
351
352// ─── Tests ───────────────────────────────────────────────────────────────────
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn canonical_ids_resolve() {
360        assert!(get_provider("claude").is_some());
361        assert!(get_provider("codex").is_some());
362        assert!(get_provider("gemini").is_some());
363        assert!(get_provider("kimi").is_some());
364        assert!(get_provider("openclaw").is_some());
365    }
366
367    #[test]
368    fn aliases_resolve() {
369        assert_eq!(get_provider("claude-code").unwrap().id, "claude");
370        assert_eq!(get_provider("claude_code").unwrap().id, "claude");
371        assert_eq!(get_provider("codex-cli").unwrap().id, "codex");
372        assert_eq!(get_provider("gemini-cli").unwrap().id, "gemini");
373        assert_eq!(get_provider("kimi-cli").unwrap().id, "kimi");
374        assert_eq!(get_provider("openclaw-cli").unwrap().id, "openclaw");
375    }
376
377    #[test]
378    fn unknown_returns_none() {
379        assert!(get_provider("unknown-provider").is_none());
380    }
381
382    #[test]
383    fn provider_list_has_seven_entries() {
384        // Update this when a provider is added or removed; a stale
385        // count is the cheapest detection mechanism for accidental
386        // additions.
387        assert_eq!(get_provider_list().count(), 7);
388    }
389
390    #[test]
391    fn claude_subprocess_with_persistent_args_present() {
392        let p = get_provider("claude").unwrap();
393        assert!(p.persistent_launch_args.is_some());
394        assert_eq!(p.controller_type, ControllerType::Subprocess);
395    }
396
397    #[test]
398    fn codex_resume_flag_is_none() {
399        let p = get_provider("codex").unwrap();
400        assert!(p.resume_flag.is_none());
401        assert_eq!(p.controller_type, ControllerType::Subprocess);
402    }
403
404    #[test]
405    fn gemini_auth_extra_env() {
406        let p = get_provider("gemini").unwrap();
407        assert!(p
408            .auth_extra_env
409            .iter()
410            .any(|(k, v)| *k == "GEMINI_FORCE_FILE_STORAGE" && *v == "true"));
411    }
412
413    #[test]
414    fn kimi_is_subprocess_controller() {
415        let p = get_provider("kimi").unwrap();
416        assert_eq!(p.controller_type, ControllerType::Subprocess);
417        assert_eq!(p.controller_type_str(), "subprocess");
418        assert_eq!(p.styled_output_format, "kimi-stream-json");
419        assert_eq!(p.cli_command, "kimi");
420        assert!(p.npm_package.is_empty());
421    }
422
423    #[test]
424    fn openclaw_is_acp_controller() {
425        let p = get_provider("openclaw").unwrap();
426        assert_eq!(p.controller_type, ControllerType::Acp);
427        assert_eq!(p.controller_type_str(), "acp");
428        assert_eq!(p.styled_output_format, "acp");
429        assert!(p.resume_flag.is_none());
430    }
431
432    #[test]
433    fn pi_is_acp_controller() {
434        let p = get_provider("pi").unwrap();
435        assert_eq!(p.controller_type, ControllerType::Acp);
436        assert_eq!(p.controller_type_str(), "acp");
437        assert_eq!(p.styled_output_format, "acp");
438        assert_eq!(p.cli_command, "pi");
439        assert_eq!(p.npm_package, "@mariozechner/pi-coding-agent");
440    }
441}