Skip to main content

construct/sidecars/
install.rs

1//! Sidecar installation logic.
2//!
3//! At compile time we embed:
4//! - The two Python launcher scripts (as strings).
5//! - The `operator-mcp/` Python package source (via `include_dir!`).
6//!
7//! At install time we detect Python, create per-sidecar venvs, pip-install
8//! `kumiho[mcp]` into the Kumiho venv, extract the embedded operator-mcp
9//! source into a temp dir and pip-install it into the Operator venv, and
10//! materialize the launchers. No shell scripts involved.
11
12use anyhow::{Context, Result, anyhow};
13use include_dir::{Dir, DirEntry, include_dir};
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use super::python::detect_python;
18use super::{construct_root, kumiho_launcher_path, operator_launcher_path};
19
20const KUMIHO_LAUNCHER_SRC: &str = include_str!("../../resources/sidecars/run_kumiho_mcp.py");
21const OPERATOR_LAUNCHER_SRC: &str = include_str!("../../resources/sidecars/run_operator_mcp.py");
22
23static OPERATOR_MCP_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/operator-mcp");
24
25/// The PyPI version pin for the Kumiho package. Must match
26/// `operator-mcp/requirements.txt`.
27const KUMIHO_PIN: &str = "kumiho[mcp]>=0.9.20";
28
29#[derive(Debug, Default, Clone)]
30pub struct SidecarInstallOptions {
31    pub skip_kumiho: bool,
32    pub skip_operator: bool,
33    pub dry_run: bool,
34    pub python: Option<String>,
35}
36
37pub async fn install_sidecars(opts: &SidecarInstallOptions) -> Result<()> {
38    let python = detect_python(opts.python.as_deref())?;
39    eprintln!("==> construct install --sidecars-only");
40    eprintln!("    python: {}", python.display());
41
42    let root = construct_root()?;
43    std::fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
44
45    if !opts.skip_operator {
46        install_operator(&python, opts.dry_run)?;
47    } else {
48        eprintln!("    [skip] Operator (--skip-operator)");
49    }
50
51    if !opts.skip_kumiho {
52        install_kumiho(&python, opts.dry_run)?;
53    } else {
54        eprintln!("    [skip] Kumiho (--skip-kumiho)");
55    }
56
57    eprintln!("==> sidecars ready");
58    eprintln!("    kumiho   : {}", kumiho_launcher_path()?.display());
59    eprintln!("    operator : {}", operator_launcher_path()?.display());
60    Ok(())
61}
62
63fn install_kumiho(python: &Path, dry_run: bool) -> Result<()> {
64    let dir = construct_root()?.join("kumiho");
65    let venv = dir.join("venv");
66    let launcher = dir.join("run_kumiho_mcp.py");
67
68    eprintln!("==> Installing Kumiho MCP → {}", dir.display());
69    if dry_run {
70        eprintln!("    + create {}", venv.display());
71        eprintln!("    + pip install {KUMIHO_PIN}");
72        eprintln!("    + write {}", launcher.display());
73        return Ok(());
74    }
75
76    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
77    ensure_venv(python, &venv)?;
78    let venv_py = venv_python(&venv)?;
79
80    run(
81        &venv_py,
82        &["-m", "pip", "install", "--quiet", "--upgrade", "pip"],
83    )?;
84    run(&venv_py, &["-m", "pip", "install", "--quiet", KUMIHO_PIN])?;
85    eprintln!("    [ok] kumiho[mcp] installed");
86
87    write_launcher(&launcher, KUMIHO_LAUNCHER_SRC)?;
88    eprintln!("    [ok] launcher: {}", launcher.display());
89    Ok(())
90}
91
92fn install_operator(python: &Path, dry_run: bool) -> Result<()> {
93    let dir = construct_root()?.join("operator_mcp");
94    let venv = dir.join("venv");
95    let launcher = dir.join("run_operator_mcp.py");
96
97    eprintln!("==> Installing Operator MCP → {}", dir.display());
98    if dry_run {
99        eprintln!("    + extract embedded operator-mcp source");
100        eprintln!("    + create {}", venv.display());
101        eprintln!("    + pip install operator-mcp");
102        eprintln!("    + write {}", launcher.display());
103        return Ok(());
104    }
105
106    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
107
108    let staging = tempfile::tempdir().context("creating operator-mcp staging dir")?;
109    extract_operator_source(staging.path())?;
110    eprintln!("    [ok] extracted operator-mcp source → staging");
111
112    ensure_venv(python, &venv)?;
113    let venv_py = venv_python(&venv)?;
114
115    run(
116        &venv_py,
117        &["-m", "pip", "install", "--quiet", "--upgrade", "pip"],
118    )?;
119    let staging_str = staging.path().to_string_lossy().to_string();
120    run(&venv_py, &["-m", "pip", "install", "--quiet", &staging_str])?;
121    eprintln!("    [ok] operator-mcp installed");
122
123    write_launcher(&launcher, OPERATOR_LAUNCHER_SRC)?;
124    eprintln!("    [ok] launcher: {}", launcher.display());
125    Ok(())
126}
127
128/// Extract the embedded `operator-mcp/` tree into `dest`, skipping the files
129/// that pip doesn't need (tests, session-manager, node bits, caches).
130fn extract_operator_source(dest: &Path) -> Result<()> {
131    walk_dir(&OPERATOR_MCP_SRC, dest)?;
132    for required in ["pyproject.toml", "operator_mcp/__init__.py"] {
133        if !dest.join(required).exists() {
134            return Err(anyhow!(
135                "embedded operator-mcp source missing `{required}` after extraction; \
136                 check Cargo.toml `include` whitelist"
137            ));
138        }
139    }
140    Ok(())
141}
142
143fn walk_dir(dir: &Dir<'_>, dest: &Path) -> Result<()> {
144    for entry in dir.entries() {
145        let rel = entry.path();
146        if !is_relevant(rel) {
147            continue;
148        }
149        match entry {
150            DirEntry::Dir(sub) => {
151                let out = dest.join(rel);
152                std::fs::create_dir_all(&out)
153                    .with_context(|| format!("creating {}", out.display()))?;
154                walk_dir(sub, dest)?;
155            }
156            DirEntry::File(file) => {
157                let out = dest.join(rel);
158                if let Some(parent) = out.parent() {
159                    std::fs::create_dir_all(parent)
160                        .with_context(|| format!("creating {}", parent.display()))?;
161                }
162                std::fs::write(&out, file.contents())
163                    .with_context(|| format!("writing {}", out.display()))?;
164            }
165        }
166    }
167    Ok(())
168}
169
170fn is_relevant(rel: &Path) -> bool {
171    let s = rel.to_string_lossy();
172    if s.contains("__pycache__")
173        || s.contains("/.venv")
174        || s.contains("/venv/")
175        || s.starts_with("tests/")
176        || s.starts_with("session-manager/")
177        || s.starts_with("node_modules/")
178        || s.ends_with(".pyc")
179    {
180        return false;
181    }
182    true
183}
184
185fn ensure_venv(python: &Path, venv: &Path) -> Result<()> {
186    if venv_python(venv).is_ok() {
187        eprintln!("    [skip] venv already exists: {}", venv.display());
188        return Ok(());
189    }
190    let venv_str = venv.to_string_lossy().to_string();
191    run(python, &["-m", "venv", &venv_str])?;
192    eprintln!("    [ok] venv created: {}", venv.display());
193    Ok(())
194}
195
196fn venv_python(venv: &Path) -> Result<PathBuf> {
197    let candidates = if cfg!(windows) {
198        vec![venv.join("Scripts").join("python.exe")]
199    } else {
200        vec![
201            venv.join("bin").join("python3"),
202            venv.join("bin").join("python"),
203        ]
204    };
205    for c in candidates {
206        if c.exists() {
207            return Ok(c);
208        }
209    }
210    Err(anyhow!("venv python not found under {}", venv.display()))
211}
212
213fn write_launcher(path: &Path, contents: &str) -> Result<()> {
214    if let Some(parent) = path.parent() {
215        std::fs::create_dir_all(parent)
216            .with_context(|| format!("creating {}", parent.display()))?;
217    }
218    std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
219    #[cfg(unix)]
220    {
221        use std::os::unix::fs::PermissionsExt;
222        let mut perm = std::fs::metadata(path)?.permissions();
223        perm.set_mode(0o755);
224        std::fs::set_permissions(path, perm)
225            .with_context(|| format!("chmod +x {}", path.display()))?;
226    }
227    Ok(())
228}
229
230fn run(program: &Path, args: &[&str]) -> Result<()> {
231    let status = Command::new(program)
232        .args(args)
233        .stdin(Stdio::null())
234        .status()
235        .with_context(|| format!("invoking {} {}", program.display(), args.join(" ")))?;
236    if !status.success() {
237        return Err(anyhow!(
238            "`{} {}` exited with status {}",
239            program.display(),
240            args.join(" "),
241            status.code().unwrap_or(-1)
242        ));
243    }
244    Ok(())
245}