Skip to main content

construct/sidecars/
mod.rs

1//! Pure-Rust sidecar provisioning.
2//!
3//! Replaces the legacy `install-sidecars.{sh,bat}` scripts. Creates per-sidecar
4//! Python venvs under `~/.construct/{kumiho,operator_mcp}/` and materializes
5//! embedded launchers so Construct itself does not depend on any particular
6//! Python on PATH at runtime.
7
8mod install;
9pub mod python;
10
11pub use install::{SidecarInstallOptions, install_sidecars};
12
13use anyhow::Result;
14use std::path::PathBuf;
15
16/// Installation status of a single sidecar.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SidecarStatus {
19    Ready,
20    Missing,
21}
22
23/// Return the `~/.construct` root directory.
24pub fn construct_root() -> Result<PathBuf> {
25    let home = directories::UserDirs::new()
26        .map(|u| u.home_dir().to_path_buf())
27        .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
28    Ok(home.join(".construct"))
29}
30
31/// Path to the Kumiho sidecar launcher.
32pub fn kumiho_launcher_path() -> Result<PathBuf> {
33    Ok(construct_root()?.join("kumiho").join("run_kumiho_mcp.py"))
34}
35
36/// Path to the Operator sidecar launcher.
37pub fn operator_launcher_path() -> Result<PathBuf> {
38    Ok(construct_root()?
39        .join("operator_mcp")
40        .join("run_operator_mcp.py"))
41}
42
43/// Probe current state of a sidecar (both venv interpreter and launcher).
44pub fn status(sidecar: Sidecar) -> SidecarStatus {
45    let Ok(root) = construct_root() else {
46        return SidecarStatus::Missing;
47    };
48    let (dir, launcher) = match sidecar {
49        Sidecar::Kumiho => (root.join("kumiho"), "run_kumiho_mcp.py"),
50        Sidecar::Operator => (root.join("operator_mcp"), "run_operator_mcp.py"),
51    };
52    let interp = if cfg!(windows) {
53        dir.join("venv").join("Scripts").join("python.exe")
54    } else {
55        dir.join("venv").join("bin").join("python3")
56    };
57    if interp.exists() && dir.join(launcher).exists() {
58        SidecarStatus::Ready
59    } else {
60        SidecarStatus::Missing
61    }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Sidecar {
66    Kumiho,
67    Operator,
68}
69
70/// Ensure both sidecars are provisioned. If any are missing and `interactive`
71/// is true, prompt the user before installing. Otherwise install silently.
72///
73/// This is the hook point called from `inject_kumiho` / `inject_operator`
74/// and every command that launches an MCP-consuming agent.
75pub async fn ensure_sidecars_ready(interactive: bool) -> Result<()> {
76    let kumiho = status(Sidecar::Kumiho);
77    let operator = status(Sidecar::Operator);
78    if kumiho == SidecarStatus::Ready && operator == SidecarStatus::Ready {
79        return Ok(());
80    }
81
82    if interactive && !prompt_install(kumiho, operator)? {
83        anyhow::bail!(
84            "sidecars not installed; re-run with `construct install --sidecars-only` when ready"
85        );
86    }
87
88    install_sidecars(&SidecarInstallOptions {
89        skip_kumiho: kumiho == SidecarStatus::Ready,
90        skip_operator: operator == SidecarStatus::Ready,
91        ..Default::default()
92    })
93    .await
94}
95
96fn prompt_install(kumiho: SidecarStatus, operator: SidecarStatus) -> Result<bool> {
97    use std::io::{BufRead, Write};
98    let mut missing = Vec::new();
99    if kumiho == SidecarStatus::Missing {
100        missing.push("Kumiho");
101    }
102    if operator == SidecarStatus::Missing {
103        missing.push("Operator");
104    }
105    eprintln!(
106        "==> Construct needs to install the {} MCP sidecar{} (one-time, ~60s).",
107        missing.join(" + "),
108        if missing.len() == 1 { "" } else { "s" }
109    );
110    eprint!("    Install now? [Y/n] ");
111    std::io::stderr().flush().ok();
112
113    let stdin = std::io::stdin();
114    let mut line = String::new();
115    stdin.lock().read_line(&mut line)?;
116    let ans = line.trim().to_lowercase();
117    Ok(ans.is_empty() || ans == "y" || ans == "yes")
118}