use std::path::{Path, PathBuf};
use bitrouter_config::{AgentConfig, Distribution};
use tokio::process::Command;
use tokio::sync::mpsc;
use super::install::install_binary_agent;
use super::state::{self, InstallMethod, InstallRecord, now_unix_seconds};
use super::types::InstallProgress;
#[derive(Debug, Clone)]
pub struct InstalledAgent {
pub agent_id: String,
pub method: InstallMethod,
pub binary_path: Option<PathBuf>,
}
pub async fn install_agent(
agent_id: &str,
config: &AgentConfig,
install_dir: &Path,
state_file: &Path,
version: &str,
progress_tx: mpsc::Sender<InstallProgress>,
) -> Result<InstalledAgent, String> {
if config.distribution.is_empty() {
return Err(format!("no distribution methods declared for {agent_id}"));
}
for dist in &config.distribution {
match dist {
Distribution::Npx { package, .. } => {
if which("npx").is_none() {
continue;
}
run_install_command("npm", &["install", "-g", package], &progress_tx).await?;
let record = InstallRecord {
id: agent_id.to_owned(),
version: version.to_owned(),
method: InstallMethod::Npx,
resolved_binary_path: None,
installed_at: now_unix_seconds(),
};
state::upsert_record(state_file, record).await?;
let _ = progress_tx
.send(InstallProgress::Done(PathBuf::from("npx")))
.await;
return Ok(InstalledAgent {
agent_id: agent_id.to_owned(),
method: InstallMethod::Npx,
binary_path: None,
});
}
Distribution::Uvx { package, .. } => {
if which("uvx").is_none() {
continue;
}
run_install_command("uv", &["tool", "install", package], &progress_tx).await?;
let record = InstallRecord {
id: agent_id.to_owned(),
version: version.to_owned(),
method: InstallMethod::Uvx,
resolved_binary_path: None,
installed_at: now_unix_seconds(),
};
state::upsert_record(state_file, record).await?;
let _ = progress_tx
.send(InstallProgress::Done(PathBuf::from("uvx")))
.await;
return Ok(InstalledAgent {
agent_id: agent_id.to_owned(),
method: InstallMethod::Uvx,
binary_path: None,
});
}
Distribution::Binary { platforms } => {
let binary_path =
install_binary_agent(agent_id, install_dir, platforms, progress_tx.clone())
.await?;
let record = InstallRecord {
id: agent_id.to_owned(),
version: version.to_owned(),
method: InstallMethod::Binary,
resolved_binary_path: Some(binary_path.clone()),
installed_at: now_unix_seconds(),
};
state::upsert_record(state_file, record).await?;
return Ok(InstalledAgent {
agent_id: agent_id.to_owned(),
method: InstallMethod::Binary,
binary_path: Some(binary_path),
});
}
}
}
Err(format!(
"no installable distribution for {agent_id} on this system \
(tried npx/uvx/binary, none of the required runtimes were available)"
))
}
pub async fn uninstall_agent(
agent_id: &str,
install_dir: &Path,
state_file: &Path,
) -> Result<(), String> {
if let Some(record) = state::find_record(state_file, agent_id).await? {
match record.method {
InstallMethod::Binary => {
if install_dir.exists() {
tokio::fs::remove_dir_all(install_dir)
.await
.map_err(|e| format!("failed to remove {}: {e}", install_dir.display()))?;
}
}
InstallMethod::Npx => {
tracing::debug!(
agent_id,
"npx-installed agent — leaving npm global cache intact"
);
}
InstallMethod::Uvx => {
tracing::debug!(agent_id, "uvx-installed agent — leaving uv tool dir intact");
}
}
}
state::remove_record(state_file, agent_id).await
}
async fn run_install_command(
program: &str,
args: &[&str],
progress_tx: &mpsc::Sender<InstallProgress>,
) -> Result<(), String> {
let _ = progress_tx
.send(InstallProgress::Downloading {
bytes_received: 0,
total: None,
})
.await;
let output = Command::new(program)
.args(args)
.output()
.await
.map_err(|e| format!("failed to spawn {program}: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{program} {} failed: {}",
args.join(" "),
stderr.trim()
));
}
let _ = progress_tx.send(InstallProgress::Extracting).await;
Ok(())
}
fn which(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use bitrouter_config::AgentProtocol;
use tempfile::TempDir;
fn make_config(distribution: Vec<Distribution>) -> AgentConfig {
AgentConfig {
protocol: AgentProtocol::Acp,
binary: "test-agent".to_owned(),
args: Vec::new(),
enabled: true,
distribution,
session: None,
a2a: None,
}
}
#[tokio::test]
async fn empty_distribution_is_rejected() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let state = dir.path().join("state.json");
let install = dir.path().join("install");
let (tx, _rx) = mpsc::channel(8);
let err = install_agent(
"nobody",
&make_config(Vec::new()),
&install,
&state,
"1.0.0",
tx,
)
.await
.err()
.ok_or("expected error")?;
assert!(err.contains("no distribution methods"), "got: {err}");
Ok(())
}
#[tokio::test]
async fn uninstall_missing_agent_is_noop() -> Result<(), String> {
let dir = TempDir::new().map_err(|e| e.to_string())?;
let state = dir.path().join("state.json");
let install = dir.path().join("never-installed");
uninstall_agent("ghost", &install, &state).await?;
Ok(())
}
}