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_npm, 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/// Embedded session-manager sidecar tree (TypeScript build output + package
26/// manifest). Cargo's package include rules in `Cargo.toml` keep this to
27/// `dist/` + `package.json` only — no `node_modules/` (that gets installed
28/// fresh at deploy time via `npm install --omit=dev`) and no `src/` (we
29/// ship the prebuilt JS, not the source).
30static SESSION_MANAGER_SRC: Dir<'_> =
31    include_dir!("$CARGO_MANIFEST_DIR/operator-mcp/session-manager");
32
33/// The PyPI version pin for the Kumiho package. Must match
34/// `operator-mcp/requirements.txt`.
35const KUMIHO_PIN: &str = "kumiho[mcp]>=0.9.20";
36
37#[derive(Debug, Default, Clone)]
38pub struct SidecarInstallOptions {
39    pub skip_kumiho: bool,
40    pub skip_operator: bool,
41    /// Opt-in: install the Node.js Session Manager sidecar.
42    ///
43    /// Defaults to `false`. The Session Manager drives spawned agents via
44    /// the Claude Agent SDK, which only accepts `ANTHROPIC_API_KEY`
45    /// (pay-per-token) — it cannot use the user's Claude Pro/Max
46    /// subscription OAuth. The default subprocess path
47    /// (`claude --print` + `codex exec`) uses each CLI's own OAuth and
48    /// routes spawned-agent calls against the subscription, which is
49    /// roughly 15–30× cheaper for equivalent work. See
50    /// https://github.com/anthropics/claude-agent-sdk-python/issues/559.
51    pub with_session_manager: bool,
52    pub dry_run: bool,
53    pub python: Option<String>,
54}
55
56pub async fn install_sidecars(opts: &SidecarInstallOptions) -> Result<()> {
57    let python = detect_python(opts.python.as_deref())?;
58    eprintln!("==> construct install --sidecars-only");
59    eprintln!("    python: {}", python.display());
60
61    let root = construct_root()?;
62    std::fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
63
64    if !opts.skip_operator {
65        install_operator(&python, opts.dry_run)?;
66    } else {
67        eprintln!("    [skip] Operator (--skip-operator)");
68    }
69
70    if !opts.skip_kumiho {
71        install_kumiho(&python, opts.dry_run)?;
72    } else {
73        eprintln!("    [skip] Kumiho (--skip-kumiho)");
74    }
75
76    if opts.with_session_manager {
77        // Best-effort: a session-manager install failure (missing npm,
78        // network blip) shouldn't tank the whole sidecar provisioning.
79        // Operator falls back to direct subprocess spawning when the
80        // session-manager isn't available, so the runtime still works
81        // — just without streaming timeline events.
82        if let Err(err) = install_session_manager(opts.dry_run) {
83            eprintln!(
84                "    [warn] Session manager install failed: {err:#}\n    \
85                 Operator will fall back to subprocess mode for spawned \
86                 agents (uses Claude Pro/Max subscription via OAuth — see \
87                 below). Re-run with `--with-session-manager` after fixing \
88                 the underlying issue (typically: install Node.js + npm)."
89            );
90        }
91    } else {
92        eprintln!(
93            "    [info] Session Manager (Node.js sidecar) NOT installed.\n    \
94                    Operator-spawned agents will use direct subprocess mode\n    \
95                    (`claude --print` + `codex exec`), which routes calls\n    \
96                    through each CLI's own OAuth → your Claude Pro/Max + Codex\n    \
97                    CLI subscriptions. No per-call API spend on spawned agents.\n    \
98                    To enable the streaming-event sidecar (uses ANTHROPIC_API_KEY,\n    \
99                    NOT subscription), re-run with `--with-session-manager`."
100        );
101    }
102
103    eprintln!("==> sidecars ready");
104    eprintln!("    kumiho   : {}", kumiho_launcher_path()?.display());
105    eprintln!("    operator : {}", operator_launcher_path()?.display());
106    Ok(())
107}
108
109fn install_kumiho(python: &Path, dry_run: bool) -> Result<()> {
110    let dir = construct_root()?.join("kumiho");
111    let venv = dir.join("venv");
112    let launcher = dir.join("run_kumiho_mcp.py");
113
114    eprintln!("==> Installing Kumiho MCP → {}", dir.display());
115    if dry_run {
116        eprintln!("    + create {}", venv.display());
117        eprintln!("    + pip install {KUMIHO_PIN}");
118        eprintln!("    + write {}", launcher.display());
119        return Ok(());
120    }
121
122    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
123    ensure_venv(python, &venv)?;
124    let venv_py = venv_python(&venv)?;
125
126    run(
127        &venv_py,
128        &["-m", "pip", "install", "--quiet", "--upgrade", "pip"],
129    )?;
130    run(&venv_py, &["-m", "pip", "install", "--quiet", KUMIHO_PIN])?;
131    eprintln!("    [ok] kumiho[mcp] installed");
132
133    write_launcher(&launcher, KUMIHO_LAUNCHER_SRC)?;
134    eprintln!("    [ok] launcher: {}", launcher.display());
135    Ok(())
136}
137
138fn install_operator(python: &Path, dry_run: bool) -> Result<()> {
139    let dir = construct_root()?.join("operator_mcp");
140    let venv = dir.join("venv");
141    let launcher = dir.join("run_operator_mcp.py");
142
143    eprintln!("==> Installing Operator MCP → {}", dir.display());
144    if dry_run {
145        eprintln!("    + extract embedded operator-mcp source");
146        eprintln!("    + create {}", venv.display());
147        eprintln!("    + pip install operator-mcp");
148        eprintln!("    + write {}", launcher.display());
149        return Ok(());
150    }
151
152    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
153
154    let staging = tempfile::tempdir().context("creating operator-mcp staging dir")?;
155    extract_operator_source(staging.path())?;
156    eprintln!("    [ok] extracted operator-mcp source → staging");
157
158    ensure_venv(python, &venv)?;
159    let venv_py = venv_python(&venv)?;
160
161    run(
162        &venv_py,
163        &["-m", "pip", "install", "--quiet", "--upgrade", "pip"],
164    )?;
165    let staging_str = staging.path().to_string_lossy().to_string();
166    run(&venv_py, &["-m", "pip", "install", "--quiet", &staging_str])?;
167    eprintln!("    [ok] operator-mcp installed");
168
169    write_launcher(&launcher, OPERATOR_LAUNCHER_SRC)?;
170    eprintln!("    [ok] launcher: {}", launcher.display());
171    Ok(())
172}
173
174/// Extract the embedded `operator-mcp/` tree into `dest`, skipping the files
175/// that pip doesn't need (tests, session-manager, node bits, caches).
176fn extract_operator_source(dest: &Path) -> Result<()> {
177    walk_dir(&OPERATOR_MCP_SRC, dest)?;
178    for required in ["pyproject.toml", "operator_mcp/__init__.py"] {
179        if !dest.join(required).exists() {
180            return Err(anyhow!(
181                "embedded operator-mcp source missing `{required}` after extraction; \
182                 check Cargo.toml `include` whitelist"
183            ));
184        }
185    }
186    Ok(())
187}
188
189/// Install the Node.js session-manager sidecar.
190///
191/// Lays down the prebuilt `dist/` + `package.json` into
192/// `~/.construct/operator_mcp/session-manager/`, then runs
193/// `npm install --omit=dev` to fetch its node_modules. The Operator MCP
194/// (Python) discovers and spawns this sidecar at runtime to drive the
195/// Claude Agent SDK and codex CLI with structured streaming events.
196///
197/// Subprocess fallback in `agents.tool_create_agent` is what runs when
198/// this sidecar isn't installed — works, but loses the streaming
199/// timeline + cross-turn session preservation. So fresh installs without
200/// this step end up in degraded mode by default.
201fn install_session_manager(dry_run: bool) -> Result<()> {
202    let dir = construct_root()?
203        .join("operator_mcp")
204        .join("session-manager");
205
206    eprintln!("==> Installing Session Manager → {}", dir.display());
207    if dry_run {
208        eprintln!("    + extract embedded session-manager dist + package.json");
209        eprintln!("    + npm install --omit=dev");
210        return Ok(());
211    }
212
213    // Detect npm BEFORE writing files so a missing-npm machine doesn't
214    // get a half-installed session-manager dir it has to clean up.
215    let npm = detect_npm()?;
216
217    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
218
219    // Write embedded dist/ tree + package.json. Same shape as
220    // extract_operator_source but no need to filter — Cargo's package
221    // include already restricts SESSION_MANAGER_SRC to dist + package.json.
222    walk_session_manager(&SESSION_MANAGER_SRC, &dir)?;
223    let dist_index = dir.join("dist").join("index.js");
224    if !dist_index.exists() {
225        return Err(anyhow!(
226            "embedded session-manager missing dist/index.js after extraction; \
227             check Cargo.toml `include` whitelist (need /operator-mcp/session-manager/dist/**/*)"
228        ));
229    }
230    eprintln!("    [ok] dist + package.json laid down");
231
232    // npm install --omit=dev fetches the production deps listed in
233    // package.json (no dev deps — TypeScript compiler etc. aren't needed
234    // since dist/ is prebuilt). The session-manager isn't a publishable
235    // package so we don't need --no-save quirks.
236    let mut cmd = Command::new(&npm);
237    cmd.arg("install")
238        .arg("--omit=dev")
239        .arg("--no-audit")
240        .arg("--no-fund")
241        .current_dir(&dir);
242    let status = cmd
243        .status()
244        .with_context(|| format!("running `{} install` in {}", npm.display(), dir.display()))?;
245    if !status.success() {
246        return Err(anyhow!(
247            "`npm install` failed with status {:?}. Check npm output above; \
248             a network blip is the most common cause — re-running usually fixes it.",
249            status.code()
250        ));
251    }
252    eprintln!("    [ok] session-manager dependencies installed");
253    eprintln!(
254        "    [ok] entrypoint: node {}",
255        dir.join("dist").join("index.js").display()
256    );
257    Ok(())
258}
259
260/// Walk variant for the dedicated `SESSION_MANAGER_SRC` tree. The tree's
261/// content is already pre-filtered by Cargo's package include rules, so we
262/// don't need to re-apply the operator-mcp `is_relevant` filter here.
263fn walk_session_manager(dir: &Dir<'_>, dest: &Path) -> Result<()> {
264    for entry in dir.entries() {
265        let rel = entry.path();
266        match entry {
267            DirEntry::Dir(sub) => {
268                let out = dest.join(rel);
269                std::fs::create_dir_all(&out)
270                    .with_context(|| format!("creating {}", out.display()))?;
271                walk_session_manager(sub, dest)?;
272            }
273            DirEntry::File(file) => {
274                let out = dest.join(rel);
275                if let Some(parent) = out.parent() {
276                    std::fs::create_dir_all(parent)
277                        .with_context(|| format!("creating {}", parent.display()))?;
278                }
279                std::fs::write(&out, file.contents())
280                    .with_context(|| format!("writing {}", out.display()))?;
281            }
282        }
283    }
284    Ok(())
285}
286
287fn walk_dir(dir: &Dir<'_>, dest: &Path) -> Result<()> {
288    for entry in dir.entries() {
289        let rel = entry.path();
290        if !is_relevant(rel) {
291            continue;
292        }
293        match entry {
294            DirEntry::Dir(sub) => {
295                let out = dest.join(rel);
296                std::fs::create_dir_all(&out)
297                    .with_context(|| format!("creating {}", out.display()))?;
298                walk_dir(sub, dest)?;
299            }
300            DirEntry::File(file) => {
301                let out = dest.join(rel);
302                if let Some(parent) = out.parent() {
303                    std::fs::create_dir_all(parent)
304                        .with_context(|| format!("creating {}", parent.display()))?;
305                }
306                std::fs::write(&out, file.contents())
307                    .with_context(|| format!("writing {}", out.display()))?;
308            }
309        }
310    }
311    Ok(())
312}
313
314fn is_relevant(rel: &Path) -> bool {
315    let s = rel.to_string_lossy();
316    if s.contains("__pycache__")
317        || s.contains("/.venv")
318        || s.contains("/venv/")
319        || s.starts_with("tests/")
320        || s.starts_with("session-manager/")
321        || s.starts_with("node_modules/")
322        || s.ends_with(".pyc")
323    {
324        return false;
325    }
326    true
327}
328
329fn ensure_venv(python: &Path, venv: &Path) -> Result<()> {
330    if venv_python(venv).is_ok() {
331        eprintln!("    [skip] venv already exists: {}", venv.display());
332        return Ok(());
333    }
334    let venv_str = venv.to_string_lossy().to_string();
335    run(python, &["-m", "venv", &venv_str])?;
336    eprintln!("    [ok] venv created: {}", venv.display());
337    Ok(())
338}
339
340fn venv_python(venv: &Path) -> Result<PathBuf> {
341    let candidates = if cfg!(windows) {
342        vec![venv.join("Scripts").join("python.exe")]
343    } else {
344        vec![
345            venv.join("bin").join("python3"),
346            venv.join("bin").join("python"),
347        ]
348    };
349    for c in candidates {
350        if c.exists() {
351            return Ok(c);
352        }
353    }
354    Err(anyhow!("venv python not found under {}", venv.display()))
355}
356
357fn write_launcher(path: &Path, contents: &str) -> Result<()> {
358    if let Some(parent) = path.parent() {
359        std::fs::create_dir_all(parent)
360            .with_context(|| format!("creating {}", parent.display()))?;
361    }
362    std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
363    #[cfg(unix)]
364    {
365        use std::os::unix::fs::PermissionsExt;
366        let mut perm = std::fs::metadata(path)?.permissions();
367        perm.set_mode(0o755);
368        std::fs::set_permissions(path, perm)
369            .with_context(|| format!("chmod +x {}", path.display()))?;
370    }
371    Ok(())
372}
373
374fn run(program: &Path, args: &[&str]) -> Result<()> {
375    let status = Command::new(program)
376        .args(args)
377        .stdin(Stdio::null())
378        .status()
379        .with_context(|| format!("invoking {} {}", program.display(), args.join(" ")))?;
380    if !status.success() {
381        return Err(anyhow!(
382            "`{} {}` exited with status {}",
383            program.display(),
384            args.join(" "),
385            status.code().unwrap_or(-1)
386        ));
387    }
388    Ok(())
389}