collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! CLI provider — spawns a headless coding agent CLI and captures output.

use anyhow::Result;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;

use crate::config::ProviderEntry;

/// How the model is passed to the CLI binary.
#[derive(Debug, Clone)]
pub enum ModelFlag {
    /// Pass `--flag <model>` (flag is e.g. "--model" or "-m").
    Flag(String),
    /// Set an environment variable to the model name.
    Env(String),
    /// Do not pass model selection at all.
    Skip,
}

/// Wraps a CLI coding agent binary for headless execution.
#[derive(Debug, Clone)]
pub struct CliProvider {
    pub binary: String,
    pub args: Vec<String>,
    /// Extra args appended when running in yolo (auto-approve) mode.
    pub yolo_args: Vec<String>,
    /// How to pass the model to the CLI binary.
    pub model_flag: ModelFlag,
    /// Environment variables to set in yolo mode.
    pub yolo_env: Vec<(String, String)>,
    /// CLI flag for max turns (e.g. "--max-turns"). None = not supported.
    pub max_turns_flag: Option<String>,
}

impl CliProvider {
    /// Create from a CLI-type `ProviderEntry`. Returns `None` if not a CLI provider.
    pub fn from_provider(entry: &ProviderEntry) -> Option<Self> {
        let binary = entry.cli.as_ref()?.clone();
        // Reject paths that could traverse directories.
        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(),
        })
    }

    /// Build a [`Command`] from args, optional model, yolo flag, and optional max iterations.
    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
    }

    /// Execute a prompt and return the full response text.
    /// If `model` is Some and not `"default"`, passes `--model <model>` to the CLI.
    #[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())
    }

    /// Execute a prompt and stream stdout lines via a channel.
    /// If `model` is Some and not `"default"`, passes `--model <model>` to the CLI.
    #[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())
    }

    /// Spawn the CLI process and return the child handle.
    /// Caller is responsible for reading stdout and waiting.
    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()?)
    }

    /// Check if the binary is available on PATH.
    pub fn is_available(&self) -> bool {
        // Avoid spawning a subprocess — check PATH directly.
        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());
    }
}