1use std::fmt::Write as _;
22use std::fs;
23use std::io::BufRead;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::time::{Duration, Instant};
27
28use serde_json::{Map, Value, json};
29
30use crate::project::Project;
31
32pub const NAME: &str = "shuyi";
34
35pub const SETTINGS: &str = ".claude/settings.json";
37
38pub const MCP_TIMEOUT: u64 = 300_000;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Agent {
46 pub name: &'static str,
48 pub file: &'static str,
50 pub key: &'static str,
52 pub typed: bool,
54 pub folders: &'static [&'static str],
57 pub programs: &'static [&'static str],
59}
60
61pub const AGENTS: [Agent; 4] = [
63 Agent {
64 name: "Claude Code",
65 file: ".mcp.json",
66 key: "mcpServers",
67 typed: false,
68 folders: &[],
69 programs: &[],
70 },
71 Agent {
72 name: "Cursor",
73 file: ".cursor/mcp.json",
74 key: "mcpServers",
75 typed: false,
76 folders: &[".cursor"],
77 programs: &[],
78 },
79 Agent {
80 name: "VS Code",
81 file: ".vscode/mcp.json",
82 key: "servers",
83 typed: true,
84 folders: &[".vscode"],
85 programs: &["code"],
86 },
87 Agent {
88 name: "Gemini CLI",
89 file: ".gemini/settings.json",
90 key: "mcpServers",
91 typed: false,
92 folders: &[".gemini"],
93 programs: &[],
94 },
95];
96
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct Machine {
100 pub home: Option<PathBuf>,
102 pub path: Vec<PathBuf>,
104}
105
106impl Machine {
107 pub fn this() -> Self {
109 let home = std::env::var_os("HOME")
110 .or_else(|| std::env::var_os("USERPROFILE"))
111 .map(PathBuf::from);
112 let path = std::env::var_os("PATH")
113 .map(|path| std::env::split_paths(&path).collect())
114 .unwrap_or_default();
115 Self { home, path }
116 }
117
118 fn has_program(&self, program: &str) -> bool {
121 self.path.iter().any(|dir| {
122 ["", ".exe", ".cmd", ".bat"]
123 .iter()
124 .any(|ext| dir.join(format!("{program}{ext}")).is_file())
125 })
126 }
127}
128
129impl Agent {
130 pub fn found(&self, root: &Path, machine: &Machine) -> bool {
132 if self.folders.is_empty() && self.programs.is_empty() {
133 return true;
134 }
135 let in_folder = self.folders.iter().any(|folder| {
136 root.join(folder).is_dir()
137 || machine
138 .home
139 .as_ref()
140 .is_some_and(|home| home.join(folder).is_dir())
141 });
142 in_folder || self.programs.iter().any(|p| machine.has_program(p))
143 }
144
145 pub fn entry(&self, command: &str) -> Value {
147 let mut words = command.split_whitespace().map(str::to_owned);
148 let program = words.next().unwrap_or_else(|| NAME.to_owned());
149 let mut args: Vec<String> = words.collect();
150 args.push("mcp".to_owned());
151 let mut entry = Map::new();
152 if self.typed {
153 entry.insert("type".to_owned(), json!("stdio"));
154 }
155 entry.insert("command".to_owned(), json!(program));
156 entry.insert("args".to_owned(), json!(args));
157 Value::Object(entry)
158 }
159
160 pub fn file_text(&self, old: Option<&str>, command: &str) -> Result<String, String> {
164 let mut file = match old.map(str::trim) {
165 None | Some("") => Map::new(),
166 Some(text) => match serde_json::from_str::<Value>(text) {
167 Ok(Value::Object(file)) => file,
168 Ok(_) => return Err(format!("{}: not a JSON object", self.file)),
169 Err(e) => return Err(format!("{}: not JSON ({e})", self.file)),
170 },
171 };
172 let servers = file
173 .entry(self.key.to_owned())
174 .or_insert_with(|| Value::Object(Map::new()));
175 let Value::Object(servers) = servers else {
176 return Err(format!("{}: `{}` is not an object", self.file, self.key));
177 };
178 servers.insert(NAME.to_owned(), self.entry(command));
179 let mut text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
180 text.push('\n');
181 Ok(text)
182 }
183}
184
185pub fn settings_text(old: Option<&str>) -> Result<String, String> {
189 let mut file = match old.map(str::trim) {
190 None | Some("") => Map::new(),
191 Some(text) => match serde_json::from_str::<Value>(text) {
192 Ok(Value::Object(file)) => file,
193 Ok(_) => return Err(format!("{SETTINGS}: not a JSON object")),
194 Err(e) => return Err(format!("{SETTINGS}: not JSON ({e})")),
195 },
196 };
197 let env = file
198 .entry("env".to_owned())
199 .or_insert_with(|| Value::Object(Map::new()));
200 let Value::Object(env) = env else {
201 return Err(format!("{SETTINGS}: `env` is not an object"));
202 };
203 let set = env
205 .get("MCP_TIMEOUT")
206 .and_then(Value::as_str)
207 .and_then(|ms| ms.trim().parse::<u64>().ok());
208 if set.is_none_or(|ms| ms < MCP_TIMEOUT) {
209 env.insert("MCP_TIMEOUT".to_owned(), json!(MCP_TIMEOUT.to_string()));
210 }
211 let mut text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
212 text.push('\n');
213 Ok(text)
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Planned {
219 pub agent: &'static str,
221 pub path: PathBuf,
223 pub old: Option<String>,
225 pub new: String,
227}
228
229pub fn plan(root: &Path, machine: &Machine, command: &str) -> Result<Vec<Planned>, String> {
233 let mut planned = Vec::new();
234 for agent in AGENTS.iter().filter(|agent| agent.found(root, machine)) {
235 let path = root.join(agent.file);
236 let old = fs::read_to_string(&path).ok();
237 let new = agent.file_text(old.as_deref(), command)?;
238 planned.push(Planned {
239 agent: agent.name,
240 path,
241 old,
242 new,
243 });
244 if agent.file == AGENTS[0].file {
245 let path = root.join(SETTINGS);
246 let old = fs::read_to_string(&path).ok();
247 let new = settings_text(old.as_deref())?;
248 planned.push(Planned {
249 agent: agent.name,
250 path,
251 old,
252 new,
253 });
254 }
255 }
256 Ok(planned)
257}
258
259pub fn diff(old: &str, new: &str) -> String {
262 let (a, b): (Vec<&str>, Vec<&str>) = (old.lines().collect(), new.lines().collect());
263 let mut common = vec![vec![0usize; b.len() + 1]; a.len() + 1];
265 for i in (0..a.len()).rev() {
266 for j in (0..b.len()).rev() {
267 common[i][j] = if a[i] == b[j] {
268 common[i + 1][j + 1] + 1
269 } else {
270 common[i + 1][j].max(common[i][j + 1])
271 };
272 }
273 }
274 let (mut i, mut j, mut out) = (0, 0, String::new());
275 for _ in 0..a.len() + b.len() {
278 if i == a.len() && j == b.len() {
279 break;
280 }
281 if i < a.len() && j < b.len() && a[i] == b[j] {
282 let _ = writeln!(out, " {}", a[i]);
283 (i, j) = (i + 1, j + 1);
284 } else if i < a.len() && common[i + 1][j] == common[i][j] {
285 let _ = writeln!(out, "- {}", a[i]);
287 i += 1;
288 } else {
289 let _ = writeln!(out, "+ {}", b[j]);
290 j += 1;
291 }
292 }
293 out
294}
295
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct Options {
299 pub dry_run: bool,
301 pub yes: bool,
303 pub command: Option<String>,
305}
306
307pub fn parse_args(args: &[String]) -> Result<Options, String> {
309 let mut options = Options::default();
310 let mut iter = args.iter();
311 while let Some(arg) = iter.next() {
312 match arg.as_str() {
313 "--dry-run" => options.dry_run = true,
314 "--yes" => options.yes = true,
315 "--command" => {
316 let command = iter.next().ok_or("--command needs a value")?;
317 if command.trim().is_empty() {
318 return Err("--command: the words that run shuyi, not nothing".to_owned());
319 }
320 options.command = Some(command.clone());
321 }
322 other => return Err(format!("unknown argument: {other}")),
323 }
324 }
325 Ok(options)
326}
327
328pub fn install(
334 root: &Path,
335 options: &Options,
336 machine: &Machine,
337 command: &str,
338 ask: &mut dyn FnMut(&str) -> bool,
339 ready: &mut dyn FnMut(&str) -> Result<Duration, String>,
340) -> Result<String, String> {
341 let planned = plan(root, machine, command)?;
342 let (same, changed): (Vec<Planned>, Vec<Planned>) = planned
343 .into_iter()
344 .partition(|p| p.old.as_deref() == Some(p.new.as_str()));
345 let mut out = String::new();
346 let shown = |p: &Planned| shown_path(root, &p.path);
347 for p in &same {
348 let _ = writeln!(
349 out,
350 "mcp install: {} ({}) is set up already",
351 shown(p),
352 p.agent
353 );
354 }
355 if options.dry_run {
356 for p in &changed {
357 let _ = writeln!(out, "mcp install: would write {} ({}):", shown(p), p.agent);
358 out.push_str(&p.new);
359 }
360 return Ok(out);
361 }
362 if changed.is_empty() {
363 return Ok(out);
364 }
365 let mut question = String::new();
366 for p in &changed {
367 let _ = writeln!(question, "{} ({}):", shown(p), p.agent);
368 question.push_str(&diff(p.old.as_deref().unwrap_or(""), &p.new));
369 }
370 let _ = write!(question, "write {} file(s)? [y/N] ", changed.len());
371 if !options.yes && !ask(&question) {
372 out.push_str("mcp install: nothing written\n");
373 return Ok(out);
374 }
375 for p in &changed {
376 if let Some(dir) = p.path.parent() {
377 fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
378 }
379 let mut wrote = format!("mcp install: wrote {} ({})", shown(p), p.agent);
380 if p.old.is_some() {
381 let mut backup = p.path.clone().into_os_string();
382 backup.push(".bak");
383 let backup = PathBuf::from(backup);
384 fs::copy(&p.path, &backup).map_err(|e| format!("{}: {e}", backup.display()))?;
385 let _ = write!(wrote, ", its old text in {}", shown_path(root, &backup));
386 }
387 fs::write(&p.path, &p.new).map_err(|e| format!("{}: {e}", p.path.display()))?;
388 let _ = writeln!(out, "{wrote}");
389 }
390 let took = ready(&std::mem::take(&mut out))?;
391 let _ = writeln!(
392 out,
393 "mcp install: the server is ready (\"{command} mcp --ready\" took {:.1} s)",
394 took.as_secs_f64()
395 );
396 Ok(out)
397}
398
399pub fn run_ready(root: &Path, command: &str) -> Result<Duration, String> {
402 let mut words = command.split_whitespace();
403 let program = words.next().unwrap_or(NAME);
404 let started = Instant::now();
405 let status = Command::new(program)
406 .args(words)
407 .args(["mcp", "--ready"])
408 .current_dir(root)
409 .status()
410 .map_err(|e| format!("cannot run \"{command} mcp --ready\": {e}"))?;
411 if status.success() {
412 return Ok(started.elapsed());
413 }
414 let code = status
415 .code()
416 .map_or_else(String::new, |c| format!(" (exit code {c})"));
417 Err(format!(
418 "\"{command} mcp --ready\" failed{code}: the server won't start until it builds; its \
419 error is above"
420 ))
421}
422
423pub fn said_yes(input: &mut impl BufRead) -> bool {
425 let mut answer = String::new();
426 input.read_line(&mut answer).is_ok() && matches!(answer.trim(), "y" | "Y" | "yes")
427}
428
429fn shown_path(root: &Path, path: &Path) -> String {
431 path.strip_prefix(root)
432 .unwrap_or(path)
433 .display()
434 .to_string()
435}
436
437pub fn main(args: &[String], cwd: &Path) -> u8 {
442 let options = match parse_args(args) {
443 Ok(options) => options,
444 Err(e) => {
445 eprintln!("mcp install: {e}\n\n{}", super::usage("shuyi"));
446 return 2;
447 }
448 };
449 let project = Project::find(cwd).ok();
450 let root = project.as_ref().map_or(cwd, |p| p.root.as_path());
451 let command = options.command.clone().unwrap_or_else(|| {
452 project
453 .as_ref()
454 .map_or("shuyi", |p| p.config.command())
455 .to_owned()
456 });
457 let mut ask = |question: &str| {
458 print!("{question}");
459 let _ = std::io::Write::flush(&mut std::io::stdout());
460 said_yes(&mut std::io::stdin().lock())
461 };
462 let mut ready = |said: &str| {
463 print!("{said}");
464 let _ = std::io::Write::flush(&mut std::io::stdout());
465 run_ready(root, &command)
466 };
467 let machine = Machine::this();
468 match install(root, &options, &machine, &command, &mut ask, &mut ready) {
469 Ok(out) => {
470 print!("{out}");
471 0
472 }
473 Err(e) => {
474 eprintln!("mcp install: {e}");
475 1
476 }
477 }
478}
479
480#[cfg(test)]
481mod tests;