Skip to main content

mur_core/model/
cli_provider.rs

1//! CLI Provider — use authenticated CLI tools (claude, gemini, auggie) as AI backends.
2//!
3//! Spawns CLI tools in print mode (`-p`) and reads stdout, avoiding the need for API keys.
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tokio::process::Command;
10
11/// Configuration for a single CLI provider.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CliProviderConfig {
14    /// Display name (e.g., "claude-cli").
15    pub name: String,
16    /// Path to the binary.
17    pub binary: String,
18    /// Arguments before the prompt (e.g., ["-p"]).
19    #[serde(default)]
20    pub args: Vec<String>,
21    /// Timeout in seconds (default: 120).
22    #[serde(default = "default_timeout")]
23    pub timeout_secs: u64,
24}
25
26fn default_timeout() -> u64 {
27    120
28}
29
30/// A CLI-based AI provider that spawns external tools.
31#[derive(Debug, Clone)]
32pub struct CliProvider {
33    pub config: CliProviderConfig,
34}
35
36impl CliProvider {
37    /// Create a new CLI provider from config.
38    pub fn new(config: CliProviderConfig) -> Self {
39        Self { config }
40    }
41
42    /// Create from individual fields.
43    pub fn from_parts(name: &str, binary: &str, args: Vec<&str>) -> Self {
44        Self {
45            config: CliProviderConfig {
46                name: name.to_string(),
47                binary: binary.to_string(),
48                args: args.into_iter().map(|s| s.to_string()).collect(),
49                timeout_secs: default_timeout(),
50            },
51        }
52    }
53
54    /// Check if the binary exists and is accessible.
55    pub fn is_available(&self) -> bool {
56        Path::new(&self.config.binary).exists()
57    }
58
59    /// Call the CLI tool with a prompt and return the response.
60    pub async fn call(&self, prompt: &str) -> Result<String> {
61        if !self.is_available() {
62            anyhow::bail!(
63                "CLI provider '{}' binary not found at: {}",
64                self.config.name,
65                self.config.binary
66            );
67        }
68
69        let mut cmd = Command::new(&self.config.binary);
70        for arg in &self.config.args {
71            cmd.arg(arg);
72        }
73        cmd.arg(prompt);
74
75        // Suppress stderr noise, capture stdout
76        cmd.stderr(std::process::Stdio::piped());
77        cmd.stdout(std::process::Stdio::piped());
78
79        let timeout = Duration::from_secs(self.config.timeout_secs);
80
81        let output = tokio::time::timeout(timeout, cmd.output())
82            .await
83            .context(format!(
84                "CLI provider '{}' timed out after {}s",
85                self.config.name, self.config.timeout_secs
86            ))?
87            .context(format!(
88                "Failed to execute CLI provider '{}'",
89                self.config.name
90            ))?;
91
92        if !output.status.success() {
93            let stderr = String::from_utf8_lossy(&output.stderr);
94            anyhow::bail!(
95                "CLI provider '{}' exited with {}: {}",
96                self.config.name,
97                output.status,
98                stderr.trim()
99            );
100        }
101
102        let stdout = String::from_utf8(output.stdout)
103            .context("CLI provider output is not valid UTF-8")?;
104
105        Ok(stdout.trim().to_string())
106    }
107
108    /// Detect all known CLI tools on the system.
109    pub fn detect_all() -> Vec<CliProvider> {
110        let home = dirs::home_dir().unwrap_or_default();
111        let npm_global = home.join(".npm-global/bin");
112
113        let known_tools: Vec<(&str, Vec<PathBuf>, Vec<&str>)> = vec![
114            (
115                "claude-cli",
116                vec![
117                    npm_global.join("claude"),
118                    PathBuf::from("/usr/local/bin/claude"),
119                    PathBuf::from("/opt/homebrew/bin/claude"),
120                ],
121                vec!["-p"],
122            ),
123            (
124                "gemini-cli",
125                vec![
126                    npm_global.join("gemini"),
127                    PathBuf::from("/usr/local/bin/gemini"),
128                    PathBuf::from("/opt/homebrew/bin/gemini"),
129                ],
130                vec!["-p"],
131            ),
132            (
133                "auggie-cli",
134                vec![
135                    npm_global.join("auggie"),
136                    PathBuf::from("/usr/local/bin/auggie"),
137                ],
138                vec![],
139            ),
140        ];
141
142        let mut providers = Vec::new();
143        for (name, paths, args) in known_tools {
144            for path in paths {
145                if path.exists() {
146                    providers.push(CliProvider::from_parts(
147                        name,
148                        path.to_str().unwrap_or_default(),
149                        args.clone(),
150                    ));
151                    break; // Use first found path
152                }
153            }
154        }
155
156        providers
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_cli_provider_config_deserialize() {
166        let toml = r#"
167            name = "claude-cli"
168            binary = "/usr/local/bin/claude"
169            args = ["-p"]
170            timeout_secs = 60
171        "#;
172        let config: CliProviderConfig = toml::from_str(toml).unwrap();
173        assert_eq!(config.name, "claude-cli");
174        assert_eq!(config.binary, "/usr/local/bin/claude");
175        assert_eq!(config.args, vec!["-p"]);
176        assert_eq!(config.timeout_secs, 60);
177    }
178
179    #[test]
180    fn test_cli_provider_config_defaults() {
181        let toml = r#"
182            name = "test"
183            binary = "/bin/echo"
184        "#;
185        let config: CliProviderConfig = toml::from_str(toml).unwrap();
186        assert!(config.args.is_empty());
187        assert_eq!(config.timeout_secs, 120);
188    }
189
190    #[test]
191    fn test_is_available_with_echo() {
192        let provider = CliProvider::from_parts("test", "/bin/echo", vec![]);
193        assert!(provider.is_available());
194    }
195
196    #[test]
197    fn test_is_available_missing() {
198        let provider = CliProvider::from_parts("test", "/nonexistent/binary", vec![]);
199        assert!(!provider.is_available());
200    }
201
202    #[tokio::test]
203    async fn test_call_echo() {
204        let provider = CliProvider::from_parts("echo", "/bin/echo", vec![]);
205        let result = provider.call("hello world").await.unwrap();
206        assert_eq!(result, "hello world");
207    }
208
209    #[tokio::test]
210    async fn test_call_missing_binary() {
211        let provider = CliProvider::from_parts("test", "/nonexistent/binary", vec![]);
212        assert!(provider.call("test").await.is_err());
213    }
214
215    #[test]
216    fn test_detect_all_runs() {
217        // Just verify it doesn't panic
218        let providers = CliProvider::detect_all();
219        // On CI this might find nothing, on dev machines it might find tools
220        for p in &providers {
221            assert!(!p.config.name.is_empty());
222            assert!(p.is_available());
223        }
224    }
225}