use anyhow::Result;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use crate::config::ProviderEntry;
#[derive(Debug, Clone)]
pub enum ModelFlag {
Flag(String),
Env(String),
Skip,
}
#[derive(Debug, Clone)]
pub struct CliProvider {
pub binary: String,
pub args: Vec<String>,
pub yolo_args: Vec<String>,
pub model_flag: ModelFlag,
pub yolo_env: Vec<(String, String)>,
pub max_turns_flag: Option<String>,
}
impl CliProvider {
pub fn from_provider(entry: &ProviderEntry) -> Option<Self> {
let binary = entry.cli.as_ref()?.clone();
if binary.contains('/') || binary.contains('\\') || binary.contains("..") {
tracing::warn!(binary = %binary, "CLI provider binary contains path separators — rejected");
return None;
}
let model_flag = if entry.cli_skip_model {
ModelFlag::Skip
} else if let Some(env_var) = &entry.cli_model_env {
ModelFlag::Env(env_var.clone())
} else {
ModelFlag::Flag("--model".into())
};
let yolo_env = entry
.cli_yolo_env
.iter()
.filter_map(|kv| {
let mut parts = kv.splitn(2, '=');
Some((parts.next()?.to_string(), parts.next()?.to_string()))
})
.collect();
Some(Self {
binary,
args: entry.cli_args.clone(),
yolo_args: entry.cli_yolo_args.clone(),
yolo_env,
model_flag,
max_turns_flag: entry.cli_max_turns_flag.clone(),
})
}
fn build_command(
&self,
prompt: &str,
working_dir: &str,
model: Option<&str>,
yolo: bool,
max_iterations: Option<u32>,
) -> Command {
let mut cmd = Command::new(&self.binary);
cmd.args(&self.args);
if yolo {
if !self.yolo_args.is_empty() {
cmd.args(&self.yolo_args);
}
for (k, v) in &self.yolo_env {
cmd.env(k, v);
}
}
match &self.model_flag {
ModelFlag::Flag(flag) => {
if let Some(m) = model
&& m != "default"
{
cmd.args([flag.as_str(), m]);
}
}
ModelFlag::Env(var) => {
if let Some(m) = model
&& m != "default"
{
cmd.env(var, m);
}
}
ModelFlag::Skip => {}
}
if let (Some(flag), Some(n)) = (&self.max_turns_flag, max_iterations) {
cmd.args([flag.as_str(), &n.to_string()]);
}
cmd.arg(prompt)
.current_dir(working_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd
}
#[allow(dead_code)]
pub async fn execute(
&self,
prompt: &str,
working_dir: &str,
model: Option<&str>,
yolo: bool,
max_iterations: Option<u32>,
) -> Result<String> {
let mut cmd = self.build_command(prompt, working_dir, model, yolo, max_iterations);
tracing::info!(binary = %self.binary, yolo, "Spawning CLI provider");
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"CLI `{}` exited with {}: {}",
self.binary,
output.status,
stderr.chars().take(500).collect::<String>()
);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[allow(dead_code)]
pub async fn execute_streaming(
&self,
prompt: &str,
working_dir: &str,
tx: mpsc::UnboundedSender<String>,
model: Option<&str>,
yolo: bool,
max_iterations: Option<u32>,
) -> Result<String> {
let mut cmd = self.build_command(prompt, working_dir, model, yolo, max_iterations);
tracing::info!(binary = %self.binary, yolo, "Spawning CLI provider (streaming)");
let mut child = cmd.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("No stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("No stderr"))?;
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let mut stderr = stderr;
let _ = stderr.read_to_end(&mut buf).await;
});
let mut reader = BufReader::new(stdout).lines();
let mut full_response = String::new();
while let Some(line) = reader.next_line().await? {
full_response.push_str(&line);
full_response.push('\n');
let _ = tx.send(line);
}
let status = child.wait().await?;
if !status.success() {
anyhow::bail!("CLI `{}` exited with {}", self.binary, status);
}
Ok(full_response.trim().to_string())
}
pub fn spawn_child(
&self,
prompt: &str,
working_dir: &str,
model: Option<&str>,
yolo: bool,
max_iterations: Option<u32>,
) -> Result<tokio::process::Child> {
let mut cmd = self.build_command(prompt, working_dir, model, yolo, max_iterations);
tracing::info!(binary = %self.binary, yolo, "Spawning CLI provider (direct)");
Ok(cmd.spawn()?)
}
pub fn is_available(&self) -> bool {
if std::path::Path::new(&self.binary).is_absolute() {
return std::path::Path::new(&self.binary).exists();
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(&self.binary).exists()))
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_execute_with_echo() {
let provider = CliProvider {
binary: "echo".into(),
args: vec![],
yolo_args: vec![],
model_flag: ModelFlag::Flag("--model".into()),
yolo_env: vec![],
max_turns_flag: None,
};
let result = provider
.execute("hello world", ".", None, false, None)
.await;
assert!(result.is_ok(), "echo should succeed");
assert!(result.unwrap().contains("hello world"));
}
#[test]
fn test_is_available_echo() {
let provider = CliProvider {
binary: "echo".into(),
args: vec![],
yolo_args: vec![],
model_flag: ModelFlag::Flag("--model".into()),
yolo_env: vec![],
max_turns_flag: None,
};
assert!(provider.is_available(), "echo is always available");
}
#[test]
fn test_is_available_nonexistent() {
let provider = CliProvider {
binary: "collet-nonexistent-binary-xyz".into(),
args: vec![],
yolo_args: vec![],
model_flag: ModelFlag::Flag("--model".into()),
yolo_env: vec![],
max_turns_flag: None,
};
assert!(!provider.is_available());
}
}