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