1use 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
25static SESSION_MANAGER_SRC: Dir<'_> =
31 include_dir!("$CARGO_MANIFEST_DIR/operator-mcp/session-manager");
32
33const 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 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 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
174fn 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
189fn 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 let npm = detect_npm()?;
216
217 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
218
219 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 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
260fn 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}