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/mcp.json")),
180        (
181            "Antigravity",
182            home.join(".gemini/antigravity/mcp_config.json"),
183        ),
184        ("Codex CLI", home.join(".codex/config.toml")),
185        ("OpenCode", home.join(".config/opencode/opencode.json")),
186        ("Qwen Code", home.join(".qwen/mcp.json")),
187        ("Trae", home.join(".trae/mcp.json")),
188        ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
189        ("JetBrains IDEs", home.join(".jb-mcp.json")),
190        ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
191        ("Verdent", home.join(".verdent/mcp.json")),
192        ("Aider", home.join(".aider/mcp.json")),
193        ("Amp", home.join(".config/amp/settings.json")),
194        ("Crush", home.join(".config/crush/crush.json")),
195        ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
196        ("Cline", crate::core::editor_registry::cline_mcp_path()),
197        ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
198        ("Hermes Agent", home.join(".hermes/config.yaml")),
199    ];
200
201    let mut removed = false;
202
203    for (name, path) in &configs {
204        if !path.exists() {
205            continue;
206        }
207        let content = match fs::read_to_string(path) {
208            Ok(c) => c,
209            Err(_) => continue,
210        };
211        if !content.contains("lean-ctx") {
212            continue;
213        }
214
215        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
216        let is_yaml = ext == "yaml" || ext == "yml";
217        let is_toml = ext == "toml";
218
219        let cleaned = if is_yaml {
220            Some(remove_lean_ctx_from_yaml(&content))
221        } else if is_toml {
222            Some(remove_lean_ctx_from_toml(&content))
223        } else {
224            remove_lean_ctx_from_json(&content)
225        };
226
227        if let Some(cleaned) = cleaned {
228            if let Err(e) = fs::write(path, &cleaned) {
229                eprintln!("  ✗ Failed to update {} config: {}", name, e);
230            } else {
231                println!("  ✓ MCP config removed from {name}");
232                removed = true;
233            }
234        }
235    }
236
237    let zed_path = crate::core::editor_registry::zed_settings_path(home);
238    if zed_path.exists() {
239        if let Ok(content) = fs::read_to_string(&zed_path) {
240            if content.contains("lean-ctx") {
241                println!(
242                    "  ⚠ Zed: manually remove lean-ctx from {}",
243                    shorten(&zed_path, home)
244                );
245            }
246        }
247    }
248
249    let vscode_path = crate::core::editor_registry::vscode_mcp_path();
250    if vscode_path.exists() {
251        if let Ok(content) = fs::read_to_string(&vscode_path) {
252            if content.contains("lean-ctx") {
253                if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
254                    if let Err(e) = fs::write(&vscode_path, &cleaned) {
255                        eprintln!("  ✗ Failed to update VS Code config: {e}");
256                    } else {
257                        println!("  ✓ MCP config removed from VS Code / Copilot");
258                        removed = true;
259                    }
260                }
261            }
262        }
263    }
264
265    removed
266}
267
268fn remove_rules_files(home: &Path) -> bool {
269    let rules_files: Vec<(&str, PathBuf)> = vec![
270        (
271            "Claude Code",
272            crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
273        ),
274        // Legacy: shared CLAUDE.md (older releases).
275        (
276            "Claude Code (legacy)",
277            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
278        ),
279        // Legacy: hardcoded home path (very old releases).
280        ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
281        ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
282        ("Gemini CLI", home.join(".gemini/GEMINI.md")),
283        (
284            "Gemini CLI (legacy)",
285            home.join(".gemini/rules/lean-ctx.md"),
286        ),
287        ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
288        ("Codex CLI", home.join(".codex/instructions.md")),
289        ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
290        ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
291        ("Cline", home.join(".cline/rules/lean-ctx.md")),
292        ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
293        ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
294        ("Continue", home.join(".continue/rules/lean-ctx.md")),
295        ("Aider", home.join(".aider/rules/lean-ctx.md")),
296        ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
297        ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
298        ("Trae", home.join(".trae/rules/lean-ctx.md")),
299        (
300            "Amazon Q Developer",
301            home.join(".aws/amazonq/rules/lean-ctx.md"),
302        ),
303        ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
304        (
305            "Antigravity",
306            home.join(".gemini/antigravity/rules/lean-ctx.md"),
307        ),
308        ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
309        ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
310        ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
311        ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
312    ];
313
314    let mut removed = false;
315    for (name, path) in &rules_files {
316        if !path.exists() {
317            continue;
318        }
319        if let Ok(content) = fs::read_to_string(path) {
320            if content.contains("lean-ctx") {
321                if let Err(e) = fs::remove_file(path) {
322                    eprintln!("  ✗ Failed to remove {name} rules: {e}");
323                } else {
324                    println!("  ✓ Rules removed from {name}");
325                    removed = true;
326                }
327            }
328        }
329    }
330
331    let hermes_md = home.join(".hermes/HERMES.md");
332    if hermes_md.exists() {
333        if let Ok(content) = fs::read_to_string(&hermes_md) {
334            if content.contains("lean-ctx") {
335                let cleaned = remove_lean_ctx_block_from_md(&content);
336                if cleaned.trim().is_empty() {
337                    let _ = fs::remove_file(&hermes_md);
338                } else {
339                    let _ = fs::write(&hermes_md, &cleaned);
340                }
341                println!("  ✓ Rules removed from Hermes Agent");
342                removed = true;
343            }
344        }
345    }
346
347    if let Ok(cwd) = std::env::current_dir() {
348        let project_hermes = cwd.join(".hermes.md");
349        if project_hermes.exists() {
350            if let Ok(content) = fs::read_to_string(&project_hermes) {
351                if content.contains("lean-ctx") {
352                    let cleaned = remove_lean_ctx_block_from_md(&content);
353                    if cleaned.trim().is_empty() {
354                        let _ = fs::remove_file(&project_hermes);
355                    } else {
356                        let _ = fs::write(&project_hermes, &cleaned);
357                    }
358                    println!("  ✓ Rules removed from .hermes.md");
359                    removed = true;
360                }
361            }
362        }
363    }
364
365    if !removed {
366        println!("  · No rules files found");
367    }
368    removed
369}
370
371fn remove_lean_ctx_block_from_md(content: &str) -> String {
372    let mut out = String::with_capacity(content.len());
373    let mut in_block = false;
374
375    for line in content.lines() {
376        if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
377            in_block = true;
378            continue;
379        }
380        if in_block {
381            if line.starts_with('#') && !line.contains("lean-ctx") {
382                in_block = false;
383                out.push_str(line);
384                out.push('\n');
385            }
386            continue;
387        }
388        out.push_str(line);
389        out.push('\n');
390    }
391
392    while out.starts_with('\n') {
393        out.remove(0);
394    }
395    while out.ends_with("\n\n") {
396        out.pop();
397    }
398    out
399}
400
401fn remove_hook_files(home: &Path) -> bool {
402    let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
403    let hook_files: Vec<PathBuf> = vec![
404        claude_hooks_dir.join("lean-ctx-rewrite.sh"),
405        claude_hooks_dir.join("lean-ctx-redirect.sh"),
406        claude_hooks_dir.join("lean-ctx-rewrite-native"),
407        claude_hooks_dir.join("lean-ctx-redirect-native"),
408        home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
409        home.join(".cursor/hooks/lean-ctx-redirect.sh"),
410        home.join(".cursor/hooks/lean-ctx-rewrite-native"),
411        home.join(".cursor/hooks/lean-ctx-redirect-native"),
412        home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
413        home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
414        home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
415        home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
416    ];
417
418    let mut removed = false;
419    for path in &hook_files {
420        if path.exists() {
421            if let Err(e) = fs::remove_file(path) {
422                eprintln!("  ✗ Failed to remove hook {}: {e}", path.display());
423            } else {
424                removed = true;
425            }
426        }
427    }
428
429    if removed {
430        println!("  ✓ Hook scripts removed");
431    }
432
433    for (label, hj_path) in [
434        ("Cursor", home.join(".cursor/hooks.json")),
435        ("Codex", home.join(".codex/hooks.json")),
436    ] {
437        if hj_path.exists() {
438            if let Ok(content) = fs::read_to_string(&hj_path) {
439                if content.contains("lean-ctx") {
440                    if let Err(e) = fs::remove_file(&hj_path) {
441                        eprintln!("  ✗ Failed to remove {label} hooks.json: {e}");
442                    } else {
443                        println!("  ✓ {label} hooks.json removed");
444                        removed = true;
445                    }
446                }
447            }
448        }
449    }
450
451    removed
452}
453
454fn remove_data_dir(home: &Path) -> bool {
455    let data_dir = home.join(".lean-ctx");
456    if !data_dir.exists() {
457        println!("  · No data directory found");
458        return false;
459    }
460
461    match fs::remove_dir_all(&data_dir) {
462        Ok(_) => {
463            println!("  ✓ Data directory removed (~/.lean-ctx/)");
464            true
465        }
466        Err(e) => {
467            eprintln!("  ✗ Failed to remove ~/.lean-ctx/: {e}");
468            false
469        }
470    }
471}
472
473fn print_binary_removal_instructions() {
474    let binary_path = std::env::current_exe()
475        .map(|p| p.display().to_string())
476        .unwrap_or_else(|_| "lean-ctx".to_string());
477
478    println!("  To complete uninstallation, remove the binary:\n");
479
480    if binary_path.contains(".cargo") {
481        println!("    cargo uninstall lean-ctx\n");
482    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
483        println!("    brew uninstall lean-ctx\n");
484    } else {
485        println!("    rm {binary_path}\n");
486    }
487
488    println!("  Then restart your shell.\n");
489}
490
491fn remove_lean_ctx_block(content: &str) -> String {
492    if content.contains("# lean-ctx shell hook — end") {
493        return remove_lean_ctx_block_by_marker(content);
494    }
495    remove_lean_ctx_block_legacy(content)
496}
497
498fn remove_lean_ctx_block_by_marker(content: &str) -> String {
499    let mut result = String::new();
500    let mut in_block = false;
501
502    for line in content.lines() {
503        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
504            in_block = true;
505            continue;
506        }
507        if in_block {
508            if line.trim() == "# lean-ctx shell hook — end" {
509                in_block = false;
510            }
511            continue;
512        }
513        result.push_str(line);
514        result.push('\n');
515    }
516    result
517}
518
519fn remove_lean_ctx_block_legacy(content: &str) -> String {
520    let mut result = String::new();
521    let mut in_block = false;
522
523    for line in content.lines() {
524        if line.contains("lean-ctx shell hook") {
525            in_block = true;
526            continue;
527        }
528        if in_block {
529            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
530                if line.trim() == "fi" || line.trim() == "end" {
531                    in_block = false;
532                }
533                continue;
534            }
535            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
536                in_block = false;
537                result.push_str(line);
538                result.push('\n');
539            }
540            continue;
541        }
542        result.push_str(line);
543        result.push('\n');
544    }
545    result
546}
547
548fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
549    let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
550    let mut modified = false;
551
552    if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
553        modified |= servers.remove("lean-ctx").is_some();
554    }
555
556    if let Some(servers) = parsed.get_mut("servers").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_array_mut()) {
561        let before = servers.len();
562        servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
563        modified |= servers.len() < before;
564    }
565
566    if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
567        modified |= mcp.remove("lean-ctx").is_some();
568    }
569
570    if let Some(amp) = parsed
571        .get_mut("amp.mcpServers")
572        .and_then(|s| s.as_object_mut())
573    {
574        modified |= amp.remove("lean-ctx").is_some();
575    }
576
577    if modified {
578        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
579    } else {
580        None
581    }
582}
583
584fn remove_lean_ctx_from_yaml(content: &str) -> String {
585    let mut out = String::with_capacity(content.len());
586    let mut skip_depth: Option<usize> = None;
587
588    for line in content.lines() {
589        if let Some(depth) = skip_depth {
590            let indent = line.len() - line.trim_start().len();
591            if indent > depth || line.trim().is_empty() {
592                continue;
593            }
594            skip_depth = None;
595        }
596
597        let trimmed = line.trim();
598        if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
599            let indent = line.len() - line.trim_start().len();
600            skip_depth = Some(indent);
601            continue;
602        }
603
604        out.push_str(line);
605        out.push('\n');
606    }
607
608    out
609}
610
611fn remove_lean_ctx_from_toml(content: &str) -> String {
612    let mut out = String::with_capacity(content.len());
613    let mut skip = false;
614
615    for line in content.lines() {
616        let trimmed = line.trim();
617
618        if trimmed == "[mcp_servers.lean-ctx]" || trimmed == "[mcp_servers.\"lean-ctx\"]" {
619            skip = true;
620            continue;
621        }
622
623        if skip {
624            if trimmed.starts_with('[') {
625                skip = false;
626            } else {
627                continue;
628            }
629        }
630
631        if trimmed.contains("codex_hooks") && trimmed.contains("true") {
632            out.push_str(&line.replace("true", "false"));
633            out.push('\n');
634            continue;
635        }
636
637        out.push_str(line);
638        out.push('\n');
639    }
640
641    out
642}
643
644fn shorten(path: &Path, home: &Path) -> String {
645    match path.strip_prefix(home) {
646        Ok(rel) => format!("~/{}", rel.display()),
647        Err(_) => path.display().to_string(),
648    }
649}
650
651// moved to core/editor_registry/paths.rs
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn remove_toml_mcp_server_section() {
659        let input = "\
660[features]
661codex_hooks = true
662
663[mcp_servers.lean-ctx]
664command = \"/usr/local/bin/lean-ctx\"
665args = []
666
667[mcp_servers.other-tool]
668command = \"/usr/bin/other\"
669";
670        let result = remove_lean_ctx_from_toml(input);
671        assert!(
672            !result.contains("lean-ctx"),
673            "lean-ctx section should be removed"
674        );
675        assert!(
676            result.contains("[mcp_servers.other-tool]"),
677            "other sections should be preserved"
678        );
679        assert!(
680            result.contains("codex_hooks = false"),
681            "codex_hooks should be set to false"
682        );
683    }
684
685    #[test]
686    fn remove_toml_only_lean_ctx() {
687        let input = "\
688[mcp_servers.lean-ctx]
689command = \"lean-ctx\"
690";
691        let result = remove_lean_ctx_from_toml(input);
692        assert!(
693            result.trim().is_empty(),
694            "should produce empty output: {result}"
695        );
696    }
697
698    #[test]
699    fn remove_toml_no_lean_ctx() {
700        let input = "\
701[mcp_servers.other]
702command = \"other\"
703";
704        let result = remove_lean_ctx_from_toml(input);
705        assert!(
706            result.contains("[mcp_servers.other]"),
707            "other content should be preserved"
708        );
709    }
710}