Skip to main content

construct/sidecars/
python.rs

1//! Python interpreter detection for sidecar provisioning.
2//!
3//! Hard-fails with a download link when no usable Python ≥3.11 is found.
4//! Probes candidates with `-c "import sys"` to skip the Windows Store stub
5//! which exits 9009 and writes to stderr rather than actually running.
6
7use anyhow::{Result, anyhow};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11const MIN_MAJOR: u32 = 3;
12const MIN_MINOR: u32 = 11;
13
14const DOWNLOAD_URL: &str = "https://www.python.org/downloads/";
15
16/// Platform-native default name for the Python interpreter when spawning a
17/// sidecar launcher. On Windows there is no `python3` executable — installs
18/// from python.org expose `python.exe` (or `py.exe`), so hardcoding
19/// `python3` causes "program not found" at spawn time. On Unix `python3`
20/// remains the convention.
21pub fn default_python_command() -> &'static str {
22    if cfg!(windows) { "python" } else { "python3" }
23}
24
25/// Detect the `npm` executable on PATH. Returns the resolved absolute path
26/// on success, or an error with a download link when missing. Used by the
27/// session-manager sidecar installer (Node.js sidecar that drives the
28/// Claude Agent SDK and codex CLI).
29pub fn detect_npm() -> Result<PathBuf> {
30    // On Windows the executable is npm.cmd; Command will resolve either name.
31    let candidate = if cfg!(windows) { "npm.cmd" } else { "npm" };
32    let output = Command::new(candidate)
33        .arg("--version")
34        .output()
35        .map_err(|_| {
36            anyhow!(
37                "npm not found on PATH. Install Node.js LTS (includes npm) from \
38                 https://nodejs.org/ or via your package manager (`brew install node`, \
39                 `apt install nodejs npm`, `winget install OpenJS.NodeJS`)."
40            )
41        })?;
42
43    if !output.status.success() {
44        return Err(anyhow!(
45            "npm found on PATH but `npm --version` exited with status {:?}. \
46             Verify your Node.js install: {}",
47            output.status.code(),
48            "https://nodejs.org/"
49        ));
50    }
51
52    Ok(PathBuf::from(candidate))
53}
54
55/// Resolve a usable Python interpreter, honoring `explicit` if provided.
56///
57/// Returns the absolute path to the interpreter on success.
58pub fn detect_python(explicit: Option<&str>) -> Result<PathBuf> {
59    if let Some(path) = explicit {
60        let candidate = PathBuf::from(path);
61        return probe(&candidate).ok_or_else(|| missing_python_error(Some(path)));
62    }
63
64    let candidates: &[&str] = if cfg!(windows) {
65        &["py", "python3", "python"]
66    } else {
67        &["python3", "python"]
68    };
69
70    for name in candidates {
71        if let Some(resolved) = probe(Path::new(name)) {
72            return Ok(resolved);
73        }
74    }
75
76    Err(missing_python_error(None))
77}
78
79/// Probe a candidate interpreter. Returns its resolved absolute path if it
80/// runs, reports version ≥ MIN_MAJOR.MIN_MINOR, and is not a Store stub.
81fn probe(candidate: &Path) -> Option<PathBuf> {
82    let output = Command::new(candidate)
83        .args([
84            "-c",
85            "import sys; print(sys.executable); print(sys.version_info[0]); print(sys.version_info[1])",
86        ])
87        .output()
88        .ok()?;
89
90    if !output.status.success() {
91        return None;
92    }
93
94    let stdout = String::from_utf8_lossy(&output.stdout);
95    let mut lines = stdout.lines();
96    let exe = lines.next()?.trim();
97    let major: u32 = lines.next()?.trim().parse().ok()?;
98    let minor: u32 = lines.next()?.trim().parse().ok()?;
99
100    if exe.is_empty() {
101        return None;
102    }
103    if major < MIN_MAJOR || (major == MIN_MAJOR && minor < MIN_MINOR) {
104        return None;
105    }
106
107    Some(PathBuf::from(exe))
108}
109
110fn missing_python_error(tried: Option<&str>) -> anyhow::Error {
111    let prefix = match tried {
112        Some(p) => format!("Python interpreter `{p}` is unusable"),
113        None => format!("No usable Python ≥{MIN_MAJOR}.{MIN_MINOR} found on PATH"),
114    };
115    anyhow!(
116        "{prefix}.\n\n\
117         Construct requires Python {MIN_MAJOR}.{MIN_MINOR}+ to run its MCP sidecars.\n\
118         Install it from {DOWNLOAD_URL} and re-run `construct install --sidecars-only`.\n\n\
119         On Windows, avoid the Microsoft Store Python stub — install from python.org directly."
120    )
121}