Skip to main content

lean_ctx/
setup.rs

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    // Step 1: Shell hook
51    terminal_ui::print_step_header(1, 5, "Shell Hook");
52    crate::cli::cmd_init(&["--global".to_string()]);
53
54    // Step 2: Editor auto-detection + configuration
55    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    // Step 3: Agent rules injection
109    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    // Legacy agent hooks
132    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    // Step 4: Data directory + diagnostics
140    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    // Step 5: Data sharing
151    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    // Summary
185    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    // Next steps
203    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    // Logo + commands
259    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"), // not used in direct agent config
278            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            home.join(".claude.json"),
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 _ = write_config(t, &binary)?;
378    }
379
380    if agent == "kiro" {
381        install_kiro_steering(&home);
382    }
383
384    Ok(())
385}
386
387fn install_kiro_steering(home: &std::path::Path) {
388    let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
389    let steering_dir = cwd.join(".kiro").join("steering");
390    let steering_file = steering_dir.join("lean-ctx.md");
391
392    if steering_file.exists()
393        && std::fs::read_to_string(&steering_file)
394            .unwrap_or_default()
395            .contains("lean-ctx")
396    {
397        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
398        return;
399    }
400
401    let _ = std::fs::create_dir_all(&steering_dir);
402    let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
403    println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
404}
405
406fn shorten_path(path: &str, home: &str) -> String {
407    if let Some(stripped) = path.strip_prefix(home) {
408        format!("~{stripped}")
409    } else {
410        path.to_string()
411    }
412}
413
414fn build_targets(home: &std::path::Path, _binary: &str) -> Vec<EditorTarget> {
415    #[cfg(windows)]
416    let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
417        std::path::PathBuf::from(appdata)
418            .join("opencode")
419            .join("opencode.json")
420    } else {
421        home.join(".config/opencode/opencode.json")
422    };
423    #[cfg(not(windows))]
424    let opencode_cfg = home.join(".config/opencode/opencode.json");
425
426    #[cfg(windows)]
427    let opencode_detect = opencode_cfg
428        .parent()
429        .map(|p| p.to_path_buf())
430        .unwrap_or_else(|| home.join(".config/opencode"));
431    #[cfg(not(windows))]
432    let opencode_detect = home.join(".config/opencode");
433
434    vec![
435        EditorTarget {
436            name: "Cursor",
437            agent_key: "cursor".to_string(),
438            config_path: home.join(".cursor/mcp.json"),
439            detect_path: home.join(".cursor"),
440            config_type: ConfigType::McpJson,
441        },
442        EditorTarget {
443            name: "Claude Code",
444            agent_key: "claude".to_string(),
445            config_path: home.join(".claude.json"),
446            detect_path: detect_claude_path(),
447            config_type: ConfigType::McpJson,
448        },
449        EditorTarget {
450            name: "Windsurf",
451            agent_key: "windsurf".to_string(),
452            config_path: home.join(".codeium/windsurf/mcp_config.json"),
453            detect_path: home.join(".codeium/windsurf"),
454            config_type: ConfigType::McpJson,
455        },
456        EditorTarget {
457            name: "Codex CLI",
458            agent_key: "codex".to_string(),
459            config_path: home.join(".codex/config.toml"),
460            detect_path: detect_codex_path(home),
461            config_type: ConfigType::Codex,
462        },
463        EditorTarget {
464            name: "Gemini CLI",
465            agent_key: "gemini".to_string(),
466            config_path: home.join(".gemini/settings/mcp.json"),
467            detect_path: home.join(".gemini"),
468            config_type: ConfigType::McpJson,
469        },
470        EditorTarget {
471            name: "Antigravity",
472            agent_key: "gemini".to_string(),
473            config_path: home.join(".gemini/antigravity/mcp_config.json"),
474            detect_path: home.join(".gemini/antigravity"),
475            config_type: ConfigType::McpJson,
476        },
477        EditorTarget {
478            name: "Zed",
479            agent_key: "".to_string(),
480            config_path: zed_settings_path(home),
481            detect_path: zed_config_dir(home),
482            config_type: ConfigType::Zed,
483        },
484        EditorTarget {
485            name: "VS Code / Copilot",
486            agent_key: "copilot".to_string(),
487            config_path: vscode_mcp_path(),
488            detect_path: detect_vscode_path(),
489            config_type: ConfigType::VsCodeMcp,
490        },
491        EditorTarget {
492            name: "OpenCode",
493            agent_key: "".to_string(),
494            config_path: opencode_cfg,
495            detect_path: opencode_detect,
496            config_type: ConfigType::OpenCode,
497        },
498        EditorTarget {
499            name: "Qwen Code",
500            agent_key: "qwen".to_string(),
501            config_path: home.join(".qwen/mcp.json"),
502            detect_path: home.join(".qwen"),
503            config_type: ConfigType::McpJson,
504        },
505        EditorTarget {
506            name: "Trae",
507            agent_key: "trae".to_string(),
508            config_path: home.join(".trae/mcp.json"),
509            detect_path: home.join(".trae"),
510            config_type: ConfigType::McpJson,
511        },
512        EditorTarget {
513            name: "Amazon Q Developer",
514            agent_key: "amazonq".to_string(),
515            config_path: home.join(".aws/amazonq/mcp.json"),
516            detect_path: home.join(".aws/amazonq"),
517            config_type: ConfigType::McpJson,
518        },
519        EditorTarget {
520            name: "JetBrains IDEs",
521            agent_key: "jetbrains".to_string(),
522            config_path: home.join(".jb-mcp.json"),
523            detect_path: detect_jetbrains_path(home),
524            config_type: ConfigType::McpJson,
525        },
526        EditorTarget {
527            name: "Cline",
528            agent_key: "cline".to_string(),
529            config_path: cline_mcp_path(),
530            detect_path: detect_cline_path(),
531            config_type: ConfigType::McpJson,
532        },
533        EditorTarget {
534            name: "Roo Code",
535            agent_key: "roo".to_string(),
536            config_path: roo_mcp_path(),
537            detect_path: detect_roo_path(),
538            config_type: ConfigType::McpJson,
539        },
540        EditorTarget {
541            name: "AWS Kiro",
542            agent_key: "kiro".to_string(),
543            config_path: home.join(".kiro/settings/mcp.json"),
544            detect_path: home.join(".kiro"),
545            config_type: ConfigType::McpJson,
546        },
547        EditorTarget {
548            name: "Verdent",
549            agent_key: "verdent".to_string(),
550            config_path: home.join(".verdent/mcp.json"),
551            detect_path: home.join(".verdent"),
552            config_type: ConfigType::McpJson,
553        },
554        EditorTarget {
555            name: "Crush",
556            agent_key: "crush".to_string(),
557            config_path: home.join(".config/crush/crush.json"),
558            detect_path: home.join(".config/crush"),
559            config_type: ConfigType::Crush,
560        },
561        EditorTarget {
562            name: "Pi Coding Agent",
563            agent_key: "pi".to_string(),
564            config_path: home.join(".pi/agent/mcp.json"),
565            detect_path: home.join(".pi/agent"),
566            config_type: ConfigType::McpJson,
567        },
568    ]
569}
570
571fn detect_claude_path() -> PathBuf {
572    if let Ok(output) = std::process::Command::new("which").arg("claude").output() {
573        if output.status.success() {
574            return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
575        }
576    }
577    if let Some(home) = dirs::home_dir() {
578        let claude_json = home.join(".claude.json");
579        if claude_json.exists() {
580            return claude_json;
581        }
582    }
583    PathBuf::from("/nonexistent")
584}
585
586fn detect_codex_path(home: &std::path::Path) -> PathBuf {
587    let codex_dir = home.join(".codex");
588    if codex_dir.exists() {
589        return codex_dir;
590    }
591    if let Ok(output) = std::process::Command::new("which").arg("codex").output() {
592        if output.status.success() {
593            return codex_dir;
594        }
595    }
596    PathBuf::from("/nonexistent")
597}
598
599fn zed_settings_path(home: &std::path::Path) -> PathBuf {
600    if cfg!(target_os = "macos") {
601        home.join("Library/Application Support/Zed/settings.json")
602    } else {
603        home.join(".config/zed/settings.json")
604    }
605}
606
607fn zed_config_dir(home: &std::path::Path) -> PathBuf {
608    if cfg!(target_os = "macos") {
609        home.join("Library/Application Support/Zed")
610    } else {
611        home.join(".config/zed")
612    }
613}
614
615fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
616    if let Some(parent) = target.config_path.parent() {
617        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
618    }
619
620    match target.config_type {
621        ConfigType::McpJson => write_mcp_json(target, binary),
622        ConfigType::Zed => write_zed_config(target, binary),
623        ConfigType::Codex => write_codex_config(target, binary),
624        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary),
625        ConfigType::OpenCode => write_opencode_config(target, binary),
626        ConfigType::Crush => write_crush_config(target, binary),
627    }
628}
629
630fn lean_ctx_server_entry(binary: &str, data_dir: &str) -> serde_json::Value {
631    serde_json::json!({
632        "command": binary,
633        "env": {
634            "LEAN_CTX_DATA_DIR": data_dir
635        },
636        "autoApprove": [
637            "ctx_read", "ctx_shell", "ctx_search", "ctx_tree",
638            "ctx_overview", "ctx_compress", "ctx_metrics", "ctx_session",
639            "ctx_knowledge", "ctx_agent", "ctx_analyze", "ctx_benchmark",
640            "ctx_cache", "ctx_discover", "ctx_smart_read", "ctx_delta",
641            "ctx_edit", "ctx_dedup", "ctx_fill", "ctx_intent", "ctx_response",
642            "ctx_context", "ctx_graph", "ctx_wrapped", "ctx_multi_read",
643            "ctx_semantic_search", "ctx"
644        ]
645    })
646}
647
648fn write_mcp_json(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
649    let data_dir = dirs::home_dir()
650        .ok_or_else(|| "Cannot determine home directory".to_string())?
651        .join(".lean-ctx")
652        .to_string_lossy()
653        .to_string();
654    let desired = lean_ctx_server_entry(binary, &data_dir);
655    if target.config_path.exists() {
656        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
657        let mut json =
658            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
659        let obj = json
660            .as_object_mut()
661            .ok_or_else(|| "root JSON must be an object".to_string())?;
662        let servers = obj
663            .entry("mcpServers")
664            .or_insert_with(|| serde_json::json!({}));
665        let servers_obj = servers
666            .as_object_mut()
667            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
668
669        let existing = servers_obj.get("lean-ctx").cloned();
670        if existing.as_ref() == Some(&desired) {
671            return Ok(WriteAction::Already);
672        }
673        servers_obj.insert("lean-ctx".to_string(), desired);
674        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
675        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
676        return Ok(WriteAction::Updated);
677    }
678
679    let content = serde_json::to_string_pretty(&serde_json::json!({
680        "mcpServers": {
681            "lean-ctx": desired
682        }
683    }))
684    .map_err(|e| e.to_string())?;
685
686    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
687    Ok(WriteAction::Created)
688}
689
690fn write_zed_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
691    let desired = serde_json::json!({
692        "source": "custom",
693        "command": binary,
694        "args": [],
695        "env": {}
696    });
697    if target.config_path.exists() {
698        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
699        let mut json =
700            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
701        let obj = json
702            .as_object_mut()
703            .ok_or_else(|| "root JSON must be an object".to_string())?;
704        let servers = obj
705            .entry("context_servers")
706            .or_insert_with(|| serde_json::json!({}));
707        let servers_obj = servers
708            .as_object_mut()
709            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
710
711        let existing = servers_obj.get("lean-ctx").cloned();
712        if existing.as_ref() == Some(&desired) {
713            return Ok(WriteAction::Already);
714        }
715        servers_obj.insert("lean-ctx".to_string(), desired);
716        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
717        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
718        return Ok(WriteAction::Updated);
719    }
720
721    let content = serde_json::to_string_pretty(&serde_json::json!({
722        "context_servers": {
723            "lean-ctx": desired
724        }
725    }))
726    .map_err(|e| e.to_string())?;
727
728    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
729    Ok(WriteAction::Created)
730}
731
732fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
733    if target.config_path.exists() {
734        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
735        let updated = upsert_codex_toml(&content, binary);
736        if updated == content {
737            return Ok(WriteAction::Already);
738        }
739        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
740        return Ok(WriteAction::Updated);
741    }
742
743    let content = format!(
744        "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
745        binary
746    );
747    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
748    Ok(WriteAction::Created)
749}
750
751fn write_vscode_mcp(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
752    let desired = serde_json::json!({ "command": binary, "args": [] });
753    if target.config_path.exists() {
754        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
755        let mut json =
756            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
757        let obj = json
758            .as_object_mut()
759            .ok_or_else(|| "root JSON must be an object".to_string())?;
760        let servers = obj
761            .entry("servers")
762            .or_insert_with(|| serde_json::json!({}));
763        let servers_obj = servers
764            .as_object_mut()
765            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
766
767        let existing = servers_obj.get("lean-ctx").cloned();
768        if existing.as_ref() == Some(&desired) {
769            return Ok(WriteAction::Already);
770        }
771        servers_obj.insert("lean-ctx".to_string(), desired);
772        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
773        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
774        return Ok(WriteAction::Updated);
775    }
776
777    if let Some(parent) = target.config_path.parent() {
778        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
779    }
780
781    let content = serde_json::to_string_pretty(&serde_json::json!({
782        "servers": {
783            "lean-ctx": {
784                "command": binary,
785                "args": []
786            }
787        }
788    }))
789    .map_err(|e| e.to_string())?;
790
791    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
792    Ok(WriteAction::Created)
793}
794
795fn write_opencode_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
796    let desired = serde_json::json!({
797        "type": "local",
798        "command": [binary],
799        "enabled": true
800    });
801    if target.config_path.exists() {
802        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
803        let mut json =
804            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
805        let obj = json
806            .as_object_mut()
807            .ok_or_else(|| "root JSON must be an object".to_string())?;
808        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
809        let mcp_obj = mcp
810            .as_object_mut()
811            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
812        let existing = mcp_obj.get("lean-ctx").cloned();
813        if existing.as_ref() == Some(&desired) {
814            return Ok(WriteAction::Already);
815        }
816        mcp_obj.insert("lean-ctx".to_string(), desired);
817        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
818        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
819        return Ok(WriteAction::Updated);
820    }
821
822    if let Some(parent) = target.config_path.parent() {
823        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
824    }
825
826    let content = serde_json::to_string_pretty(&serde_json::json!({
827        "$schema": "https://opencode.ai/config.json",
828        "mcp": {
829            "lean-ctx": {
830                "type": "local",
831                "command": [binary],
832                "enabled": true
833            }
834        }
835    }))
836    .map_err(|e| e.to_string())?;
837
838    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
839    Ok(WriteAction::Created)
840}
841
842fn write_crush_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
843    let desired = serde_json::json!({ "type": "stdio", "command": binary });
844    if target.config_path.exists() {
845        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
846        let mut json =
847            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
848        let obj = json
849            .as_object_mut()
850            .ok_or_else(|| "root JSON must be an object".to_string())?;
851        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
852        let mcp_obj = mcp
853            .as_object_mut()
854            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
855
856        let existing = mcp_obj.get("lean-ctx").cloned();
857        if existing.as_ref() == Some(&desired) {
858            return Ok(WriteAction::Already);
859        }
860        mcp_obj.insert("lean-ctx".to_string(), desired);
861        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
862        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
863        return Ok(WriteAction::Updated);
864    }
865
866    let content = serde_json::to_string_pretty(&serde_json::json!({
867        "mcp": { "lean-ctx": desired }
868    }))
869    .map_err(|e| e.to_string())?;
870
871    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
872    Ok(WriteAction::Created)
873}
874
875fn upsert_codex_toml(existing: &str, binary: &str) -> String {
876    let mut out = String::with_capacity(existing.len() + 128);
877    let mut in_section = false;
878    let mut saw_section = false;
879    let mut wrote_command = false;
880    let mut wrote_args = false;
881
882    for line in existing.lines() {
883        let trimmed = line.trim();
884        if trimmed.starts_with('[') && trimmed.ends_with(']') {
885            if in_section && !wrote_command {
886                out.push_str(&format!("command = \"{}\"\n", binary));
887                wrote_command = true;
888            }
889            if in_section && !wrote_args {
890                out.push_str("args = []\n");
891                wrote_args = true;
892            }
893            in_section = trimmed == "[mcp_servers.lean-ctx]";
894            if in_section {
895                saw_section = true;
896            }
897            out.push_str(line);
898            out.push('\n');
899            continue;
900        }
901
902        if in_section {
903            if trimmed.starts_with("command") && trimmed.contains('=') {
904                out.push_str(&format!("command = \"{}\"\n", binary));
905                wrote_command = true;
906                continue;
907            }
908            if trimmed.starts_with("args") && trimmed.contains('=') {
909                out.push_str("args = []\n");
910                wrote_args = true;
911                continue;
912            }
913        }
914
915        out.push_str(line);
916        out.push('\n');
917    }
918
919    if saw_section {
920        if in_section && !wrote_command {
921            out.push_str(&format!("command = \"{}\"\n", binary));
922        }
923        if in_section && !wrote_args {
924            out.push_str("args = []\n");
925        }
926        return out;
927    }
928
929    if !out.ends_with('\n') {
930        out.push('\n');
931    }
932    out.push_str("\n[mcp_servers.lean-ctx]\n");
933    out.push_str(&format!("command = \"{}\"\n", binary));
934    out.push_str("args = []\n");
935    out
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
943        EditorTarget {
944            name: "test",
945            agent_key: "test".to_string(),
946            config_path: path,
947            detect_path: PathBuf::from("/nonexistent"),
948            config_type: ty,
949        }
950    }
951
952    #[test]
953    fn mcp_json_upserts_and_preserves_other_servers() {
954        let dir = tempfile::tempdir().unwrap();
955        let path = dir.path().join("mcp.json");
956        std::fs::write(
957            &path,
958            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
959        )
960        .unwrap();
961
962        let t = target(path.clone(), ConfigType::McpJson);
963        let action = write_mcp_json(&t, "/new/path/lean-ctx").unwrap();
964        assert_eq!(action, WriteAction::Updated);
965
966        let json: serde_json::Value =
967            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
968        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
969        assert_eq!(
970            json["mcpServers"]["lean-ctx"]["command"],
971            "/new/path/lean-ctx"
972        );
973        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
974        assert!(
975            json["mcpServers"]["lean-ctx"]["autoApprove"]
976                .as_array()
977                .unwrap()
978                .len()
979                > 5
980        );
981    }
982
983    #[test]
984    fn crush_config_writes_mcp_root() {
985        let dir = tempfile::tempdir().unwrap();
986        let path = dir.path().join("crush.json");
987        std::fs::write(
988            &path,
989            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
990        )
991        .unwrap();
992
993        let t = target(path.clone(), ConfigType::Crush);
994        let action = write_crush_config(&t, "new").unwrap();
995        assert_eq!(action, WriteAction::Updated);
996
997        let json: serde_json::Value =
998            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
999        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1000        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1001    }
1002
1003    #[test]
1004    fn codex_toml_upserts_existing_section() {
1005        let dir = tempfile::tempdir().unwrap();
1006        let path = dir.path().join("config.toml");
1007        std::fs::write(
1008            &path,
1009            r#"[mcp_servers.lean-ctx]
1010command = "old"
1011args = ["x"]
1012"#,
1013        )
1014        .unwrap();
1015
1016        let t = target(path.clone(), ConfigType::Codex);
1017        let action = write_codex_config(&t, "new").unwrap();
1018        assert_eq!(action, WriteAction::Updated);
1019
1020        let content = std::fs::read_to_string(&path).unwrap();
1021        assert!(content.contains(r#"command = "new""#));
1022        assert!(content.contains("args = []"));
1023    }
1024}
1025
1026fn detect_vscode_path() -> PathBuf {
1027    #[cfg(target_os = "macos")]
1028    {
1029        if let Some(home) = dirs::home_dir() {
1030            let vscode = home.join("Library/Application Support/Code/User/settings.json");
1031            if vscode.exists() {
1032                return vscode;
1033            }
1034        }
1035    }
1036    #[cfg(target_os = "linux")]
1037    {
1038        if let Some(home) = dirs::home_dir() {
1039            let vscode = home.join(".config/Code/User/settings.json");
1040            if vscode.exists() {
1041                return vscode;
1042            }
1043        }
1044    }
1045    #[cfg(target_os = "windows")]
1046    {
1047        if let Ok(appdata) = std::env::var("APPDATA") {
1048            let vscode = PathBuf::from(appdata).join("Code/User/settings.json");
1049            if vscode.exists() {
1050                return vscode;
1051            }
1052        }
1053    }
1054    if let Ok(output) = std::process::Command::new("which").arg("code").output() {
1055        if output.status.success() {
1056            return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1057        }
1058    }
1059    PathBuf::from("/nonexistent")
1060}
1061
1062fn vscode_mcp_path() -> PathBuf {
1063    if let Some(home) = dirs::home_dir() {
1064        #[cfg(target_os = "macos")]
1065        {
1066            return home.join("Library/Application Support/Code/User/mcp.json");
1067        }
1068        #[cfg(target_os = "linux")]
1069        {
1070            return home.join(".config/Code/User/mcp.json");
1071        }
1072        #[cfg(target_os = "windows")]
1073        {
1074            if let Ok(appdata) = std::env::var("APPDATA") {
1075                return PathBuf::from(appdata).join("Code/User/mcp.json");
1076            }
1077        }
1078        #[allow(unreachable_code)]
1079        home.join(".config/Code/User/mcp.json")
1080    } else {
1081        PathBuf::from("/nonexistent")
1082    }
1083}
1084
1085fn detect_jetbrains_path(home: &std::path::Path) -> PathBuf {
1086    #[cfg(target_os = "macos")]
1087    {
1088        let lib = home.join("Library/Application Support/JetBrains");
1089        if lib.exists() {
1090            return lib;
1091        }
1092    }
1093    #[cfg(target_os = "linux")]
1094    {
1095        let cfg = home.join(".config/JetBrains");
1096        if cfg.exists() {
1097            return cfg;
1098        }
1099    }
1100    if home.join(".jb-mcp.json").exists() {
1101        return home.join(".jb-mcp.json");
1102    }
1103    PathBuf::from("/nonexistent")
1104}
1105
1106fn cline_mcp_path() -> PathBuf {
1107    if let Some(home) = dirs::home_dir() {
1108        #[cfg(target_os = "macos")]
1109        {
1110            return home.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1111        }
1112        #[cfg(target_os = "linux")]
1113        {
1114            return home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1115        }
1116        #[cfg(target_os = "windows")]
1117        {
1118            if let Ok(appdata) = std::env::var("APPDATA") {
1119                return PathBuf::from(appdata).join("Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1120            }
1121        }
1122    }
1123    PathBuf::from("/nonexistent")
1124}
1125
1126fn detect_cline_path() -> PathBuf {
1127    if let Some(home) = dirs::home_dir() {
1128        #[cfg(target_os = "macos")]
1129        {
1130            let p = home
1131                .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev");
1132            if p.exists() {
1133                return p;
1134            }
1135        }
1136        #[cfg(target_os = "linux")]
1137        {
1138            let p = home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev");
1139            if p.exists() {
1140                return p;
1141            }
1142        }
1143    }
1144    PathBuf::from("/nonexistent")
1145}
1146
1147fn roo_mcp_path() -> PathBuf {
1148    if let Some(home) = dirs::home_dir() {
1149        #[cfg(target_os = "macos")]
1150        {
1151            return home.join("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1152        }
1153        #[cfg(target_os = "linux")]
1154        {
1155            return home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1156        }
1157        #[cfg(target_os = "windows")]
1158        {
1159            if let Ok(appdata) = std::env::var("APPDATA") {
1160                return PathBuf::from(appdata).join("Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1161            }
1162        }
1163    }
1164    PathBuf::from("/nonexistent")
1165}
1166
1167fn detect_roo_path() -> PathBuf {
1168    if let Some(home) = dirs::home_dir() {
1169        #[cfg(target_os = "macos")]
1170        {
1171            let p = home.join(
1172                "Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline",
1173            );
1174            if p.exists() {
1175                return p;
1176            }
1177        }
1178        #[cfg(target_os = "linux")]
1179        {
1180            let p = home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline");
1181            if p.exists() {
1182                return p;
1183            }
1184        }
1185    }
1186    PathBuf::from("/nonexistent")
1187}
1188
1189fn resolve_portable_binary() -> String {
1190    let which_cmd = if cfg!(windows) { "where" } else { "which" };
1191    if let Ok(status) = std::process::Command::new(which_cmd)
1192        .arg("lean-ctx")
1193        .stdout(std::process::Stdio::null())
1194        .stderr(std::process::Stdio::null())
1195        .status()
1196    {
1197        if status.success() {
1198            return "lean-ctx".to_string();
1199        }
1200    }
1201    std::env::current_exe()
1202        .map(|p| p.to_string_lossy().to_string())
1203        .unwrap_or_else(|_| "lean-ctx".to_string())
1204}