Skip to main content

lean_ctx/uninstall/
mod.rs

1mod agents;
2mod parsers;
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use agents::{
8    remove_hook_files, remove_mcp_configs, remove_plan_mode_settings, remove_project_agent_files,
9    remove_rules_files, remove_shell_hook,
10};
11
12pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
13    if dry_run {
14        return;
15    }
16    if path.exists() {
17        let bak = bak_path_for(path);
18        let _ = fs::copy(path, &bak);
19    }
20}
21
22pub fn bak_path_for(path: &Path) -> PathBuf {
23    let filename = path.file_name().unwrap_or_default().to_string_lossy();
24    path.with_file_name(format!("{filename}.lean-ctx.bak"))
25}
26
27fn cleanup_bak(path: &Path) {
28    let bak = bak_path_for(path);
29    if bak.exists() {
30        let _ = fs::remove_file(&bak);
31    }
32}
33
34pub(super) fn shorten(path: &Path, home: &Path) -> String {
35    match path.strip_prefix(home) {
36        Ok(rel) => format!("~/{}", rel.display()),
37        Err(_) => path.display().to_string(),
38    }
39}
40
41pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
42    #[cfg(target_os = "macos")]
43    {
44        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
45    }
46    #[cfg(target_os = "linux")]
47    {
48        return home.join(".config/Code/User/github-copilot-instructions.md");
49    }
50    #[cfg(target_os = "windows")]
51    {
52        if let Ok(appdata) = std::env::var("APPDATA") {
53            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
54        }
55    }
56    #[allow(unreachable_code)]
57    home.join(".config/Code/User/github-copilot-instructions.md")
58}
59
60/// Write `content` to `path` only if not in dry-run mode.
61pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
62    if dry_run {
63        return Ok(());
64    }
65    fs::write(path, content)?;
66    // If we successfully wrote the cleaned file, the backup is no longer needed.
67    cleanup_bak(path);
68    Ok(())
69}
70
71/// Remove `path` only if not in dry-run mode.
72pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
73    if dry_run {
74        return Ok(());
75    }
76    fs::remove_file(path)?;
77    // If we successfully removed the file, also remove its backup.
78    cleanup_bak(path);
79    Ok(())
80}
81
82// ---------------------------------------------------------------------------
83// Main entry
84// ---------------------------------------------------------------------------
85
86pub fn run(dry_run: bool, keep_config: bool) {
87    let Some(home) = dirs::home_dir() else {
88        tracing::warn!("Could not determine home directory");
89        return;
90    };
91
92    let mode_label = if keep_config {
93        "uninstall --keep-config"
94    } else {
95        "uninstall"
96    };
97
98    if dry_run {
99        println!("\n  lean-ctx {mode_label} --dry-run\n  ──────────────────────────────────\n");
100        println!("  Preview mode — no files will be modified.\n");
101    } else {
102        println!("\n  lean-ctx {mode_label}\n  ──────────────────────────────────\n");
103    }
104
105    if keep_config {
106        println!("  Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
107    }
108
109    let mut removed_any = false;
110
111    removed_any |= remove_shell_hook(&home, dry_run);
112    if dry_run {
113        crate::proxy_setup::preview_proxy_cleanup(&home);
114    } else {
115        crate::proxy_setup::uninstall_proxy_env(&home, false);
116    }
117
118    if keep_config {
119        println!("  · Skipped: MCP configs (--keep-config)");
120        println!("  · Skipped: Rules files (--keep-config)");
121    } else {
122        removed_any |= remove_mcp_configs(&home, dry_run);
123        removed_any |= remove_rules_files(&home, dry_run);
124        if !dry_run {
125            try_claude_mcp_remove();
126        }
127    }
128
129    removed_any |= remove_hook_files(&home, dry_run);
130    removed_any |= remove_plan_mode_settings(&home, dry_run);
131    removed_any |= remove_skill_dirs(&home, dry_run);
132    removed_any |= remove_project_agent_files(dry_run);
133
134    if dry_run {
135        println!("  Would remove proxy autostart (LaunchAgent/systemd)");
136        println!("  Would remove daemon autostart (LaunchAgent/systemd)");
137    } else {
138        crate::proxy_autostart::uninstall(true);
139        crate::daemon_autostart::uninstall(true);
140    }
141
142    if !dry_run {
143        cleanup_bak_files(&home);
144    }
145
146    removed_any |= remove_data_dir(&home, dry_run);
147
148    println!();
149
150    if removed_any {
151        println!("  ──────────────────────────────────");
152        if dry_run {
153            println!(
154                "  The above changes WOULD be applied.\n  Run `lean-ctx {mode_label}` to execute.\n"
155            );
156        } else if keep_config {
157            println!(
158                "  Runtime data removed. MCP configs preserved for reinstall.\n  \
159                 Reinstall with: cargo install lean-ctx\n"
160            );
161        } else {
162            println!("  lean-ctx configuration removed.\n");
163        }
164    } else {
165        println!("  Nothing to remove — lean-ctx was not configured.\n");
166    }
167
168    if !dry_run {
169        print_binary_removal_instructions();
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Marked block removal (for AGENTS.md, SharedMarkdown)
175// ---------------------------------------------------------------------------
176
177pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
178    let s = content.find(start);
179    let e = content.find(end);
180    match (s, e) {
181        (Some(si), Some(ei)) if ei >= si => {
182            let after_end = ei + end.len();
183            let before = &content[..si];
184            let after = &content[after_end..];
185            let mut out = String::new();
186            out.push_str(before.trim_end_matches('\n'));
187            out.push('\n');
188            if !after.trim().is_empty() {
189                out.push('\n');
190                out.push_str(after.trim_start_matches('\n'));
191            }
192            out
193        }
194        _ => content.to_string(),
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Skill directories: lean-ctx SKILL.md + scripts
200// ---------------------------------------------------------------------------
201
202fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
203    let claude_state = crate::core::editor_registry::claude_state_dir(home);
204    let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
205        ("Claude Code", claude_state.join("skills/lean-ctx")),
206        ("Cursor", home.join(".cursor/skills/lean-ctx")),
207        (
208            "Codex CLI",
209            crate::core::home::resolve_codex_dir()
210                .unwrap_or_else(|| home.join(".codex"))
211                .join("skills/lean-ctx"),
212        ),
213        ("Copilot", home.join(".copilot/skills/lean-ctx")),
214        ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
215    ];
216
217    // If CLAUDE_CONFIG_DIR differs from ~/.claude, also clean default path
218    let default_claude_skill = home.join(".claude/skills/lean-ctx");
219    if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
220        skill_dirs.push(("Claude Code (default)", default_claude_skill));
221    }
222
223    let mut removed = false;
224    for (name, dir) in &skill_dirs {
225        if !dir.exists() {
226            continue;
227        }
228        if dry_run {
229            println!("  Would remove {name} skill directory");
230            removed = true;
231        } else if let Err(e) = fs::remove_dir_all(dir) {
232            tracing::warn!("Failed to remove {name} skill dir: {e}");
233        } else {
234            println!("  ✓ {name} skill directory removed");
235            removed = true;
236        }
237    }
238    removed
239}
240
241// ---------------------------------------------------------------------------
242// Data directory
243// ---------------------------------------------------------------------------
244
245fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
246    let mut removed = false;
247
248    let dirs_to_remove = [home.join(".lean-ctx"), home.join(".config/lean-ctx")];
249
250    for data_dir in &dirs_to_remove {
251        if !data_dir.exists() {
252            continue;
253        }
254        let short = shorten(data_dir, home);
255        if dry_run {
256            println!("  Would remove data directory ({short})");
257            removed = true;
258            continue;
259        }
260        match fs::remove_dir_all(data_dir) {
261            Ok(()) => {
262                println!("  ✓ Data directory removed ({short})");
263                removed = true;
264            }
265            Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
266        }
267    }
268
269    // Project-local .lean-ctx/ and .lean-ctx-id in CWD
270    if let Ok(cwd) = std::env::current_dir() {
271        let project_dir = cwd.join(".lean-ctx");
272        let project_id = cwd.join(".lean-ctx-id");
273        for p in [&project_dir, &project_id] {
274            if p.exists() {
275                if dry_run {
276                    println!("  Would remove {}", p.display());
277                    removed = true;
278                } else if p.is_dir() {
279                    if fs::remove_dir_all(p).is_ok() {
280                        println!("  ✓ Removed {}", p.display());
281                        removed = true;
282                    }
283                } else if fs::remove_file(p).is_ok() {
284                    println!("  ✓ Removed {}", p.display());
285                    removed = true;
286                }
287            }
288        }
289    }
290
291    if !removed {
292        println!("  · No data directory found");
293    }
294    removed
295}
296
297fn try_claude_mcp_remove() {
298    let result = std::process::Command::new("claude")
299        .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
300        .stdout(std::process::Stdio::null())
301        .stderr(std::process::Stdio::null())
302        .status();
303    match result {
304        Ok(s) if s.success() => println!("  ✓ Removed lean-ctx from Claude MCP registry"),
305        _ => {} // claude CLI not available or already removed
306    }
307}
308
309// ---------------------------------------------------------------------------
310// .bak cleanup: remove orphaned backup files after successful surgical removal
311// ---------------------------------------------------------------------------
312
313fn cleanup_bak_files(home: &Path) {
314    let dirs_to_scan: Vec<PathBuf> = vec![
315        home.join(".cursor"),
316        home.join(".claude"),
317        crate::core::editor_registry::claude_state_dir(home),
318        home.join(".gemini"),
319        home.join(".gemini/antigravity"),
320        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
321        home.join(".codeium"),
322        home.join(".codeium/windsurf"),
323        home.join(".config/opencode"),
324        home.join(".config/amp"),
325        home.join(".config/crush"),
326        home.join(".config/zed"),
327        home.join(".qwen"),
328        home.join(".trae"),
329        home.join(".aws/amazonq"),
330        home.join(".kiro"),
331        home.join(".kiro/settings"),
332        home.join(".ampcoder"),
333        home.join(".pi"),
334        home.join(".pi/agent"),
335        home.join(".hermes"),
336        home.join(".verdent"),
337        home.join(".cline"),
338        home.join(".roo"),
339        home.join(".continue"),
340        home.join(".jb-rules"),
341        home.join(".openclaw"),
342        home.join(".augment"),
343        home.join(".qoder"),
344        home.join(".qoderwork"),
345        home.join(".aider"),
346        home.join(".emacs.d"),
347        home.join(".copilot"),
348        home.join(".github"),
349        home.join(".github/hooks"),
350        home.join(".config/mcphub"),
351        home.join(".config/sublime-text"),
352    ];
353
354    let mut cleaned = 0;
355    for dir in &dirs_to_scan {
356        if !dir.exists() {
357            continue;
358        }
359        if let Ok(entries) = fs::read_dir(dir) {
360            for entry in entries.flatten() {
361                let name = entry.file_name();
362                let name_str = name.to_string_lossy();
363                if name_str.ends_with(".lean-ctx.tmp") {
364                    let _ = fs::remove_file(entry.path());
365                    cleaned += 1;
366                    continue;
367                }
368                if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
369                    let _ = fs::remove_file(entry.path());
370                    cleaned += 1;
371                    continue;
372                }
373                if name_str.ends_with(".lean-ctx.bak") {
374                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
375                    let original = entry.path().with_file_name(original_name);
376                    if original.exists() {
377                        match fs::read_to_string(&original) {
378                            Ok(c) if !c.contains("lean-ctx") => {
379                                let _ = fs::remove_file(entry.path());
380                                cleaned += 1;
381                            }
382                            _ => {}
383                        }
384                    } else {
385                        let _ = fs::remove_file(entry.path());
386                        cleaned += 1;
387                    }
388                    continue;
389                }
390                // Plain .bak files next to known config files (created by config_io)
391                if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
392                    let original_name = name_str.trim_end_matches(".bak");
393                    let original = entry.path().with_file_name(original_name);
394                    if original.exists() {
395                        if let Ok(bak_content) = fs::read_to_string(entry.path()) {
396                            if bak_content.contains("lean-ctx") {
397                                let _ = fs::remove_file(entry.path());
398                                cleaned += 1;
399                            }
400                        }
401                    }
402                }
403            }
404        }
405    }
406
407    // Also clean shell RC backups
408    let rc_baks = [
409        home.join(".zshrc.lean-ctx.bak"),
410        home.join(".zshenv.lean-ctx.bak"),
411        home.join(".bashrc.lean-ctx.bak"),
412        home.join(".bashenv.lean-ctx.bak"),
413    ];
414    for bak in &rc_baks {
415        if bak.exists() {
416            let original_name = bak
417                .file_name()
418                .unwrap_or_default()
419                .to_string_lossy()
420                .trim_end_matches(".lean-ctx.bak")
421                .to_string();
422            let original = bak.with_file_name(original_name);
423            if original.exists() {
424                if let Ok(c) = fs::read_to_string(&original) {
425                    if !c.contains("lean-ctx") {
426                        let _ = fs::remove_file(bak);
427                        cleaned += 1;
428                    }
429                }
430            } else {
431                let _ = fs::remove_file(bak);
432                cleaned += 1;
433            }
434        }
435    }
436
437    if cleaned > 0 {
438        println!("  ✓ Cleaned up {cleaned} backup file(s)");
439    }
440}
441
442// ---------------------------------------------------------------------------
443// Binary removal instructions
444// ---------------------------------------------------------------------------
445
446fn print_binary_removal_instructions() {
447    let binary_path = std::env::current_exe()
448        .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
449
450    println!("  To complete uninstallation, remove the binary:\n");
451
452    if binary_path.contains(".cargo") {
453        println!("    cargo uninstall lean-ctx\n");
454    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
455        println!("    brew uninstall lean-ctx\n");
456    } else {
457        println!("    rm {binary_path}\n");
458    }
459
460    println!("  Then restart your shell.\n");
461}