diff --git a/Cargo.lock b/Cargo.lock index 74becac..e2fab5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 46d3fa2..50d86e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.2" +version = "0.2.3" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/src/deps.rs b/src/deps.rs index a5f8ed8..b45c251 100644 --- a/src/deps.rs +++ b/src/deps.rs @@ -151,19 +151,13 @@ pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<( let proceed = app .confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?; if proceed { - if let Ok(tokens) = shell_words::split(cmd) { - if !tokens.is_empty() { - let prog = tokens[0].clone(); - let args: Vec = tokens[1..].to_vec(); - let empty = BTreeMap::new(); - let out = runner.run(&prog, &args, &empty, None, false)?; - if out.code != 0 { - app.log.warn(&format!( - "installation of '{}' failed (exit code {})", - dep.name, out.code - )); - } - } + let code = crate::toolchain::run_install_command(runner, cmd)?; + crate::toolchain::refresh_path(); + if code != 0 { + app.log.warn(&format!( + "installation of '{}' failed (exit code {code})", + dep.name + )); } } } diff --git a/src/doctor.rs b/src/doctor.rs index 473a7e2..09704b6 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -65,7 +65,12 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport { ]; let os = crate::toolchain::OsInfo::detect(); for (tool, required) in tools { - match runner.which(tool) { + let found = if *tool == "python" { + runner.which("python3").or_else(|| runner.which("python")) + } else { + runner.which(tool) + }; + match found { Some(path) => { let mut detail = path.display().to_string(); let empty = BTreeMap::new(); @@ -211,8 +216,8 @@ fn is_on_path(dir: &std::path::Path) -> bool { pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result> { let mut installed = Vec::new(); let os = crate::toolchain::OsInfo::detect(); - for tool in ["node", "python", "cargo", "go", "bun", "uv", "docker"] { - if runner.which(tool).is_some() { + for tool in ["git", "node", "python", "cargo", "go", "bun", "uv", "docker"] { + if crate::toolchain::tool_is_present(tool, runner) { continue; } let cmds = crate::toolchain::install_commands(tool, &os); @@ -225,19 +230,13 @@ pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result = tokens[1..].to_vec(); - let empty = BTreeMap::new(); - let out = runner.run(&prog, &args, &empty, None, false)?; - installed.push(if out.code == 0 { - format!("installed '{tool}' ({cmd})") - } else { - format!("failed to install '{tool}' (exit code {})", out.code) - }); - } - } + let code = crate::toolchain::run_install_command(runner, cmd)?; + crate::toolchain::refresh_path(); + installed.push(if code == 0 { + format!("installed '{tool}' ({cmd})") + } else { + format!("failed to install '{tool}' (exit code {code})") + }); } Ok(installed) } diff --git a/src/toolchain.rs b/src/toolchain.rs index db207c8..5e1b3cc 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -5,6 +5,10 @@ //! already present (scoop/winget on Windows, apt/dnf/pacman/apk on Linux, //! brew on macOS) — and proposes the exact command to install it. +use crate::runner::Runner; +use anyhow::Result; +use std::path::PathBuf; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Family { Windows, @@ -130,6 +134,66 @@ pub fn install_commands(tool: &str, os: &OsInfo) -> Vec { out } +/// Run an installer command line. Commands containing shell syntax +/// (pipes, redirections, ...) are executed through the shell; plain +/// commands are spawned directly. +pub fn run_install_command(runner: &dyn Runner, cmd: &str) -> Result { + let needs_shell = cmd.contains('|') + || cmd.contains('>') + || cmd.contains('<') + || cmd.contains('&') + || cmd.contains(';'); + let (prog, args) = if needs_shell { + #[cfg(windows)] + { + ("cmd".to_string(), vec!["/C".to_string(), cmd.to_string()]) + } + #[cfg(not(windows))] + { + ("sh".to_string(), vec!["-c".to_string(), cmd.to_string()]) + } + } else { + let tokens = shell_words::split(cmd) + .unwrap_or_else(|_| cmd.split_whitespace().map(String::from).collect()); + if tokens.is_empty() { + return Ok(0); + } + (tokens[0].clone(), tokens[1..].to_vec()) + }; + let empty = std::collections::BTreeMap::new(); + let out = runner.run(&prog, &args, &empty, None, false)?; + Ok(out.code) +} + +/// After installers that modify ~/.profile or ~/.zshrc (rustup, bun, uv), +/// make the newly installed tools visible to the current process so the +/// dependency re-check can find them. +pub fn refresh_path() { + let mut extra: Vec = Vec::new(); + if let Some(home) = crate::config::home_dir() { + extra.push(home.join(".cargo").join("bin")); + extra.push(home.join(".bun").join("bin")); + extra.push(home.join(".local").join("bin")); + } + let mut parts: Vec = extra.into_iter().filter(|p| p.is_dir()).collect(); + if let Some(current) = std::env::var_os("PATH") { + parts.extend(std::env::split_paths(¤t)); + } + if let Ok(joined) = std::env::join_paths(parts) { + std::env::set_var("PATH", joined); + } +} + +/// Is a tool considered present? Python is satisfied by python3 too +/// (Debian ships python3 without a python symlink). +pub fn tool_is_present(tool: &str, runner: &dyn Runner) -> bool { + if tool == "python" { + runner.which("python3").is_some() || runner.which("python").is_some() + } else { + runner.which(tool).is_some() + } +} + fn scoop_command(tool: &str) -> Option { let bucket = match tool { "git" => "git", @@ -208,7 +272,7 @@ fn linux_command(tool: &str, pkg: &str) -> Option { ("pip", "apk") => "sudo apk add py3-pip", ("pip", "zypper") => "sudo zypper install -y python3-pip", ("cargo" | "rust", "apt") => { - "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" } ("cargo" | "rust", "dnf") => "sudo dnf install -y rust cargo", ("cargo" | "rust", "pacman") => "sudo pacman -S --noconfirm rust", @@ -278,6 +342,36 @@ mod tests { assert!(brew_command("docker").unwrap().contains("--cask")); } + #[test] + fn pipe_commands_run_through_shell() { + let runner = crate::runner::MockRunner::new(); + let code = run_install_command(&runner, "curl -sSf https://x | sh").unwrap(); + assert_eq!(code, 0); + let calls = runner.snapshot(); + assert_eq!(calls.len(), 1); + if cfg!(windows) { + assert!(calls[0].starts_with("cmd /C ")); + } else { + assert!(calls[0].starts_with("sh -c ")); + } + } + + #[test] + fn plain_commands_spawn_directly() { + let runner = crate::runner::MockRunner::new(); + run_install_command(&runner, "apt install -y git").unwrap(); + let calls = runner.snapshot(); + assert!(calls[0].starts_with("apt install -y git")); + } + + #[test] + fn python3_satisfies_python() { + let runner = crate::runner::MockRunner::new().with_found("python3"); + assert!(tool_is_present("python", &runner)); + let runner = crate::runner::MockRunner::new(); + assert!(!tool_is_present("python", &runner)); + } + #[test] fn os_aware_selection() { // Windows without scoop falls back to winget.