mur_core/model/
cli_provider.rs1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tokio::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CliProviderConfig {
14 pub name: String,
16 pub binary: String,
18 #[serde(default)]
20 pub args: Vec<String>,
21 #[serde(default = "default_timeout")]
23 pub timeout_secs: u64,
24}
25
26fn default_timeout() -> u64 {
27 120
28}
29
30#[derive(Debug, Clone)]
32pub struct CliProvider {
33 pub config: CliProviderConfig,
34}
35
36impl CliProvider {
37 pub fn new(config: CliProviderConfig) -> Self {
39 Self { config }
40 }
41
42 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 pub fn is_available(&self) -> bool {
56 Path::new(&self.config.binary).exists()
57 }
58
59 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 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 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; }
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 let providers = CliProvider::detect_all();
219 for p in &providers {
221 assert!(!p.config.name.is_empty());
222 assert!(p.is_available());
223 }
224 }
225}