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            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
586/// Returns the path to Claude Code's MCP config JSON.
587/// Respects `$CLAUDE_CONFIG_DIR` — Claude Code always creates `.claude.json`
588/// inside that directory. Falls back to `~/.claude.json`.
589pub 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
596/// Returns the Claude config directory.
597/// Respects `$CLAUDE_CONFIG_DIR`, falls back to `~/.claude`.
598pub 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}