//! pip and uv installers: a private virtualenv per agent keeps everything //! self-contained (no --user, no system pollution). use super::*; fn find_python(runner: &dyn Runner) -> Result { runner .which("python3") .or_else(|| runner.which("python")) .ok_or_else(|| { anyhow!( "neither 'python3' nor 'python' found on PATH — install Python first (https://python.org)" ) }) } fn venv_bin_dir(venv: &Path) -> PathBuf { if cfg!(windows) { venv.join("Scripts") } else { venv.join("bin") } } fn venv_python(venv: &Path) -> PathBuf { venv_bin_dir(venv).join(if cfg!(windows) { "python.exe" } else { "python" }) } /// Shared implementation for pip (use_uv=false) and uv (use_uv=true). /// /// The uv path creates the venv with 'uv venv': uv downloads and manages a /// Python interpreter itself when no working system Python exists, so uv /// installs do not depend on a system Python at all. pub fn install( app: &App, agent: &AgentDef, method: &MethodSpec, runner: &dyn Runner, use_uv: bool, ) -> Result { let pkg = method .package .as_deref() .ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?; let root = install_root(app, &agent.name); let venv = root.join("venv"); ensure_dir(app, &root)?; if !app.dry_run() && venv.exists() { std::fs::remove_dir_all(&venv) .with_context(|| format!("cannot remove {}", venv.display()))?; } let empty = BTreeMap::new(); if use_uv { let uv = require_tool( runner, "uv", "https://docs.astral.sh/uv — e.g. 'pip install uv'", )?; sh( runner, &uv.display().to_string(), &[ "venv".into(), "--python".into(), "3.13".into(), venv.display().to_string(), ], &empty, None, )?; let vp = venv_python(&venv); let vbin = venv_bin_dir(&venv); let mut args: Vec = vec![ "pip".into(), "install".into(), "--python".into(), vp.display().to_string(), pkg.to_string(), ]; args.extend(method.args.iter().cloned()); sh(runner, &uv.display().to_string(), &args, &method.env, None)?; } else { let python = find_python(runner)?; sh( runner, &python.display().to_string(), &[ "-m".to_string(), "venv".to_string(), venv.display().to_string(), ], &empty, None, )?; let vp = venv_python(&venv); let vbin = venv_bin_dir(&venv); let mut args: Vec = vec![ "-m".into(), "pip".into(), "install".into(), "--upgrade".into(), pkg.to_string(), ]; args.extend(method.args.iter().cloned()); sh(runner, &vp.display().to_string(), &args, &method.env, None)?; } let expected = expected_bin_names(agent, method); let mut bins = find_bins(&venv_bin_dir(&venv), &expected); if bins.is_empty() { bins = find_bins(&bin_dir(app), &expected); } let version = bins.first().and_then(|b| detect_version(runner, b)); Ok(InstallOutcome { bins, version }) } /// Latest published version from the PyPI JSON API. pub fn latest( _app: &App, _agent: &AgentDef, method: &MethodSpec, _runner: &dyn Runner, ) -> Result> { let Some(pkg) = method.package.as_deref() else { return Ok(None); }; let url = format!("https://pypi.org/pypi/{pkg}/json"); let resp = match ureq::get(&url) .set("User-Agent", crate::download::USER_AGENT) .call() { Ok(r) => r, Err(_) => return Ok(None), }; let Ok(text) = resp.into_string() else { return Ok(None); }; let Ok(v) = serde_json::from_str::(&text) else { return Ok(None); }; Ok(v.get("info") .and_then(|i| i.get("version")) .and_then(|x| x.as_str()) .map(|s| s.to_string())) }