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_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
25const 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
128fn 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}