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    removed_any |= remove_mcp_configs(&home);
19    removed_any |= remove_rules_files(&home);
20    removed_any |= remove_hook_files(&home);
21    removed_any |= remove_project_agent_files();
22    removed_any |= remove_data_dir(&home);
23
24    println!();
25
26    if removed_any {
27        println!("  ──────────────────────────────────");
28        println!("  lean-ctx configuration removed.\n");
29    } else {
30        println!("  Nothing to remove — lean-ctx was not configured.\n");
31    }
32
33    print_binary_removal_instructions();
34}
35
36fn remove_project_agent_files() -> bool {
37    let cwd = std::env::current_dir().unwrap_or_default();
38    let agents = cwd.join("AGENTS.md");
39    let lean_ctx_md = cwd.join("LEAN-CTX.md");
40
41    const START: &str = "<!-- lean-ctx -->";
42    const END: &str = "<!-- /lean-ctx -->";
43    const OWNED: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
44
45    let mut removed = false;
46
47    if agents.exists() {
48        if let Ok(content) = fs::read_to_string(&agents) {
49            if content.contains(START) {
50                let cleaned = remove_marked_block(&content, START, END);
51                if cleaned != content {
52                    if let Err(e) = fs::write(&agents, cleaned) {
53                        eprintln!("  ✗ Failed to update project AGENTS.md: {e}");
54                    } else {
55                        println!("  ✓ Project: removed lean-ctx block from AGENTS.md");
56                        removed = true;
57                    }
58                }
59            }
60        }
61    }
62
63    if lean_ctx_md.exists() {
64        if let Ok(content) = fs::read_to_string(&lean_ctx_md) {
65            if content.contains(OWNED) {
66                if let Err(e) = fs::remove_file(&lean_ctx_md) {
67                    eprintln!("  ✗ Failed to remove project LEAN-CTX.md: {e}");
68                } else {
69                    println!("  ✓ Project: removed LEAN-CTX.md");
70                    removed = true;
71                }
72            }
73        }
74    }
75
76    removed
77}
78
79fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
80    let s = content.find(start);
81    let e = content.find(end);
82    match (s, e) {
83        (Some(si), Some(ei)) if ei >= si => {
84            let after_end = ei + end.len();
85            let before = &content[..si];
86            let after = &content[after_end..];
87            let mut out = String::new();
88            out.push_str(before.trim_end_matches('\n'));
89            out.push('\n');
90            if !after.trim().is_empty() {
91                out.push('\n');
92                out.push_str(after.trim_start_matches('\n'));
93            }
94            out
95        }
96        _ => content.to_string(),
97    }
98}
99
100fn remove_shell_hook(home: &Path) -> bool {
101    let shell = std::env::var("SHELL").unwrap_or_default();
102    let mut removed = false;
103
104    let rc_files: Vec<PathBuf> = vec![
105        home.join(".zshrc"),
106        home.join(".bashrc"),
107        home.join(".config/fish/config.fish"),
108        #[cfg(windows)]
109        home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
110    ];
111
112    for rc in &rc_files {
113        if !rc.exists() {
114            continue;
115        }
116        let content = match fs::read_to_string(rc) {
117            Ok(c) => c,
118            Err(_) => continue,
119        };
120        if !content.contains("lean-ctx") {
121            continue;
122        }
123
124        let cleaned = remove_lean_ctx_block(&content);
125        if cleaned.trim() != content.trim() {
126            let bak = rc.with_extension("lean-ctx.bak");
127            let _ = fs::copy(rc, &bak);
128            if let Err(e) = fs::write(rc, &cleaned) {
129                eprintln!("  ✗ Failed to update {}: {}", rc.display(), e);
130            } else {
131                let short = shorten(rc, home);
132                println!("  ✓ Shell hook removed from {short}");
133                println!("    Backup: {}", shorten(&bak, home));
134                removed = true;
135            }
136        }
137    }
138
139    if !removed && !shell.is_empty() {
140        println!("  · No shell hook found");
141    }
142
143    removed
144}
145
146fn remove_mcp_configs(home: &Path) -> bool {
147    let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR")
148        .ok()
149        .map(|d| PathBuf::from(d).join(".claude.json"))
150        .unwrap_or_else(|| PathBuf::from("/nonexistent"));
151    let configs: Vec<(&str, PathBuf)> = vec![
152        ("Cursor", home.join(".cursor/mcp.json")),
153        ("Claude Code (config dir)", claude_cfg_dir_json),
154        ("Claude Code (home)", home.join(".claude.json")),
155        ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
156        ("Gemini CLI", home.join(".gemini/settings/mcp.json")),
157        (
158            "Antigravity",
159            home.join(".gemini/antigravity/mcp_config.json"),
160        ),
161        ("Codex CLI", home.join(".codex/config.toml")),
162        ("OpenCode", home.join(".config/opencode/opencode.json")),
163        ("Qwen Code", home.join(".qwen/mcp.json")),
164        ("Trae", home.join(".trae/mcp.json")),
165        ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
166        ("JetBrains IDEs", home.join(".jb-mcp.json")),
167        ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
168        ("Verdent", home.join(".verdent/mcp.json")),
169        ("OpenCode", home.join(".opencode/mcp.json")),
170        ("Aider", home.join(".aider/mcp.json")),
171        ("Amp", home.join(".amp/mcp.json")),
172        ("Crush", home.join(".config/crush/crush.json")),
173    ];
174
175    let mut removed = false;
176
177    for (name, path) in &configs {
178        if !path.exists() {
179            continue;
180        }
181        let content = match fs::read_to_string(path) {
182            Ok(c) => c,
183            Err(_) => continue,
184        };
185        if !content.contains("lean-ctx") {
186            continue;
187        }
188
189        if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
190            if let Err(e) = fs::write(path, &cleaned) {
191                eprintln!("  ✗ Failed to update {} config: {}", name, e);
192            } else {
193                println!("  ✓ MCP config removed from {name}");
194                removed = true;
195            }
196        }
197    }
198
199    let zed_path = crate::core::editor_registry::zed_settings_path(home);
200    if zed_path.exists() {
201        if let Ok(content) = fs::read_to_string(&zed_path) {
202            if content.contains("lean-ctx") {
203                println!(
204                    "  ⚠ Zed: manually remove lean-ctx from {}",
205                    shorten(&zed_path, home)
206                );
207            }
208        }
209    }
210
211    let vscode_path = crate::core::editor_registry::vscode_mcp_path();
212    if vscode_path.exists() {
213        if let Ok(content) = fs::read_to_string(&vscode_path) {
214            if content.contains("lean-ctx") {
215                if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
216                    if let Err(e) = fs::write(&vscode_path, &cleaned) {
217                        eprintln!("  ✗ Failed to update VS Code config: {e}");
218                    } else {
219                        println!("  ✓ MCP config removed from VS Code / Copilot");
220                        removed = true;
221                    }
222                }
223            }
224        }
225    }
226
227    removed
228}
229
230fn remove_rules_files(home: &Path) -> bool {
231    let rules_files: Vec<(&str, PathBuf)> = vec![
232        (
233            "Claude Code",
234            crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
235        ),
236        // Legacy: shared CLAUDE.md (older releases).
237        (
238            "Claude Code (legacy)",
239            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
240        ),
241        // Legacy: hardcoded home path (very old releases).
242        ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
243        ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
244        ("Gemini CLI", home.join(".gemini/GEMINI.md")),
245        (
246            "Gemini CLI (legacy)",
247            home.join(".gemini/rules/lean-ctx.md"),
248        ),
249        ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
250        ("Codex CLI", home.join(".codex/instructions.md")),
251        ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
252        ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
253        ("Cline", home.join(".cline/rules/lean-ctx.md")),
254        ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
255        ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
256        ("Continue", home.join(".continue/rules/lean-ctx.md")),
257        ("Aider", home.join(".aider/rules/lean-ctx.md")),
258        ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
259        ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
260        ("Trae", home.join(".trae/rules/lean-ctx.md")),
261        (
262            "Amazon Q Developer",
263            home.join(".aws/amazonq/rules/lean-ctx.md"),
264        ),
265        ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
266        (
267            "Antigravity",
268            home.join(".gemini/antigravity/rules/lean-ctx.md"),
269        ),
270        ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
271        ("AWS Kiro", home.join(".kiro/rules/lean-ctx.md")),
272        ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
273        ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
274    ];
275
276    let mut removed = false;
277    for (name, path) in &rules_files {
278        if !path.exists() {
279            continue;
280        }
281        if let Ok(content) = fs::read_to_string(path) {
282            if content.contains("lean-ctx") {
283                if let Err(e) = fs::remove_file(path) {
284                    eprintln!("  ✗ Failed to remove {name} rules: {e}");
285                } else {
286                    println!("  ✓ Rules removed from {name}");
287                    removed = true;
288                }
289            }
290        }
291    }
292
293    if !removed {
294        println!("  · No rules files found");
295    }
296    removed
297}
298
299fn remove_hook_files(home: &Path) -> bool {
300    let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
301    let hook_files: Vec<PathBuf> = vec![
302        claude_hooks_dir.join("lean-ctx-rewrite.sh"),
303        claude_hooks_dir.join("lean-ctx-redirect.sh"),
304        claude_hooks_dir.join("lean-ctx-rewrite-native"),
305        claude_hooks_dir.join("lean-ctx-redirect-native"),
306        home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
307        home.join(".cursor/hooks/lean-ctx-redirect.sh"),
308        home.join(".cursor/hooks/lean-ctx-rewrite-native"),
309        home.join(".cursor/hooks/lean-ctx-redirect-native"),
310        home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
311        home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
312        home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
313        home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
314    ];
315
316    let mut removed = false;
317    for path in &hook_files {
318        if path.exists() {
319            if let Err(e) = fs::remove_file(path) {
320                eprintln!("  ✗ Failed to remove hook {}: {e}", path.display());
321            } else {
322                removed = true;
323            }
324        }
325    }
326
327    if removed {
328        println!("  ✓ Hook scripts removed");
329    }
330
331    let hooks_json = home.join(".cursor/hooks.json");
332    if hooks_json.exists() {
333        if let Ok(content) = fs::read_to_string(&hooks_json) {
334            if content.contains("lean-ctx") {
335                if let Err(e) = fs::remove_file(&hooks_json) {
336                    eprintln!("  ✗ Failed to remove Cursor hooks.json: {e}");
337                } else {
338                    println!("  ✓ Cursor hooks.json removed");
339                    removed = true;
340                }
341            }
342        }
343    }
344
345    removed
346}
347
348fn remove_data_dir(home: &Path) -> bool {
349    let data_dir = home.join(".lean-ctx");
350    if !data_dir.exists() {
351        println!("  · No data directory found");
352        return false;
353    }
354
355    match fs::remove_dir_all(&data_dir) {
356        Ok(_) => {
357            println!("  ✓ Data directory removed (~/.lean-ctx/)");
358            true
359        }
360        Err(e) => {
361            eprintln!("  ✗ Failed to remove ~/.lean-ctx/: {e}");
362            false
363        }
364    }
365}
366
367fn print_binary_removal_instructions() {
368    let binary_path = std::env::current_exe()
369        .map(|p| p.display().to_string())
370        .unwrap_or_else(|_| "lean-ctx".to_string());
371
372    println!("  To complete uninstallation, remove the binary:\n");
373
374    if binary_path.contains(".cargo") {
375        println!("    cargo uninstall lean-ctx\n");
376    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
377        println!("    brew uninstall lean-ctx\n");
378    } else {
379        println!("    rm {binary_path}\n");
380    }
381
382    println!("  Then restart your shell.\n");
383}
384
385fn remove_lean_ctx_block(content: &str) -> String {
386    if content.contains("# lean-ctx shell hook — end") {
387        return remove_lean_ctx_block_by_marker(content);
388    }
389    remove_lean_ctx_block_legacy(content)
390}
391
392fn remove_lean_ctx_block_by_marker(content: &str) -> String {
393    let mut result = String::new();
394    let mut in_block = false;
395
396    for line in content.lines() {
397        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
398            in_block = true;
399            continue;
400        }
401        if in_block {
402            if line.trim() == "# lean-ctx shell hook — end" {
403                in_block = false;
404            }
405            continue;
406        }
407        result.push_str(line);
408        result.push('\n');
409    }
410    result
411}
412
413fn remove_lean_ctx_block_legacy(content: &str) -> String {
414    let mut result = String::new();
415    let mut in_block = false;
416
417    for line in content.lines() {
418        if line.contains("lean-ctx shell hook") {
419            in_block = true;
420            continue;
421        }
422        if in_block {
423            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
424                if line.trim() == "fi" || line.trim() == "end" {
425                    in_block = false;
426                }
427                continue;
428            }
429            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
430                in_block = false;
431                result.push_str(line);
432                result.push('\n');
433            }
434            continue;
435        }
436        result.push_str(line);
437        result.push('\n');
438    }
439    result
440}
441
442fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
443    let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
444    let mut modified = false;
445
446    if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
447        modified |= servers.remove("lean-ctx").is_some();
448    }
449
450    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
451        modified |= servers.remove("lean-ctx").is_some();
452    }
453
454    if modified {
455        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
456    } else {
457        None
458    }
459}
460
461fn shorten(path: &Path, home: &Path) -> String {
462    match path.strip_prefix(home) {
463        Ok(rel) => format!("~/{}", rel.display()),
464        Err(_) => path.display().to_string(),
465    }
466}
467
468// moved to core/editor_registry/paths.rs