1use std::path::PathBuf;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum WriteAction {
5 Created,
6 Updated,
7 Already,
8}
9
10struct EditorTarget {
11 name: &'static str,
12 agent_key: String,
13 config_path: PathBuf,
14 detect_path: PathBuf,
15 config_type: ConfigType,
16}
17
18enum ConfigType {
19 McpJson,
20 Zed,
21 Codex,
22 VsCodeMcp,
23 OpenCode,
24 Crush,
25}
26
27pub fn run_setup() {
28 if crate::shell::is_non_interactive() {
29 eprintln!("Non-interactive terminal detected — running shell hook install only.");
30 crate::cli::cmd_init(&["--global".to_string()]);
31 return;
32 }
33
34 use crate::terminal_ui;
35
36 let home = match dirs::home_dir() {
37 Some(h) => h,
38 None => {
39 eprintln!("Cannot determine home directory");
40 std::process::exit(1);
41 }
42 };
43
44 let binary = resolve_portable_binary();
45
46 let home_str = home.to_string_lossy().to_string();
47
48 terminal_ui::print_setup_header();
49
50 terminal_ui::print_step_header(1, 5, "Shell Hook");
52 crate::cli::cmd_init(&["--global".to_string()]);
53
54 terminal_ui::print_step_header(2, 5, "AI Tool Detection");
56
57 let targets = build_targets(&home, &binary);
58 let mut newly_configured: Vec<&str> = Vec::new();
59 let mut already_configured: Vec<&str> = Vec::new();
60 let mut not_installed: Vec<&str> = Vec::new();
61 let mut errors: Vec<&str> = Vec::new();
62
63 for target in &targets {
64 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
65
66 if !target.detect_path.exists() {
67 not_installed.push(target.name);
68 continue;
69 }
70
71 match write_config(target, &binary) {
72 Ok(WriteAction::Already) => {
73 terminal_ui::print_status_ok(&format!(
74 "{:<20} \x1b[2m{short_path}\x1b[0m",
75 target.name
76 ));
77 already_configured.push(target.name);
78 }
79 Ok(WriteAction::Created | WriteAction::Updated) => {
80 terminal_ui::print_status_new(&format!(
81 "{:<20} \x1b[2m{short_path}\x1b[0m",
82 target.name
83 ));
84 newly_configured.push(target.name);
85 }
86 Err(e) => {
87 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
88 errors.push(target.name);
89 }
90 }
91 }
92
93 let total_ok = newly_configured.len() + already_configured.len();
94 if total_ok == 0 && errors.is_empty() {
95 terminal_ui::print_status_warn(
96 "No AI tools detected. Install one and re-run: lean-ctx setup",
97 );
98 }
99
100 if !not_installed.is_empty() {
101 println!(
102 " \x1b[2m○ {} not detected: {}\x1b[0m",
103 not_installed.len(),
104 not_installed.join(", ")
105 );
106 }
107
108 terminal_ui::print_step_header(3, 5, "Agent Rules");
110 let rules_result = crate::rules_inject::inject_all_rules(&home);
111 for name in &rules_result.injected {
112 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
113 }
114 for name in &rules_result.updated {
115 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
116 }
117 for name in &rules_result.already {
118 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
119 }
120 for err in &rules_result.errors {
121 terminal_ui::print_status_warn(err);
122 }
123 if rules_result.injected.is_empty()
124 && rules_result.updated.is_empty()
125 && rules_result.already.is_empty()
126 && rules_result.errors.is_empty()
127 {
128 terminal_ui::print_status_skip("No agent rules needed");
129 }
130
131 for target in &targets {
133 if !target.detect_path.exists() || target.agent_key.is_empty() {
134 continue;
135 }
136 crate::hooks::install_agent_hook(&target.agent_key, true);
137 }
138
139 terminal_ui::print_step_header(4, 5, "Environment Check");
141 let lean_dir = home.join(".lean-ctx");
142 if !lean_dir.exists() {
143 let _ = std::fs::create_dir_all(&lean_dir);
144 terminal_ui::print_status_new("Created ~/.lean-ctx/");
145 } else {
146 terminal_ui::print_status_ok("~/.lean-ctx/ ready");
147 }
148 crate::doctor::run_compact();
149
150 terminal_ui::print_step_header(5, 5, "Help Improve lean-ctx");
152 println!(" Share anonymous compression stats to make lean-ctx better.");
153 println!(" \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
154 println!();
155 print!(" Enable anonymous data sharing? \x1b[1m[Y/n]\x1b[0m ");
156 use std::io::Write;
157 std::io::stdout().flush().ok();
158
159 let mut input = String::new();
160 let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
161 let answer = input.trim().to_lowercase();
162 answer.is_empty() || answer == "y" || answer == "yes"
163 } else {
164 false
165 };
166
167 if contribute {
168 let config_dir = home.join(".lean-ctx");
169 let _ = std::fs::create_dir_all(&config_dir);
170 let config_path = config_dir.join("config.toml");
171 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
172 if !config_content.contains("[cloud]") {
173 if !config_content.is_empty() && !config_content.ends_with('\n') {
174 config_content.push('\n');
175 }
176 config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
177 let _ = std::fs::write(&config_path, config_content);
178 }
179 terminal_ui::print_status_ok("Enabled — thank you!");
180 } else {
181 terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
182 }
183
184 println!();
186 println!(
187 " \x1b[1;32m✓ Setup complete!\x1b[0m \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
188 newly_configured.len(),
189 already_configured.len(),
190 not_installed.len()
191 );
192
193 if !errors.is_empty() {
194 println!(
195 " \x1b[33m⚠ {} error{}: {}\x1b[0m",
196 errors.len(),
197 if errors.len() != 1 { "s" } else { "" },
198 errors.join(", ")
199 );
200 }
201
202 let shell = std::env::var("SHELL").unwrap_or_default();
204 let source_cmd = if shell.contains("zsh") {
205 "source ~/.zshrc"
206 } else if shell.contains("fish") {
207 "source ~/.config/fish/config.fish"
208 } else if shell.contains("bash") {
209 "source ~/.bashrc"
210 } else {
211 "Restart your shell"
212 };
213
214 let dim = "\x1b[2m";
215 let bold = "\x1b[1m";
216 let cyan = "\x1b[36m";
217 let yellow = "\x1b[33m";
218 let rst = "\x1b[0m";
219
220 println!();
221 println!(" {bold}Next steps:{rst}");
222 println!();
223 println!(" {cyan}1.{rst} Reload your shell:");
224 println!(" {bold}{source_cmd}{rst}");
225 println!();
226
227 let mut tools_to_restart: Vec<String> =
228 newly_configured.iter().map(|s| s.to_string()).collect();
229 for name in rules_result
230 .injected
231 .iter()
232 .chain(rules_result.updated.iter())
233 {
234 if !tools_to_restart.iter().any(|t| t == name) {
235 tools_to_restart.push(name.clone());
236 }
237 }
238
239 if !tools_to_restart.is_empty() {
240 println!(" {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
241 println!(" {bold}{}{rst}", tools_to_restart.join(", "));
242 println!(
243 " {dim}The MCP connection must be re-established for changes to take effect.{rst}"
244 );
245 println!(" {dim}Close and re-open the application completely.{rst}");
246 } else if !already_configured.is_empty() {
247 println!(
248 " {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
249 );
250 }
251
252 println!();
253 println!(
254 " {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
255 );
256 println!(" {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
257
258 println!();
260 terminal_ui::print_logo_animated();
261 terminal_ui::print_command_box();
262}
263
264pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
265 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
266 let binary = resolve_portable_binary();
267
268 let mut targets = Vec::<EditorTarget>::new();
269
270 let push = |targets: &mut Vec<EditorTarget>,
271 name: &'static str,
272 config_path: PathBuf,
273 config_type: ConfigType| {
274 targets.push(EditorTarget {
275 name,
276 agent_key: agent.to_string(),
277 detect_path: PathBuf::from("/nonexistent"), config_path,
279 config_type,
280 });
281 };
282
283 match agent {
284 "cursor" => push(
285 &mut targets,
286 "Cursor",
287 home.join(".cursor/mcp.json"),
288 ConfigType::McpJson,
289 ),
290 "claude" | "claude-code" => push(
291 &mut targets,
292 "Claude Code",
293 claude_config_json_path(&home),
294 ConfigType::McpJson,
295 ),
296 "windsurf" => push(
297 &mut targets,
298 "Windsurf",
299 home.join(".codeium/windsurf/mcp_config.json"),
300 ConfigType::McpJson,
301 ),
302 "codex" => push(
303 &mut targets,
304 "Codex CLI",
305 home.join(".codex/config.toml"),
306 ConfigType::Codex,
307 ),
308 "gemini" => {
309 push(
310 &mut targets,
311 "Gemini CLI",
312 home.join(".gemini/settings/mcp.json"),
313 ConfigType::McpJson,
314 );
315 push(
316 &mut targets,
317 "Antigravity",
318 home.join(".gemini/antigravity/mcp_config.json"),
319 ConfigType::McpJson,
320 );
321 }
322 "antigravity" => push(
323 &mut targets,
324 "Antigravity",
325 home.join(".gemini/antigravity/mcp_config.json"),
326 ConfigType::McpJson,
327 ),
328 "copilot" => push(
329 &mut targets,
330 "VS Code / Copilot",
331 vscode_mcp_path(),
332 ConfigType::VsCodeMcp,
333 ),
334 "crush" => push(
335 &mut targets,
336 "Crush",
337 home.join(".config/crush/crush.json"),
338 ConfigType::Crush,
339 ),
340 "pi" => push(
341 &mut targets,
342 "Pi Coding Agent",
343 home.join(".pi/agent/mcp.json"),
344 ConfigType::McpJson,
345 ),
346 "cline" => push(&mut targets, "Cline", cline_mcp_path(), ConfigType::McpJson),
347 "roo" => push(
348 &mut targets,
349 "Roo Code",
350 roo_mcp_path(),
351 ConfigType::McpJson,
352 ),
353 "kiro" => push(
354 &mut targets,
355 "AWS Kiro",
356 home.join(".kiro/settings/mcp.json"),
357 ConfigType::McpJson,
358 ),
359 "verdent" => push(
360 &mut targets,
361 "Verdent",
362 home.join(".verdent/mcp.json"),
363 ConfigType::McpJson,
364 ),
365 "jetbrains" => push(
366 &mut targets,
367 "JetBrains IDEs",
368 home.join(".jb-mcp.json"),
369 ConfigType::McpJson,
370 ),
371 _ => {
372 return Err(format!("Unknown agent '{agent}'"));
373 }
374 }
375
376 for t in &targets {
377 let action = write_config(t, &binary)?;
378 let path_display = t.config_path.display();
379 match action {
380 WriteAction::Created => println!(
381 " \x1b[32m✓\x1b[0m {}: MCP config created at {path_display}",
382 t.name
383 ),
384 WriteAction::Updated => println!(
385 " \x1b[32m✓\x1b[0m {}: MCP config updated at {path_display}",
386 t.name
387 ),
388 WriteAction::Already => println!(
389 " \x1b[32m✓\x1b[0m {}: already configured at {path_display}",
390 t.name
391 ),
392 }
393 }
394
395 if agent == "kiro" {
396 install_kiro_steering(&home);
397 }
398
399 Ok(())
400}
401
402fn install_kiro_steering(home: &std::path::Path) {
403 let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
404 let steering_dir = cwd.join(".kiro").join("steering");
405 let steering_file = steering_dir.join("lean-ctx.md");
406
407 if steering_file.exists()
408 && std::fs::read_to_string(&steering_file)
409 .unwrap_or_default()
410 .contains("lean-ctx")
411 {
412 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
413 return;
414 }
415
416 let _ = std::fs::create_dir_all(&steering_dir);
417 let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
418 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
419}
420
421fn shorten_path(path: &str, home: &str) -> String {
422 if let Some(stripped) = path.strip_prefix(home) {
423 format!("~{stripped}")
424 } else {
425 path.to_string()
426 }
427}
428
429fn build_targets(home: &std::path::Path, _binary: &str) -> Vec<EditorTarget> {
430 #[cfg(windows)]
431 let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
432 std::path::PathBuf::from(appdata)
433 .join("opencode")
434 .join("opencode.json")
435 } else {
436 home.join(".config/opencode/opencode.json")
437 };
438 #[cfg(not(windows))]
439 let opencode_cfg = home.join(".config/opencode/opencode.json");
440
441 #[cfg(windows)]
442 let opencode_detect = opencode_cfg
443 .parent()
444 .map(|p| p.to_path_buf())
445 .unwrap_or_else(|| home.join(".config/opencode"));
446 #[cfg(not(windows))]
447 let opencode_detect = home.join(".config/opencode");
448
449 vec![
450 EditorTarget {
451 name: "Cursor",
452 agent_key: "cursor".to_string(),
453 config_path: home.join(".cursor/mcp.json"),
454 detect_path: home.join(".cursor"),
455 config_type: ConfigType::McpJson,
456 },
457 EditorTarget {
458 name: "Claude Code",
459 agent_key: "claude".to_string(),
460 config_path: claude_config_json_path(home),
461 detect_path: detect_claude_path(),
462 config_type: ConfigType::McpJson,
463 },
464 EditorTarget {
465 name: "Windsurf",
466 agent_key: "windsurf".to_string(),
467 config_path: home.join(".codeium/windsurf/mcp_config.json"),
468 detect_path: home.join(".codeium/windsurf"),
469 config_type: ConfigType::McpJson,
470 },
471 EditorTarget {
472 name: "Codex CLI",
473 agent_key: "codex".to_string(),
474 config_path: home.join(".codex/config.toml"),
475 detect_path: detect_codex_path(home),
476 config_type: ConfigType::Codex,
477 },
478 EditorTarget {
479 name: "Gemini CLI",
480 agent_key: "gemini".to_string(),
481 config_path: home.join(".gemini/settings/mcp.json"),
482 detect_path: home.join(".gemini"),
483 config_type: ConfigType::McpJson,
484 },
485 EditorTarget {
486 name: "Antigravity",
487 agent_key: "gemini".to_string(),
488 config_path: home.join(".gemini/antigravity/mcp_config.json"),
489 detect_path: home.join(".gemini/antigravity"),
490 config_type: ConfigType::McpJson,
491 },
492 EditorTarget {
493 name: "Zed",
494 agent_key: "".to_string(),
495 config_path: zed_settings_path(home),
496 detect_path: zed_config_dir(home),
497 config_type: ConfigType::Zed,
498 },
499 EditorTarget {
500 name: "VS Code / Copilot",
501 agent_key: "copilot".to_string(),
502 config_path: vscode_mcp_path(),
503 detect_path: detect_vscode_path(),
504 config_type: ConfigType::VsCodeMcp,
505 },
506 EditorTarget {
507 name: "OpenCode",
508 agent_key: "".to_string(),
509 config_path: opencode_cfg,
510 detect_path: opencode_detect,
511 config_type: ConfigType::OpenCode,
512 },
513 EditorTarget {
514 name: "Qwen Code",
515 agent_key: "qwen".to_string(),
516 config_path: home.join(".qwen/mcp.json"),
517 detect_path: home.join(".qwen"),
518 config_type: ConfigType::McpJson,
519 },
520 EditorTarget {
521 name: "Trae",
522 agent_key: "trae".to_string(),
523 config_path: home.join(".trae/mcp.json"),
524 detect_path: home.join(".trae"),
525 config_type: ConfigType::McpJson,
526 },
527 EditorTarget {
528 name: "Amazon Q Developer",
529 agent_key: "amazonq".to_string(),
530 config_path: home.join(".aws/amazonq/mcp.json"),
531 detect_path: home.join(".aws/amazonq"),
532 config_type: ConfigType::McpJson,
533 },
534 EditorTarget {
535 name: "JetBrains IDEs",
536 agent_key: "jetbrains".to_string(),
537 config_path: home.join(".jb-mcp.json"),
538 detect_path: detect_jetbrains_path(home),
539 config_type: ConfigType::McpJson,
540 },
541 EditorTarget {
542 name: "Cline",
543 agent_key: "cline".to_string(),
544 config_path: cline_mcp_path(),
545 detect_path: detect_cline_path(),
546 config_type: ConfigType::McpJson,
547 },
548 EditorTarget {
549 name: "Roo Code",
550 agent_key: "roo".to_string(),
551 config_path: roo_mcp_path(),
552 detect_path: detect_roo_path(),
553 config_type: ConfigType::McpJson,
554 },
555 EditorTarget {
556 name: "AWS Kiro",
557 agent_key: "kiro".to_string(),
558 config_path: home.join(".kiro/settings/mcp.json"),
559 detect_path: home.join(".kiro"),
560 config_type: ConfigType::McpJson,
561 },
562 EditorTarget {
563 name: "Verdent",
564 agent_key: "verdent".to_string(),
565 config_path: home.join(".verdent/mcp.json"),
566 detect_path: home.join(".verdent"),
567 config_type: ConfigType::McpJson,
568 },
569 EditorTarget {
570 name: "Crush",
571 agent_key: "crush".to_string(),
572 config_path: home.join(".config/crush/crush.json"),
573 detect_path: home.join(".config/crush"),
574 config_type: ConfigType::Crush,
575 },
576 EditorTarget {
577 name: "Pi Coding Agent",
578 agent_key: "pi".to_string(),
579 config_path: home.join(".pi/agent/mcp.json"),
580 detect_path: home.join(".pi/agent"),
581 config_type: ConfigType::McpJson,
582 },
583 ]
584}
585
586pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
590 if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
591 return PathBuf::from(dir).join(".claude.json");
592 }
593 home.join(".claude.json")
594}
595
596pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
599 if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
600 return PathBuf::from(dir);
601 }
602 home.join(".claude")
603}
604
605fn detect_claude_path() -> PathBuf {
606 let which_cmd = if cfg!(windows) { "where" } else { "which" };
607 if let Ok(output) = std::process::Command::new(which_cmd).arg("claude").output() {
608 if output.status.success() {
609 return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
610 }
611 }
612 if let Some(home) = dirs::home_dir() {
613 let cfg = claude_config_json_path(&home);
614 if cfg.exists() {
615 return cfg;
616 }
617 if claude_config_dir(&home).exists() {
618 return claude_config_dir(&home);
619 }
620 }
621 PathBuf::from("/nonexistent")
622}
623
624fn detect_codex_path(home: &std::path::Path) -> PathBuf {
625 let codex_dir = home.join(".codex");
626 if codex_dir.exists() {
627 return codex_dir;
628 }
629 if let Ok(output) = std::process::Command::new("which").arg("codex").output() {
630 if output.status.success() {
631 return codex_dir;
632 }
633 }
634 PathBuf::from("/nonexistent")
635}
636
637fn zed_settings_path(home: &std::path::Path) -> PathBuf {
638 if cfg!(target_os = "macos") {
639 home.join("Library/Application Support/Zed/settings.json")
640 } else {
641 home.join(".config/zed/settings.json")
642 }
643}
644
645fn zed_config_dir(home: &std::path::Path) -> PathBuf {
646 if cfg!(target_os = "macos") {
647 home.join("Library/Application Support/Zed")
648 } else {
649 home.join(".config/zed")
650 }
651}
652
653fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
654 if let Some(parent) = target.config_path.parent() {
655 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
656 }
657
658 match target.config_type {
659 ConfigType::McpJson => write_mcp_json(target, binary),
660 ConfigType::Zed => write_zed_config(target, binary),
661 ConfigType::Codex => write_codex_config(target, binary),
662 ConfigType::VsCodeMcp => write_vscode_mcp(target, binary),
663 ConfigType::OpenCode => write_opencode_config(target, binary),
664 ConfigType::Crush => write_crush_config(target, binary),
665 }
666}
667
668fn lean_ctx_server_entry(binary: &str, data_dir: &str) -> serde_json::Value {
669 serde_json::json!({
670 "command": binary,
671 "env": {
672 "LEAN_CTX_DATA_DIR": data_dir
673 },
674 "autoApprove": [
675 "ctx_read", "ctx_shell", "ctx_search", "ctx_tree",
676 "ctx_overview", "ctx_compress", "ctx_metrics", "ctx_session",
677 "ctx_knowledge", "ctx_agent", "ctx_analyze", "ctx_benchmark",
678 "ctx_cache", "ctx_discover", "ctx_smart_read", "ctx_delta",
679 "ctx_edit", "ctx_dedup", "ctx_fill", "ctx_intent", "ctx_response",
680 "ctx_context", "ctx_graph", "ctx_wrapped", "ctx_multi_read",
681 "ctx_semantic_search", "ctx"
682 ]
683 })
684}
685
686fn write_mcp_json(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
687 let data_dir = dirs::home_dir()
688 .ok_or_else(|| "Cannot determine home directory".to_string())?
689 .join(".lean-ctx")
690 .to_string_lossy()
691 .to_string();
692 let desired = lean_ctx_server_entry(binary, &data_dir);
693 if target.config_path.exists() {
694 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
695 let mut json =
696 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
697 let obj = json
698 .as_object_mut()
699 .ok_or_else(|| "root JSON must be an object".to_string())?;
700 let servers = obj
701 .entry("mcpServers")
702 .or_insert_with(|| serde_json::json!({}));
703 let servers_obj = servers
704 .as_object_mut()
705 .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
706
707 let existing = servers_obj.get("lean-ctx").cloned();
708 if existing.as_ref() == Some(&desired) {
709 return Ok(WriteAction::Already);
710 }
711 servers_obj.insert("lean-ctx".to_string(), desired);
712 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
713 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
714 return Ok(WriteAction::Updated);
715 }
716
717 let content = serde_json::to_string_pretty(&serde_json::json!({
718 "mcpServers": {
719 "lean-ctx": desired
720 }
721 }))
722 .map_err(|e| e.to_string())?;
723
724 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
725 Ok(WriteAction::Created)
726}
727
728fn write_zed_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
729 let desired = serde_json::json!({
730 "source": "custom",
731 "command": binary,
732 "args": [],
733 "env": {}
734 });
735 if target.config_path.exists() {
736 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
737 let mut json =
738 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
739 let obj = json
740 .as_object_mut()
741 .ok_or_else(|| "root JSON must be an object".to_string())?;
742 let servers = obj
743 .entry("context_servers")
744 .or_insert_with(|| serde_json::json!({}));
745 let servers_obj = servers
746 .as_object_mut()
747 .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
748
749 let existing = servers_obj.get("lean-ctx").cloned();
750 if existing.as_ref() == Some(&desired) {
751 return Ok(WriteAction::Already);
752 }
753 servers_obj.insert("lean-ctx".to_string(), desired);
754 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
755 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
756 return Ok(WriteAction::Updated);
757 }
758
759 let content = serde_json::to_string_pretty(&serde_json::json!({
760 "context_servers": {
761 "lean-ctx": desired
762 }
763 }))
764 .map_err(|e| e.to_string())?;
765
766 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
767 Ok(WriteAction::Created)
768}
769
770fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
771 if target.config_path.exists() {
772 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
773 let updated = upsert_codex_toml(&content, binary);
774 if updated == content {
775 return Ok(WriteAction::Already);
776 }
777 crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
778 return Ok(WriteAction::Updated);
779 }
780
781 let content = format!(
782 "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
783 binary
784 );
785 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
786 Ok(WriteAction::Created)
787}
788
789fn write_vscode_mcp(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
790 let desired = serde_json::json!({ "command": binary, "args": [] });
791 if target.config_path.exists() {
792 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
793 let mut json =
794 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
795 let obj = json
796 .as_object_mut()
797 .ok_or_else(|| "root JSON must be an object".to_string())?;
798 let servers = obj
799 .entry("servers")
800 .or_insert_with(|| serde_json::json!({}));
801 let servers_obj = servers
802 .as_object_mut()
803 .ok_or_else(|| "\"servers\" must be an object".to_string())?;
804
805 let existing = servers_obj.get("lean-ctx").cloned();
806 if existing.as_ref() == Some(&desired) {
807 return Ok(WriteAction::Already);
808 }
809 servers_obj.insert("lean-ctx".to_string(), desired);
810 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
811 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
812 return Ok(WriteAction::Updated);
813 }
814
815 if let Some(parent) = target.config_path.parent() {
816 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
817 }
818
819 let content = serde_json::to_string_pretty(&serde_json::json!({
820 "servers": {
821 "lean-ctx": {
822 "command": binary,
823 "args": []
824 }
825 }
826 }))
827 .map_err(|e| e.to_string())?;
828
829 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
830 Ok(WriteAction::Created)
831}
832
833fn write_opencode_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
834 let desired = serde_json::json!({
835 "type": "local",
836 "command": [binary],
837 "enabled": true
838 });
839 if target.config_path.exists() {
840 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
841 let mut json =
842 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
843 let obj = json
844 .as_object_mut()
845 .ok_or_else(|| "root JSON must be an object".to_string())?;
846 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
847 let mcp_obj = mcp
848 .as_object_mut()
849 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
850 let existing = mcp_obj.get("lean-ctx").cloned();
851 if existing.as_ref() == Some(&desired) {
852 return Ok(WriteAction::Already);
853 }
854 mcp_obj.insert("lean-ctx".to_string(), desired);
855 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
856 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
857 return Ok(WriteAction::Updated);
858 }
859
860 if let Some(parent) = target.config_path.parent() {
861 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
862 }
863
864 let content = serde_json::to_string_pretty(&serde_json::json!({
865 "$schema": "https://opencode.ai/config.json",
866 "mcp": {
867 "lean-ctx": {
868 "type": "local",
869 "command": [binary],
870 "enabled": true
871 }
872 }
873 }))
874 .map_err(|e| e.to_string())?;
875
876 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
877 Ok(WriteAction::Created)
878}
879
880fn write_crush_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
881 let desired = serde_json::json!({ "type": "stdio", "command": binary });
882 if target.config_path.exists() {
883 let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
884 let mut json =
885 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
886 let obj = json
887 .as_object_mut()
888 .ok_or_else(|| "root JSON must be an object".to_string())?;
889 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
890 let mcp_obj = mcp
891 .as_object_mut()
892 .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
893
894 let existing = mcp_obj.get("lean-ctx").cloned();
895 if existing.as_ref() == Some(&desired) {
896 return Ok(WriteAction::Already);
897 }
898 mcp_obj.insert("lean-ctx".to_string(), desired);
899 let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
900 crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
901 return Ok(WriteAction::Updated);
902 }
903
904 let content = serde_json::to_string_pretty(&serde_json::json!({
905 "mcp": { "lean-ctx": desired }
906 }))
907 .map_err(|e| e.to_string())?;
908
909 crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
910 Ok(WriteAction::Created)
911}
912
913fn upsert_codex_toml(existing: &str, binary: &str) -> String {
914 let mut out = String::with_capacity(existing.len() + 128);
915 let mut in_section = false;
916 let mut saw_section = false;
917 let mut wrote_command = false;
918 let mut wrote_args = false;
919
920 for line in existing.lines() {
921 let trimmed = line.trim();
922 if trimmed.starts_with('[') && trimmed.ends_with(']') {
923 if in_section && !wrote_command {
924 out.push_str(&format!("command = \"{}\"\n", binary));
925 wrote_command = true;
926 }
927 if in_section && !wrote_args {
928 out.push_str("args = []\n");
929 wrote_args = true;
930 }
931 in_section = trimmed == "[mcp_servers.lean-ctx]";
932 if in_section {
933 saw_section = true;
934 }
935 out.push_str(line);
936 out.push('\n');
937 continue;
938 }
939
940 if in_section {
941 if trimmed.starts_with("command") && trimmed.contains('=') {
942 out.push_str(&format!("command = \"{}\"\n", binary));
943 wrote_command = true;
944 continue;
945 }
946 if trimmed.starts_with("args") && trimmed.contains('=') {
947 out.push_str("args = []\n");
948 wrote_args = true;
949 continue;
950 }
951 }
952
953 out.push_str(line);
954 out.push('\n');
955 }
956
957 if saw_section {
958 if in_section && !wrote_command {
959 out.push_str(&format!("command = \"{}\"\n", binary));
960 }
961 if in_section && !wrote_args {
962 out.push_str("args = []\n");
963 }
964 return out;
965 }
966
967 if !out.ends_with('\n') {
968 out.push('\n');
969 }
970 out.push_str("\n[mcp_servers.lean-ctx]\n");
971 out.push_str(&format!("command = \"{}\"\n", binary));
972 out.push_str("args = []\n");
973 out
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979
980 fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
981 EditorTarget {
982 name: "test",
983 agent_key: "test".to_string(),
984 config_path: path,
985 detect_path: PathBuf::from("/nonexistent"),
986 config_type: ty,
987 }
988 }
989
990 #[test]
991 fn mcp_json_upserts_and_preserves_other_servers() {
992 let dir = tempfile::tempdir().unwrap();
993 let path = dir.path().join("mcp.json");
994 std::fs::write(
995 &path,
996 r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
997 )
998 .unwrap();
999
1000 let t = target(path.clone(), ConfigType::McpJson);
1001 let action = write_mcp_json(&t, "/new/path/lean-ctx").unwrap();
1002 assert_eq!(action, WriteAction::Updated);
1003
1004 let json: serde_json::Value =
1005 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1006 assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1007 assert_eq!(
1008 json["mcpServers"]["lean-ctx"]["command"],
1009 "/new/path/lean-ctx"
1010 );
1011 assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1012 assert!(
1013 json["mcpServers"]["lean-ctx"]["autoApprove"]
1014 .as_array()
1015 .unwrap()
1016 .len()
1017 > 5
1018 );
1019 }
1020
1021 #[test]
1022 fn crush_config_writes_mcp_root() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let path = dir.path().join("crush.json");
1025 std::fs::write(
1026 &path,
1027 r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1028 )
1029 .unwrap();
1030
1031 let t = target(path.clone(), ConfigType::Crush);
1032 let action = write_crush_config(&t, "new").unwrap();
1033 assert_eq!(action, WriteAction::Updated);
1034
1035 let json: serde_json::Value =
1036 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1037 assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1038 assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1039 }
1040
1041 #[test]
1042 fn codex_toml_upserts_existing_section() {
1043 let dir = tempfile::tempdir().unwrap();
1044 let path = dir.path().join("config.toml");
1045 std::fs::write(
1046 &path,
1047 r#"[mcp_servers.lean-ctx]
1048command = "old"
1049args = ["x"]
1050"#,
1051 )
1052 .unwrap();
1053
1054 let t = target(path.clone(), ConfigType::Codex);
1055 let action = write_codex_config(&t, "new").unwrap();
1056 assert_eq!(action, WriteAction::Updated);
1057
1058 let content = std::fs::read_to_string(&path).unwrap();
1059 assert!(content.contains(r#"command = "new""#));
1060 assert!(content.contains("args = []"));
1061 }
1062}
1063
1064fn detect_vscode_path() -> PathBuf {
1065 #[cfg(target_os = "macos")]
1066 {
1067 if let Some(home) = dirs::home_dir() {
1068 let vscode = home.join("Library/Application Support/Code/User/settings.json");
1069 if vscode.exists() {
1070 return vscode;
1071 }
1072 }
1073 }
1074 #[cfg(target_os = "linux")]
1075 {
1076 if let Some(home) = dirs::home_dir() {
1077 let vscode = home.join(".config/Code/User/settings.json");
1078 if vscode.exists() {
1079 return vscode;
1080 }
1081 }
1082 }
1083 #[cfg(target_os = "windows")]
1084 {
1085 if let Ok(appdata) = std::env::var("APPDATA") {
1086 let vscode = PathBuf::from(appdata).join("Code/User/settings.json");
1087 if vscode.exists() {
1088 return vscode;
1089 }
1090 }
1091 }
1092 if let Ok(output) = std::process::Command::new("which").arg("code").output() {
1093 if output.status.success() {
1094 return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1095 }
1096 }
1097 PathBuf::from("/nonexistent")
1098}
1099
1100fn vscode_mcp_path() -> PathBuf {
1101 if let Some(home) = dirs::home_dir() {
1102 #[cfg(target_os = "macos")]
1103 {
1104 return home.join("Library/Application Support/Code/User/mcp.json");
1105 }
1106 #[cfg(target_os = "linux")]
1107 {
1108 return home.join(".config/Code/User/mcp.json");
1109 }
1110 #[cfg(target_os = "windows")]
1111 {
1112 if let Ok(appdata) = std::env::var("APPDATA") {
1113 return PathBuf::from(appdata).join("Code/User/mcp.json");
1114 }
1115 }
1116 #[allow(unreachable_code)]
1117 home.join(".config/Code/User/mcp.json")
1118 } else {
1119 PathBuf::from("/nonexistent")
1120 }
1121}
1122
1123fn detect_jetbrains_path(home: &std::path::Path) -> PathBuf {
1124 #[cfg(target_os = "macos")]
1125 {
1126 let lib = home.join("Library/Application Support/JetBrains");
1127 if lib.exists() {
1128 return lib;
1129 }
1130 }
1131 #[cfg(target_os = "linux")]
1132 {
1133 let cfg = home.join(".config/JetBrains");
1134 if cfg.exists() {
1135 return cfg;
1136 }
1137 }
1138 if home.join(".jb-mcp.json").exists() {
1139 return home.join(".jb-mcp.json");
1140 }
1141 PathBuf::from("/nonexistent")
1142}
1143
1144fn cline_mcp_path() -> PathBuf {
1145 if let Some(home) = dirs::home_dir() {
1146 #[cfg(target_os = "macos")]
1147 {
1148 return home.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1149 }
1150 #[cfg(target_os = "linux")]
1151 {
1152 return home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1153 }
1154 #[cfg(target_os = "windows")]
1155 {
1156 if let Ok(appdata) = std::env::var("APPDATA") {
1157 return PathBuf::from(appdata).join("Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1158 }
1159 }
1160 }
1161 PathBuf::from("/nonexistent")
1162}
1163
1164fn detect_cline_path() -> PathBuf {
1165 if let Some(home) = dirs::home_dir() {
1166 #[cfg(target_os = "macos")]
1167 {
1168 let p = home
1169 .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev");
1170 if p.exists() {
1171 return p;
1172 }
1173 }
1174 #[cfg(target_os = "linux")]
1175 {
1176 let p = home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev");
1177 if p.exists() {
1178 return p;
1179 }
1180 }
1181 }
1182 PathBuf::from("/nonexistent")
1183}
1184
1185fn roo_mcp_path() -> PathBuf {
1186 if let Some(home) = dirs::home_dir() {
1187 #[cfg(target_os = "macos")]
1188 {
1189 return home.join("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1190 }
1191 #[cfg(target_os = "linux")]
1192 {
1193 return home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1194 }
1195 #[cfg(target_os = "windows")]
1196 {
1197 if let Ok(appdata) = std::env::var("APPDATA") {
1198 return PathBuf::from(appdata).join("Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1199 }
1200 }
1201 }
1202 PathBuf::from("/nonexistent")
1203}
1204
1205fn detect_roo_path() -> PathBuf {
1206 if let Some(home) = dirs::home_dir() {
1207 #[cfg(target_os = "macos")]
1208 {
1209 let p = home.join(
1210 "Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline",
1211 );
1212 if p.exists() {
1213 return p;
1214 }
1215 }
1216 #[cfg(target_os = "linux")]
1217 {
1218 let p = home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline");
1219 if p.exists() {
1220 return p;
1221 }
1222 }
1223 }
1224 PathBuf::from("/nonexistent")
1225}
1226
1227fn resolve_portable_binary() -> String {
1228 let which_cmd = if cfg!(windows) { "where" } else { "which" };
1229 if let Ok(status) = std::process::Command::new(which_cmd)
1230 .arg("lean-ctx")
1231 .stdout(std::process::Stdio::null())
1232 .stderr(std::process::Stdio::null())
1233 .status()
1234 {
1235 if status.success() {
1236 return "lean-ctx".to_string();
1237 }
1238 }
1239 std::env::current_exe()
1240 .map(|p| p.to_string_lossy().to_string())
1241 .unwrap_or_else(|_| "lean-ctx".to_string())
1242}