Skip to main content

lean_ctx/
uninstall.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4pub fn run() {
5    let home = match dirs::home_dir() {
6        Some(h) => h,
7        None => {
8            eprintln!("  ✗ Could not determine home directory");
9            return;
10        }
11    };
12
13    println!("\n  lean-ctx uninstall\n  ──────────────────────────────────\n");
14
15    let mut removed_any = false;
16
17    removed_any |= remove_shell_hook(&home);
18    crate::proxy_setup::uninstall_proxy_env(&home, false);
19    removed_any |= remove_mcp_configs(&home);
20    removed_any |= remove_rules_files(&home);
21    removed_any |= remove_hook_files(&home);
22    removed_any |= remove_project_agent_files();
23    removed_any |= remove_data_dir(&home);
24
25    println!();
26
27    if removed_any {
28        println!("  ──────────────────────────────────");
29        println!("  lean-ctx configuration removed.\n");
30    } else {
31        println!("  Nothing to remove — lean-ctx was not configured.\n");
32    }
33
34    print_binary_removal_instructions();
35}
36
37fn remove_project_agent_files() -> bool {
38    let cwd = std::env::current_dir().unwrap_or_default();
39    let agents = cwd.join("AGENTS.md");
40    let lean_ctx_md = cwd.join("LEAN-CTX.md");
41
42    const START: &str = "<!-- lean-ctx -->";
43    const END: &str = "<!-- /lean-ctx -->";
44    const OWNED: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
45
46    let mut removed = false;
47
48    if agents.exists() {
49        if let Ok(content) = fs::read_to_string(&agents) {
50            if content.contains(START) {
51                let cleaned = remove_marked_block(&content, START, END);
52                if cleaned != content {
53                    if let Err(e) = fs::write(&agents, cleaned) {
54                        eprintln!("  ✗ Failed to update project AGENTS.md: {e}");
55                    } else {
56                        println!("  ✓ Project: removed lean-ctx block from AGENTS.md");
57                        removed = true;
58                    }
59                }
60            }
61        }
62    }
63
64    if lean_ctx_md.exists() {
65        if let Ok(content) = fs::read_to_string(&lean_ctx_md) {
66            if content.contains(OWNED) {
67                if let Err(e) = fs::remove_file(&lean_ctx_md) {
68                    eprintln!("  ✗ Failed to remove project LEAN-CTX.md: {e}");
69                } else {
70                    println!("  ✓ Project: removed LEAN-CTX.md");
71                    removed = true;
72                }
73            }
74        }
75    }
76
77    let project_files = [
78        ".windsurfrules",
79        ".clinerules",
80        ".cursorrules",
81        ".kiro/steering/lean-ctx.md",
82        ".cursor/rules/lean-ctx.mdc",
83    ];
84    for rel in &project_files {
85        let path = cwd.join(rel);
86        if path.exists() {
87            if let Ok(content) = fs::read_to_string(&path) {
88                if content.contains("lean-ctx") {
89                    let _ = fs::remove_file(&path);
90                    println!("  ✓ Project: removed {rel}");
91                    removed = true;
92                }
93            }
94        }
95    }
96
97    removed
98}
99
100fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
101    let s = content.find(start);
102    let e = content.find(end);
103    match (s, e) {
104        (Some(si), Some(ei)) if ei >= si => {
105            let after_end = ei + end.len();
106            let before = &content[..si];
107            let after = &content[after_end..];
108            let mut out = String::new();
109            out.push_str(before.trim_end_matches('\n'));
110            out.push('\n');
111            if !after.trim().is_empty() {
112                out.push('\n');
113                out.push_str(after.trim_start_matches('\n'));
114            }
115            out
116        }
117        _ => content.to_string(),
118    }
119}
120
121fn remove_shell_hook(home: &Path) -> bool {
122    let shell = std::env::var("SHELL").unwrap_or_default();
123    let mut removed = false;
124
125    crate::shell_hook::uninstall_all(false);
126
127    let rc_files: Vec<PathBuf> = vec![
128        home.join(".zshrc"),
129        home.join(".bashrc"),
130        home.join(".config/fish/config.fish"),
131        #[cfg(windows)]
132        home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
133    ];
134
135    for rc in &rc_files {
136        if !rc.exists() {
137            continue;
138        }
139        let content = match fs::read_to_string(rc) {
140            Ok(c) => c,
141            Err(_) => continue,
142        };
143        if !content.contains("lean-ctx") {
144            continue;
145        }
146
147        let cleaned = remove_lean_ctx_block(&content);
148        if cleaned.trim() != content.trim() {
149            let bak = rc.with_extension("lean-ctx.bak");
150            let _ = fs::copy(rc, &bak);
151            if let Err(e) = fs::write(rc, &cleaned) {
152                eprintln!("  ✗ Failed to update {}: {}", rc.display(), e);
153            } else {
154                let short = shorten(rc, home);
155                println!("  ✓ Shell hook removed from {short}");
156                println!("    Backup: {}", shorten(&bak, home));
157                removed = true;
158            }
159        }
160    }
161
162    if !removed && !shell.is_empty() {
163        println!("  · No shell hook found");
164    }
165
166    removed
167}
168
169fn remove_mcp_configs(home: &Path) -> bool {
170    let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR")
171        .ok()
172        .map(|d| PathBuf::from(d).join(".claude.json"))
173        .unwrap_or_else(|| PathBuf::from("/nonexistent"));
174    let configs: Vec<(&str, PathBuf)> = vec![
175        ("Cursor", home.join(".cursor/mcp.json")),
176        ("Claude Code (config dir)", claude_cfg_dir_json),
177        ("Claude Code (home)", home.join(".claude.json")),
178        ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
179        ("Gemini CLI", home.join(".gemini/settings.json")),
180        (
181            "Gemini CLI (legacy)",
182            home.join(".gemini/settings/mcp.json"),
183        ),
184        (
185            "Antigravity",
186            home.join(".gemini/antigravity/mcp_config.json"),
187        ),
188        ("Codex CLI", home.join(".codex/config.toml")),
189        ("OpenCode", home.join(".config/opencode/opencode.json")),
190        ("Qwen Code", home.join(".qwen/mcp.json")),
191        ("Trae", home.join(".trae/mcp.json")),
192        ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
193        ("JetBrains IDEs", home.join(".jb-mcp.json")),
194        ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
195        ("Verdent", home.join(".verdent/mcp.json")),
196        ("Aider", home.join(".aider/mcp.json")),
197        ("Amp", home.join(".config/amp/settings.json")),
198        ("Crush", home.join(".config/crush/crush.json")),
199        ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
200        ("Cline", crate::core::editor_registry::cline_mcp_path()),
201        ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
202        ("Hermes Agent", home.join(".hermes/config.yaml")),
203    ];
204
205    let mut removed = false;
206
207    for (name, path) in &configs {
208        if !path.exists() {
209            continue;
210        }
211        let content = match fs::read_to_string(path) {
212            Ok(c) => c,
213            Err(_) => continue,
214        };
215        if !content.contains("lean-ctx") {
216            continue;
217        }
218
219        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
220        let is_yaml = ext == "yaml" || ext == "yml";
221        let is_toml = ext == "toml";
222
223        let cleaned = if is_yaml {
224            Some(remove_lean_ctx_from_yaml(&content))
225        } else if is_toml {
226            Some(remove_lean_ctx_from_toml(&content))
227        } else {
228            remove_lean_ctx_from_json(&content)
229        };
230
231        if let Some(cleaned) = cleaned {
232            if let Err(e) = fs::write(path, &cleaned) {
233                eprintln!("  ✗ Failed to update {} config: {}", name, e);
234            } else {
235                println!("  ✓ MCP config removed from {name}");
236                removed = true;
237            }
238        }
239    }
240
241    let zed_path = crate::core::editor_registry::zed_settings_path(home);
242    if zed_path.exists() {
243        if let Ok(content) = fs::read_to_string(&zed_path) {
244            if content.contains("lean-ctx") {
245                println!(
246                    "  ⚠ Zed: manually remove lean-ctx from {}",
247                    shorten(&zed_path, home)
248                );
249            }
250        }
251    }
252
253    let vscode_path = crate::core::editor_registry::vscode_mcp_path();
254    if vscode_path.exists() {
255        if let Ok(content) = fs::read_to_string(&vscode_path) {
256            if content.contains("lean-ctx") {
257                if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
258                    if let Err(e) = fs::write(&vscode_path, &cleaned) {
259                        eprintln!("  ✗ Failed to update VS Code config: {e}");
260                    } else {
261                        println!("  ✓ MCP config removed from VS Code / Copilot");
262                        removed = true;
263                    }
264                }
265            }
266        }
267    }
268
269    removed
270}
271
272fn remove_rules_files(home: &Path) -> bool {
273    let rules_files: Vec<(&str, PathBuf)> = vec![
274        (
275            "Claude Code",
276            crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
277        ),
278        // Legacy: shared CLAUDE.md (older releases).
279        (
280            "Claude Code (legacy)",
281            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
282        ),
283        // Legacy: hardcoded home path (very old releases).
284        ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
285        ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
286        ("Gemini CLI", home.join(".gemini/GEMINI.md")),
287        (
288            "Gemini CLI (legacy)",
289            home.join(".gemini/rules/lean-ctx.md"),
290        ),
291        ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
292        ("Codex CLI", home.join(".codex/instructions.md")),
293        ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
294        ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
295        ("Cline", home.join(".cline/rules/lean-ctx.md")),
296        ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
297        ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
298        ("Continue", home.join(".continue/rules/lean-ctx.md")),
299        ("Aider", home.join(".aider/rules/lean-ctx.md")),
300        ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
301        ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
302        ("Trae", home.join(".trae/rules/lean-ctx.md")),
303        (
304            "Amazon Q Developer",
305            home.join(".aws/amazonq/rules/lean-ctx.md"),
306        ),
307        ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
308        (
309            "Antigravity",
310            home.join(".gemini/antigravity/rules/lean-ctx.md"),
311        ),
312        ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
313        ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
314        ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
315        ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
316    ];
317
318    let mut removed = false;
319    for (name, path) in &rules_files {
320        if !path.exists() {
321            continue;
322        }
323        if let Ok(content) = fs::read_to_string(path) {
324            if content.contains("lean-ctx") {
325                if let Err(e) = fs::remove_file(path) {
326                    eprintln!("  ✗ Failed to remove {name} rules: {e}");
327                } else {
328                    println!("  ✓ Rules removed from {name}");
329                    removed = true;
330                }
331            }
332        }
333    }
334
335    let hermes_md = home.join(".hermes/HERMES.md");
336    if hermes_md.exists() {
337        if let Ok(content) = fs::read_to_string(&hermes_md) {
338            if content.contains("lean-ctx") {
339                let cleaned = remove_lean_ctx_block_from_md(&content);
340                if cleaned.trim().is_empty() {
341                    let _ = fs::remove_file(&hermes_md);
342                } else {
343                    let _ = fs::write(&hermes_md, &cleaned);
344                }
345                println!("  ✓ Rules removed from Hermes Agent");
346                removed = true;
347            }
348        }
349    }
350
351    if let Ok(cwd) = std::env::current_dir() {
352        let project_hermes = cwd.join(".hermes.md");
353        if project_hermes.exists() {
354            if let Ok(content) = fs::read_to_string(&project_hermes) {
355                if content.contains("lean-ctx") {
356                    let cleaned = remove_lean_ctx_block_from_md(&content);
357                    if cleaned.trim().is_empty() {
358                        let _ = fs::remove_file(&project_hermes);
359                    } else {
360                        let _ = fs::write(&project_hermes, &cleaned);
361                    }
362                    println!("  ✓ Rules removed from .hermes.md");
363                    removed = true;
364                }
365            }
366        }
367    }
368
369    if !removed {
370        println!("  · No rules files found");
371    }
372    removed
373}
374
375fn remove_lean_ctx_block_from_md(content: &str) -> String {
376    let mut out = String::with_capacity(content.len());
377    let mut in_block = false;
378
379    for line in content.lines() {
380        if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
381            in_block = true;
382            continue;
383        }
384        if in_block {
385            if line.starts_with('#') && !line.contains("lean-ctx") {
386                in_block = false;
387                out.push_str(line);
388                out.push('\n');
389            }
390            continue;
391        }
392        out.push_str(line);
393        out.push('\n');
394    }
395
396    while out.starts_with('\n') {
397        out.remove(0);
398    }
399    while out.ends_with("\n\n") {
400        out.pop();
401    }
402    out
403}
404
405fn remove_hook_files(home: &Path) -> bool {
406    let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
407    let hook_files: Vec<PathBuf> = vec![
408        claude_hooks_dir.join("lean-ctx-rewrite.sh"),
409        claude_hooks_dir.join("lean-ctx-redirect.sh"),
410        claude_hooks_dir.join("lean-ctx-rewrite-native"),
411        claude_hooks_dir.join("lean-ctx-redirect-native"),
412        home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
413        home.join(".cursor/hooks/lean-ctx-redirect.sh"),
414        home.join(".cursor/hooks/lean-ctx-rewrite-native"),
415        home.join(".cursor/hooks/lean-ctx-redirect-native"),
416        home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
417        home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
418        home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
419        home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
420    ];
421
422    let mut removed = false;
423    for path in &hook_files {
424        if path.exists() {
425            if let Err(e) = fs::remove_file(path) {
426                eprintln!("  ✗ Failed to remove hook {}: {e}", path.display());
427            } else {
428                removed = true;
429            }
430        }
431    }
432
433    if removed {
434        println!("  ✓ Hook scripts removed");
435    }
436
437    for (label, hj_path) in [
438        ("Cursor", home.join(".cursor/hooks.json")),
439        ("Codex", home.join(".codex/hooks.json")),
440    ] {
441        if hj_path.exists() {
442            if let Ok(content) = fs::read_to_string(&hj_path) {
443                if content.contains("lean-ctx") {
444                    if let Err(e) = fs::remove_file(&hj_path) {
445                        eprintln!("  ✗ Failed to remove {label} hooks.json: {e}");
446                    } else {
447                        println!("  ✓ {label} hooks.json removed");
448                        removed = true;
449                    }
450                }
451            }
452        }
453    }
454
455    removed
456}
457
458fn remove_data_dir(home: &Path) -> bool {
459    let data_dir = home.join(".lean-ctx");
460    if !data_dir.exists() {
461        println!("  · No data directory found");
462        return false;
463    }
464
465    match fs::remove_dir_all(&data_dir) {
466        Ok(_) => {
467            println!("  ✓ Data directory removed (~/.lean-ctx/)");
468            true
469        }
470        Err(e) => {
471            eprintln!("  ✗ Failed to remove ~/.lean-ctx/: {e}");
472            false
473        }
474    }
475}
476
477fn print_binary_removal_instructions() {
478    let binary_path = std::env::current_exe()
479        .map(|p| p.display().to_string())
480        .unwrap_or_else(|_| "lean-ctx".to_string());
481
482    println!("  To complete uninstallation, remove the binary:\n");
483
484    if binary_path.contains(".cargo") {
485        println!("    cargo uninstall lean-ctx\n");
486    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
487        println!("    brew uninstall lean-ctx\n");
488    } else {
489        println!("    rm {binary_path}\n");
490    }
491
492    println!("  Then restart your shell.\n");
493}
494
495fn remove_lean_ctx_block(content: &str) -> String {
496    if content.contains("# lean-ctx shell hook — end") {
497        return remove_lean_ctx_block_by_marker(content);
498    }
499    remove_lean_ctx_block_legacy(content)
500}
501
502fn remove_lean_ctx_block_by_marker(content: &str) -> String {
503    let mut result = String::new();
504    let mut in_block = false;
505
506    for line in content.lines() {
507        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
508            in_block = true;
509            continue;
510        }
511        if in_block {
512            if line.trim() == "# lean-ctx shell hook — end" {
513                in_block = false;
514            }
515            continue;
516        }
517        result.push_str(line);
518        result.push('\n');
519    }
520    result
521}
522
523fn remove_lean_ctx_block_legacy(content: &str) -> String {
524    let mut result = String::new();
525    let mut in_block = false;
526
527    for line in content.lines() {
528        if line.contains("lean-ctx shell hook") {
529            in_block = true;
530            continue;
531        }
532        if in_block {
533            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
534                if line.trim() == "fi" || line.trim() == "end" {
535                    in_block = false;
536                }
537                continue;
538            }
539            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
540                in_block = false;
541                result.push_str(line);
542                result.push('\n');
543            }
544            continue;
545        }
546        result.push_str(line);
547        result.push('\n');
548    }
549    result
550}
551
552fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
553    let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
554    let mut modified = false;
555
556    if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
557        modified |= servers.remove("lean-ctx").is_some();
558    }
559
560    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
561        modified |= servers.remove("lean-ctx").is_some();
562    }
563
564    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
565        let before = servers.len();
566        servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
567        modified |= servers.len() < before;
568    }
569
570    if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
571        modified |= mcp.remove("lean-ctx").is_some();
572    }
573
574    if let Some(amp) = parsed
575        .get_mut("amp.mcpServers")
576        .and_then(|s| s.as_object_mut())
577    {
578        modified |= amp.remove("lean-ctx").is_some();
579    }
580
581    if modified {
582        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
583    } else {
584        None
585    }
586}
587
588fn remove_lean_ctx_from_yaml(content: &str) -> String {
589    let mut out = String::with_capacity(content.len());
590    let mut skip_depth: Option<usize> = None;
591
592    for line in content.lines() {
593        if let Some(depth) = skip_depth {
594            let indent = line.len() - line.trim_start().len();
595            if indent > depth || line.trim().is_empty() {
596                continue;
597            }
598            skip_depth = None;
599        }
600
601        let trimmed = line.trim();
602        if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
603            let indent = line.len() - line.trim_start().len();
604            skip_depth = Some(indent);
605            continue;
606        }
607
608        out.push_str(line);
609        out.push('\n');
610    }
611
612    out
613}
614
615fn remove_lean_ctx_from_toml(content: &str) -> String {
616    let mut out = String::with_capacity(content.len());
617    let mut skip = false;
618
619    for line in content.lines() {
620        let trimmed = line.trim();
621
622        if trimmed.starts_with('[') && trimmed.ends_with(']') {
623            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
624            if section == "mcp_servers.lean-ctx"
625                || section == "mcp_servers.\"lean-ctx\""
626                || section.starts_with("mcp_servers.lean-ctx.")
627                || section.starts_with("mcp_servers.\"lean-ctx\".")
628            {
629                skip = true;
630                continue;
631            }
632            skip = false;
633        }
634
635        if skip {
636            continue;
637        }
638
639        if trimmed.contains("codex_hooks") && trimmed.contains("true") {
640            out.push_str(&line.replace("true", "false"));
641            out.push('\n');
642            continue;
643        }
644
645        out.push_str(line);
646        out.push('\n');
647    }
648
649    let cleaned: String = out
650        .lines()
651        .filter(|l| l.trim() != "[]")
652        .collect::<Vec<_>>()
653        .join("\n");
654    if cleaned.is_empty() {
655        cleaned
656    } else {
657        cleaned + "\n"
658    }
659}
660
661fn shorten(path: &Path, home: &Path) -> String {
662    match path.strip_prefix(home) {
663        Ok(rel) => format!("~/{}", rel.display()),
664        Err(_) => path.display().to_string(),
665    }
666}
667
668// moved to core/editor_registry/paths.rs
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn remove_toml_mcp_server_section() {
676        let input = "\
677[features]
678codex_hooks = true
679
680[mcp_servers.lean-ctx]
681command = \"/usr/local/bin/lean-ctx\"
682args = []
683
684[mcp_servers.other-tool]
685command = \"/usr/bin/other\"
686";
687        let result = remove_lean_ctx_from_toml(input);
688        assert!(
689            !result.contains("lean-ctx"),
690            "lean-ctx section should be removed"
691        );
692        assert!(
693            result.contains("[mcp_servers.other-tool]"),
694            "other sections should be preserved"
695        );
696        assert!(
697            result.contains("codex_hooks = false"),
698            "codex_hooks should be set to false"
699        );
700    }
701
702    #[test]
703    fn remove_toml_only_lean_ctx() {
704        let input = "\
705[mcp_servers.lean-ctx]
706command = \"lean-ctx\"
707";
708        let result = remove_lean_ctx_from_toml(input);
709        assert!(
710            result.trim().is_empty(),
711            "should produce empty output: {result}"
712        );
713    }
714
715    #[test]
716    fn remove_toml_no_lean_ctx() {
717        let input = "\
718[mcp_servers.other]
719command = \"other\"
720";
721        let result = remove_lean_ctx_from_toml(input);
722        assert!(
723            result.contains("[mcp_servers.other]"),
724            "other content should be preserved"
725        );
726    }
727}