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 _ = 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: claude_config_json_path(home),
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
571/// Returns the path to Claude Code's MCP config JSON.
572/// Respects `$CLAUDE_CONFIG_DIR` (official Claude Code env var).
573/// Falls back to `~/.claude.json`.
574pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
575    if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
576        let custom = PathBuf::from(&dir);
577        let json_in_dir = custom.join(".claude.json");
578        if json_in_dir.exists() {
579            return json_in_dir;
580        }
581        let parent_json = custom.parent().map(|p| p.join(".claude.json"));
582        if let Some(pj) = &parent_json {
583            if pj.exists() {
584                return pj.clone();
585            }
586        }
587        return json_in_dir;
588    }
589    home.join(".claude.json")
590}
591
592/// Returns the Claude config directory.
593/// Respects `$CLAUDE_CONFIG_DIR`, falls back to `~/.claude`.
594pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
595    if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
596        return PathBuf::from(dir);
597    }
598    home.join(".claude")
599}
600
601fn detect_claude_path() -> PathBuf {
602    let which_cmd = if cfg!(windows) { "where" } else { "which" };
603    if let Ok(output) = std::process::Command::new(which_cmd).arg("claude").output() {
604        if output.status.success() {
605            return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
606        }
607    }
608    if let Some(home) = dirs::home_dir() {
609        let cfg = claude_config_json_path(&home);
610        if cfg.exists() {
611            return cfg;
612        }
613        if claude_config_dir(&home).exists() {
614            return claude_config_dir(&home);
615        }
616    }
617    PathBuf::from("/nonexistent")
618}
619
620fn detect_codex_path(home: &std::path::Path) -> PathBuf {
621    let codex_dir = home.join(".codex");
622    if codex_dir.exists() {
623        return codex_dir;
624    }
625    if let Ok(output) = std::process::Command::new("which").arg("codex").output() {
626        if output.status.success() {
627            return codex_dir;
628        }
629    }
630    PathBuf::from("/nonexistent")
631}
632
633fn zed_settings_path(home: &std::path::Path) -> PathBuf {
634    if cfg!(target_os = "macos") {
635        home.join("Library/Application Support/Zed/settings.json")
636    } else {
637        home.join(".config/zed/settings.json")
638    }
639}
640
641fn zed_config_dir(home: &std::path::Path) -> PathBuf {
642    if cfg!(target_os = "macos") {
643        home.join("Library/Application Support/Zed")
644    } else {
645        home.join(".config/zed")
646    }
647}
648
649fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
650    if let Some(parent) = target.config_path.parent() {
651        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
652    }
653
654    match target.config_type {
655        ConfigType::McpJson => write_mcp_json(target, binary),
656        ConfigType::Zed => write_zed_config(target, binary),
657        ConfigType::Codex => write_codex_config(target, binary),
658        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary),
659        ConfigType::OpenCode => write_opencode_config(target, binary),
660        ConfigType::Crush => write_crush_config(target, binary),
661    }
662}
663
664fn lean_ctx_server_entry(binary: &str, data_dir: &str) -> serde_json::Value {
665    serde_json::json!({
666        "command": binary,
667        "env": {
668            "LEAN_CTX_DATA_DIR": data_dir
669        },
670        "autoApprove": [
671            "ctx_read", "ctx_shell", "ctx_search", "ctx_tree",
672            "ctx_overview", "ctx_compress", "ctx_metrics", "ctx_session",
673            "ctx_knowledge", "ctx_agent", "ctx_analyze", "ctx_benchmark",
674            "ctx_cache", "ctx_discover", "ctx_smart_read", "ctx_delta",
675            "ctx_edit", "ctx_dedup", "ctx_fill", "ctx_intent", "ctx_response",
676            "ctx_context", "ctx_graph", "ctx_wrapped", "ctx_multi_read",
677            "ctx_semantic_search", "ctx"
678        ]
679    })
680}
681
682fn write_mcp_json(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
683    let data_dir = dirs::home_dir()
684        .ok_or_else(|| "Cannot determine home directory".to_string())?
685        .join(".lean-ctx")
686        .to_string_lossy()
687        .to_string();
688    let desired = lean_ctx_server_entry(binary, &data_dir);
689    if target.config_path.exists() {
690        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
691        let mut json =
692            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
693        let obj = json
694            .as_object_mut()
695            .ok_or_else(|| "root JSON must be an object".to_string())?;
696        let servers = obj
697            .entry("mcpServers")
698            .or_insert_with(|| serde_json::json!({}));
699        let servers_obj = servers
700            .as_object_mut()
701            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
702
703        let existing = servers_obj.get("lean-ctx").cloned();
704        if existing.as_ref() == Some(&desired) {
705            return Ok(WriteAction::Already);
706        }
707        servers_obj.insert("lean-ctx".to_string(), desired);
708        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
709        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
710        return Ok(WriteAction::Updated);
711    }
712
713    let content = serde_json::to_string_pretty(&serde_json::json!({
714        "mcpServers": {
715            "lean-ctx": desired
716        }
717    }))
718    .map_err(|e| e.to_string())?;
719
720    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
721    Ok(WriteAction::Created)
722}
723
724fn write_zed_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
725    let desired = serde_json::json!({
726        "source": "custom",
727        "command": binary,
728        "args": [],
729        "env": {}
730    });
731    if target.config_path.exists() {
732        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
733        let mut json =
734            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
735        let obj = json
736            .as_object_mut()
737            .ok_or_else(|| "root JSON must be an object".to_string())?;
738        let servers = obj
739            .entry("context_servers")
740            .or_insert_with(|| serde_json::json!({}));
741        let servers_obj = servers
742            .as_object_mut()
743            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
744
745        let existing = servers_obj.get("lean-ctx").cloned();
746        if existing.as_ref() == Some(&desired) {
747            return Ok(WriteAction::Already);
748        }
749        servers_obj.insert("lean-ctx".to_string(), desired);
750        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
751        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
752        return Ok(WriteAction::Updated);
753    }
754
755    let content = serde_json::to_string_pretty(&serde_json::json!({
756        "context_servers": {
757            "lean-ctx": desired
758        }
759    }))
760    .map_err(|e| e.to_string())?;
761
762    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
763    Ok(WriteAction::Created)
764}
765
766fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
767    if target.config_path.exists() {
768        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
769        let updated = upsert_codex_toml(&content, binary);
770        if updated == content {
771            return Ok(WriteAction::Already);
772        }
773        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
774        return Ok(WriteAction::Updated);
775    }
776
777    let content = format!(
778        "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
779        binary
780    );
781    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
782    Ok(WriteAction::Created)
783}
784
785fn write_vscode_mcp(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
786    let desired = serde_json::json!({ "command": binary, "args": [] });
787    if target.config_path.exists() {
788        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
789        let mut json =
790            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
791        let obj = json
792            .as_object_mut()
793            .ok_or_else(|| "root JSON must be an object".to_string())?;
794        let servers = obj
795            .entry("servers")
796            .or_insert_with(|| serde_json::json!({}));
797        let servers_obj = servers
798            .as_object_mut()
799            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
800
801        let existing = servers_obj.get("lean-ctx").cloned();
802        if existing.as_ref() == Some(&desired) {
803            return Ok(WriteAction::Already);
804        }
805        servers_obj.insert("lean-ctx".to_string(), desired);
806        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
807        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
808        return Ok(WriteAction::Updated);
809    }
810
811    if let Some(parent) = target.config_path.parent() {
812        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
813    }
814
815    let content = serde_json::to_string_pretty(&serde_json::json!({
816        "servers": {
817            "lean-ctx": {
818                "command": binary,
819                "args": []
820            }
821        }
822    }))
823    .map_err(|e| e.to_string())?;
824
825    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
826    Ok(WriteAction::Created)
827}
828
829fn write_opencode_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
830    let desired = serde_json::json!({
831        "type": "local",
832        "command": [binary],
833        "enabled": true
834    });
835    if target.config_path.exists() {
836        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
837        let mut json =
838            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
839        let obj = json
840            .as_object_mut()
841            .ok_or_else(|| "root JSON must be an object".to_string())?;
842        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
843        let mcp_obj = mcp
844            .as_object_mut()
845            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
846        let existing = mcp_obj.get("lean-ctx").cloned();
847        if existing.as_ref() == Some(&desired) {
848            return Ok(WriteAction::Already);
849        }
850        mcp_obj.insert("lean-ctx".to_string(), desired);
851        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
852        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
853        return Ok(WriteAction::Updated);
854    }
855
856    if let Some(parent) = target.config_path.parent() {
857        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
858    }
859
860    let content = serde_json::to_string_pretty(&serde_json::json!({
861        "$schema": "https://opencode.ai/config.json",
862        "mcp": {
863            "lean-ctx": {
864                "type": "local",
865                "command": [binary],
866                "enabled": true
867            }
868        }
869    }))
870    .map_err(|e| e.to_string())?;
871
872    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
873    Ok(WriteAction::Created)
874}
875
876fn write_crush_config(target: &EditorTarget, binary: &str) -> Result<WriteAction, String> {
877    let desired = serde_json::json!({ "type": "stdio", "command": binary });
878    if target.config_path.exists() {
879        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
880        let mut json =
881            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| e.to_string())?;
882        let obj = json
883            .as_object_mut()
884            .ok_or_else(|| "root JSON must be an object".to_string())?;
885        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
886        let mcp_obj = mcp
887            .as_object_mut()
888            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
889
890        let existing = mcp_obj.get("lean-ctx").cloned();
891        if existing.as_ref() == Some(&desired) {
892            return Ok(WriteAction::Already);
893        }
894        mcp_obj.insert("lean-ctx".to_string(), desired);
895        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
896        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
897        return Ok(WriteAction::Updated);
898    }
899
900    let content = serde_json::to_string_pretty(&serde_json::json!({
901        "mcp": { "lean-ctx": desired }
902    }))
903    .map_err(|e| e.to_string())?;
904
905    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
906    Ok(WriteAction::Created)
907}
908
909fn upsert_codex_toml(existing: &str, binary: &str) -> String {
910    let mut out = String::with_capacity(existing.len() + 128);
911    let mut in_section = false;
912    let mut saw_section = false;
913    let mut wrote_command = false;
914    let mut wrote_args = false;
915
916    for line in existing.lines() {
917        let trimmed = line.trim();
918        if trimmed.starts_with('[') && trimmed.ends_with(']') {
919            if in_section && !wrote_command {
920                out.push_str(&format!("command = \"{}\"\n", binary));
921                wrote_command = true;
922            }
923            if in_section && !wrote_args {
924                out.push_str("args = []\n");
925                wrote_args = true;
926            }
927            in_section = trimmed == "[mcp_servers.lean-ctx]";
928            if in_section {
929                saw_section = true;
930            }
931            out.push_str(line);
932            out.push('\n');
933            continue;
934        }
935
936        if in_section {
937            if trimmed.starts_with("command") && trimmed.contains('=') {
938                out.push_str(&format!("command = \"{}\"\n", binary));
939                wrote_command = true;
940                continue;
941            }
942            if trimmed.starts_with("args") && trimmed.contains('=') {
943                out.push_str("args = []\n");
944                wrote_args = true;
945                continue;
946            }
947        }
948
949        out.push_str(line);
950        out.push('\n');
951    }
952
953    if saw_section {
954        if in_section && !wrote_command {
955            out.push_str(&format!("command = \"{}\"\n", binary));
956        }
957        if in_section && !wrote_args {
958            out.push_str("args = []\n");
959        }
960        return out;
961    }
962
963    if !out.ends_with('\n') {
964        out.push('\n');
965    }
966    out.push_str("\n[mcp_servers.lean-ctx]\n");
967    out.push_str(&format!("command = \"{}\"\n", binary));
968    out.push_str("args = []\n");
969    out
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
977        EditorTarget {
978            name: "test",
979            agent_key: "test".to_string(),
980            config_path: path,
981            detect_path: PathBuf::from("/nonexistent"),
982            config_type: ty,
983        }
984    }
985
986    #[test]
987    fn mcp_json_upserts_and_preserves_other_servers() {
988        let dir = tempfile::tempdir().unwrap();
989        let path = dir.path().join("mcp.json");
990        std::fs::write(
991            &path,
992            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
993        )
994        .unwrap();
995
996        let t = target(path.clone(), ConfigType::McpJson);
997        let action = write_mcp_json(&t, "/new/path/lean-ctx").unwrap();
998        assert_eq!(action, WriteAction::Updated);
999
1000        let json: serde_json::Value =
1001            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1002        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1003        assert_eq!(
1004            json["mcpServers"]["lean-ctx"]["command"],
1005            "/new/path/lean-ctx"
1006        );
1007        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1008        assert!(
1009            json["mcpServers"]["lean-ctx"]["autoApprove"]
1010                .as_array()
1011                .unwrap()
1012                .len()
1013                > 5
1014        );
1015    }
1016
1017    #[test]
1018    fn crush_config_writes_mcp_root() {
1019        let dir = tempfile::tempdir().unwrap();
1020        let path = dir.path().join("crush.json");
1021        std::fs::write(
1022            &path,
1023            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1024        )
1025        .unwrap();
1026
1027        let t = target(path.clone(), ConfigType::Crush);
1028        let action = write_crush_config(&t, "new").unwrap();
1029        assert_eq!(action, WriteAction::Updated);
1030
1031        let json: serde_json::Value =
1032            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1033        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1034        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1035    }
1036
1037    #[test]
1038    fn codex_toml_upserts_existing_section() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let path = dir.path().join("config.toml");
1041        std::fs::write(
1042            &path,
1043            r#"[mcp_servers.lean-ctx]
1044command = "old"
1045args = ["x"]
1046"#,
1047        )
1048        .unwrap();
1049
1050        let t = target(path.clone(), ConfigType::Codex);
1051        let action = write_codex_config(&t, "new").unwrap();
1052        assert_eq!(action, WriteAction::Updated);
1053
1054        let content = std::fs::read_to_string(&path).unwrap();
1055        assert!(content.contains(r#"command = "new""#));
1056        assert!(content.contains("args = []"));
1057    }
1058}
1059
1060fn detect_vscode_path() -> PathBuf {
1061    #[cfg(target_os = "macos")]
1062    {
1063        if let Some(home) = dirs::home_dir() {
1064            let vscode = home.join("Library/Application Support/Code/User/settings.json");
1065            if vscode.exists() {
1066                return vscode;
1067            }
1068        }
1069    }
1070    #[cfg(target_os = "linux")]
1071    {
1072        if let Some(home) = dirs::home_dir() {
1073            let vscode = home.join(".config/Code/User/settings.json");
1074            if vscode.exists() {
1075                return vscode;
1076            }
1077        }
1078    }
1079    #[cfg(target_os = "windows")]
1080    {
1081        if let Ok(appdata) = std::env::var("APPDATA") {
1082            let vscode = PathBuf::from(appdata).join("Code/User/settings.json");
1083            if vscode.exists() {
1084                return vscode;
1085            }
1086        }
1087    }
1088    if let Ok(output) = std::process::Command::new("which").arg("code").output() {
1089        if output.status.success() {
1090            return PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1091        }
1092    }
1093    PathBuf::from("/nonexistent")
1094}
1095
1096fn vscode_mcp_path() -> PathBuf {
1097    if let Some(home) = dirs::home_dir() {
1098        #[cfg(target_os = "macos")]
1099        {
1100            return home.join("Library/Application Support/Code/User/mcp.json");
1101        }
1102        #[cfg(target_os = "linux")]
1103        {
1104            return home.join(".config/Code/User/mcp.json");
1105        }
1106        #[cfg(target_os = "windows")]
1107        {
1108            if let Ok(appdata) = std::env::var("APPDATA") {
1109                return PathBuf::from(appdata).join("Code/User/mcp.json");
1110            }
1111        }
1112        #[allow(unreachable_code)]
1113        home.join(".config/Code/User/mcp.json")
1114    } else {
1115        PathBuf::from("/nonexistent")
1116    }
1117}
1118
1119fn detect_jetbrains_path(home: &std::path::Path) -> PathBuf {
1120    #[cfg(target_os = "macos")]
1121    {
1122        let lib = home.join("Library/Application Support/JetBrains");
1123        if lib.exists() {
1124            return lib;
1125        }
1126    }
1127    #[cfg(target_os = "linux")]
1128    {
1129        let cfg = home.join(".config/JetBrains");
1130        if cfg.exists() {
1131            return cfg;
1132        }
1133    }
1134    if home.join(".jb-mcp.json").exists() {
1135        return home.join(".jb-mcp.json");
1136    }
1137    PathBuf::from("/nonexistent")
1138}
1139
1140fn cline_mcp_path() -> PathBuf {
1141    if let Some(home) = dirs::home_dir() {
1142        #[cfg(target_os = "macos")]
1143        {
1144            return home.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1145        }
1146        #[cfg(target_os = "linux")]
1147        {
1148            return home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1149        }
1150        #[cfg(target_os = "windows")]
1151        {
1152            if let Ok(appdata) = std::env::var("APPDATA") {
1153                return PathBuf::from(appdata).join("Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
1154            }
1155        }
1156    }
1157    PathBuf::from("/nonexistent")
1158}
1159
1160fn detect_cline_path() -> PathBuf {
1161    if let Some(home) = dirs::home_dir() {
1162        #[cfg(target_os = "macos")]
1163        {
1164            let p = home
1165                .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev");
1166            if p.exists() {
1167                return p;
1168            }
1169        }
1170        #[cfg(target_os = "linux")]
1171        {
1172            let p = home.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev");
1173            if p.exists() {
1174                return p;
1175            }
1176        }
1177    }
1178    PathBuf::from("/nonexistent")
1179}
1180
1181fn roo_mcp_path() -> PathBuf {
1182    if let Some(home) = dirs::home_dir() {
1183        #[cfg(target_os = "macos")]
1184        {
1185            return home.join("Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1186        }
1187        #[cfg(target_os = "linux")]
1188        {
1189            return home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1190        }
1191        #[cfg(target_os = "windows")]
1192        {
1193            if let Ok(appdata) = std::env::var("APPDATA") {
1194                return PathBuf::from(appdata).join("Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json");
1195            }
1196        }
1197    }
1198    PathBuf::from("/nonexistent")
1199}
1200
1201fn detect_roo_path() -> PathBuf {
1202    if let Some(home) = dirs::home_dir() {
1203        #[cfg(target_os = "macos")]
1204        {
1205            let p = home.join(
1206                "Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline",
1207            );
1208            if p.exists() {
1209                return p;
1210            }
1211        }
1212        #[cfg(target_os = "linux")]
1213        {
1214            let p = home.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline");
1215            if p.exists() {
1216                return p;
1217            }
1218        }
1219    }
1220    PathBuf::from("/nonexistent")
1221}
1222
1223fn resolve_portable_binary() -> String {
1224    let which_cmd = if cfg!(windows) { "where" } else { "which" };
1225    if let Ok(status) = std::process::Command::new(which_cmd)
1226        .arg("lean-ctx")
1227        .stdout(std::process::Stdio::null())
1228        .stderr(std::process::Stdio::null())
1229        .status()
1230    {
1231        if status.success() {
1232            return "lean-ctx".to_string();
1233        }
1234    }
1235    std::env::current_exe()
1236        .map(|p| p.to_string_lossy().to_string())
1237        .unwrap_or_else(|_| "lean-ctx".to_string())
1238}