ralph 0.1.5

A CLI agent harness for running AI coding agents (Codex, Claude, Pi, Gemini)
use anyhow::{bail, Result};
use std::process::Command;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum PackageManager {
    Brew,
    Npm,
    Cargo,
    Pipx,
    Pip,
    Winget,
}

impl PackageManager {
    fn command(self) -> &'static str {
        match self {
            PackageManager::Brew => "brew",
            PackageManager::Npm => "npm",
            PackageManager::Cargo => "cargo",
            PackageManager::Pipx => "pipx",
            PackageManager::Pip => "pip",
            PackageManager::Winget => "winget",
        }
    }

    fn label(self) -> &'static str {
        self.command()
    }
}

#[derive(Debug, Clone, Copy)]
struct InstallTarget {
    name: &'static str,
    command: &'static str,
    description: &'static str,
}

const INSTALLABLE_AGENTS: &[InstallTarget] = &[
    InstallTarget {
        name: "codex",
        command: "codex",
        description: "OpenAI Codex CLI",
    },
    InstallTarget {
        name: "claude",
        command: "claude",
        description: "Anthropic Claude Code",
    },
    InstallTarget {
        name: "gemini",
        command: "gemini",
        description: "Google Gemini CLI",
    },
    InstallTarget {
        name: "pi",
        command: "pi",
        description: "Pi Coding Agent",
    },
];

#[derive(Debug, Clone, Copy)]
struct InstallMethod {
    agent: &'static str,
    manager: PackageManager,
    program: &'static str,
    args: &'static [&'static str],
}

const INSTALL_METHODS: &[InstallMethod] = &[
    InstallMethod {
        agent: "codex",
        manager: PackageManager::Npm,
        program: "npm",
        args: &["install", "-g", "@openai/codex"],
    },
    InstallMethod {
        agent: "claude",
        manager: PackageManager::Npm,
        program: "npm",
        args: &["install", "-g", "@anthropic-ai/claude-code"],
    },
    InstallMethod {
        agent: "gemini",
        manager: PackageManager::Npm,
        program: "npm",
        args: &["install", "-g", "@google/gemini-cli"],
    },
    InstallMethod {
        agent: "pi",
        manager: PackageManager::Npm,
        program: "npm",
        args: &["install", "-g", "@mariozechner/pi-coding-agent"],
    },
];

const PACKAGE_MANAGER_ORDER: &[PackageManager] = &[
    PackageManager::Brew,
    PackageManager::Npm,
    PackageManager::Cargo,
    PackageManager::Pipx,
    PackageManager::Pip,
    PackageManager::Winget,
];

pub fn run_install(agent: Option<String>, all: bool, list: bool) -> Result<()> {
    let managers = detect_package_managers();

    let targets = resolve_targets(agent, all, list)?;

    if list {
        print_install_list(&targets, &managers);
        return Ok(());
    }

    if targets.is_empty() {
        bail!("Specify an agent name or use --all/--list");
    }

    if managers.is_empty() {
        let supported = supported_manager_labels();
        bail!(
            "No supported package manager detected. Install one of: {}.",
            supported.join(", ")
        );
    }

    let mut installed_any = false;
    for target in targets {
        if is_installed(target.command) {
            println!("✓ {} already installed", target.name);
            continue;
        }

        let method = match select_method(target.name, &managers) {
            Some(method) => method,
            None => {
                let supported = supported_managers(target.name);
                if supported.is_empty() {
                    bail!("No install method available for {}", target.name);
                }
                bail!(
                    "No supported package manager found for {}. Requires: {}",
                    target.name,
                    supported.join(", ")
                );
            }
        };

        println!(
            "Installing {} via {}...",
            target.name,
            method.manager.label()
        );
        println!("> {}", format_command(method));

        let status = Command::new(method.program).args(method.args).status()?;
        if !status.success() {
            bail!("Install command failed for {}", target.name);
        }
        println!("✓ {} installed", target.name);
        installed_any = true;
    }

    if all && !installed_any {
        println!("All installable agents are already installed.");
    }

    Ok(())
}

fn detect_package_managers() -> Vec<PackageManager> {
    let supported: std::collections::HashSet<PackageManager> =
        INSTALL_METHODS.iter().map(|m| m.manager).collect();
    PACKAGE_MANAGER_ORDER
        .iter()
        .copied()
        .filter(|manager| supported.contains(manager) && which::which(manager.command()).is_ok())
        .collect()
}

fn resolve_targets(
    agent: Option<String>,
    all: bool,
    list: bool,
) -> Result<Vec<&'static InstallTarget>> {
    if let Some(agent) = agent {
        let agent = agent.to_lowercase();
        if let Some(target) = find_target(&agent) {
            return Ok(vec![target]);
        }
        bail!(
            "Unknown agent '{}'. Supported agents: {}",
            agent,
            INSTALLABLE_AGENTS
                .iter()
                .map(|t| t.name)
                .collect::<Vec<_>>()
                .join(", ")
        );
    }

    if all || list {
        return Ok(INSTALLABLE_AGENTS.iter().collect());
    }

    Ok(Vec::new())
}

fn find_target(name: &str) -> Option<&'static InstallTarget> {
    INSTALLABLE_AGENTS.iter().find(|t| t.name == name)
}

fn is_installed(command: &str) -> bool {
    which::which(command).is_ok()
}

fn supported_managers(agent: &str) -> Vec<&'static str> {
    let mut managers: Vec<&'static str> = INSTALL_METHODS
        .iter()
        .filter(|m| m.agent == agent)
        .map(|m| m.manager.label())
        .collect();
    managers.sort();
    managers.dedup();
    managers
}

fn supported_manager_labels() -> Vec<&'static str> {
    let mut managers: Vec<&'static str> =
        INSTALL_METHODS.iter().map(|m| m.manager.label()).collect();
    managers.sort();
    managers.dedup();
    managers
}

fn select_method(agent: &str, managers: &[PackageManager]) -> Option<&'static InstallMethod> {
    for manager in managers {
        if let Some(method) = INSTALL_METHODS
            .iter()
            .find(|m| m.agent == agent && m.manager == *manager)
        {
            return Some(method);
        }
    }
    None
}

fn format_command(method: &InstallMethod) -> String {
    let mut parts = Vec::with_capacity(1 + method.args.len());
    parts.push(method.program);
    parts.extend(method.args.iter().copied());
    parts.join(" ")
}

fn print_install_list(targets: &[&InstallTarget], managers: &[PackageManager]) {
    if managers.is_empty() {
        println!("Detected package managers: none");
    } else {
        let detected = managers
            .iter()
            .map(|m| m.label())
            .collect::<Vec<_>>()
            .join(", ");
        println!("Detected package managers: {}", detected);
    }

    println!();
    println!("Install commands:");
    for target in targets {
        if let Some(method) = select_method(target.name, managers) {
            println!(
                "  {:<8} {} ({})",
                target.name,
                format_command(method),
                method.manager.label()
            );
        } else {
            let supported = supported_managers(target.name);
            if supported.is_empty() {
                println!("  {:<8} (no install method available)", target.name);
            } else {
                println!("  {:<8} (requires: {})", target.name, supported.join(", "));
            }
        }
    }

    println!();
    println!("Installable agents:");
    for target in targets {
        println!("  {:<8} {}", target.name, target.description);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_select_method_prefers_order() {
        let managers = vec![PackageManager::Npm, PackageManager::Cargo];
        let method = select_method("codex", &managers).unwrap();
        assert_eq!(method.program, "npm");
    }

    #[test]
    fn test_supported_managers_list() {
        let managers = supported_managers("codex");
        assert!(managers.contains(&"npm"));
    }
}