Skip to main content

lean_ctx/uninstall/
mod.rs

1mod agents;
2mod binary;
3mod parsers;
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use agents::{
9    remove_hook_files, remove_mcp_configs, remove_plan_mode_settings, remove_project_agent_files,
10    remove_rules_files, remove_shell_hook,
11};
12
13pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
14    if dry_run {
15        return;
16    }
17    if path.exists() {
18        let bak = bak_path_for(path);
19        let _ = fs::copy(path, &bak);
20    }
21}
22
23pub fn bak_path_for(path: &Path) -> PathBuf {
24    let filename = path.file_name().unwrap_or_default().to_string_lossy();
25    path.with_file_name(format!("{filename}.lean-ctx.bak"))
26}
27
28fn cleanup_bak(path: &Path) {
29    let bak = bak_path_for(path);
30    if bak.exists() {
31        let _ = fs::remove_file(&bak);
32    }
33}
34
35pub(super) fn shorten(path: &Path, home: &Path) -> String {
36    match path.strip_prefix(home) {
37        Ok(rel) => format!("~/{}", rel.display()),
38        Err(_) => path.display().to_string(),
39    }
40}
41
42pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
43    #[cfg(target_os = "macos")]
44    {
45        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
46    }
47    #[cfg(target_os = "linux")]
48    {
49        let user_dirs = [
50            home.join(".config/Code/User"),
51            home.join(".config/Code - Insiders/User"),
52            home.join(".vscode-server/data/User"),
53        ];
54        let user_dir = user_dirs
55            .iter()
56            .find(|p| p.exists())
57            .cloned()
58            .unwrap_or_else(|| user_dirs[0].clone());
59        return user_dir.join("github-copilot-instructions.md");
60    }
61    #[cfg(target_os = "windows")]
62    {
63        if let Ok(appdata) = std::env::var("APPDATA") {
64            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
65        }
66    }
67    #[allow(unreachable_code)]
68    home.join(".config/Code/User/github-copilot-instructions.md")
69}
70
71/// Write `content` to `path` only if not in dry-run mode.
72pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
73    if dry_run {
74        return Ok(());
75    }
76    fs::write(path, content)?;
77    // If we successfully wrote the cleaned file, the backup is no longer needed.
78    cleanup_bak(path);
79    Ok(())
80}
81
82/// Remove `path` only if not in dry-run mode.
83pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
84    if dry_run {
85        return Ok(());
86    }
87    fs::remove_file(path)?;
88    // If we successfully removed the file, also remove its backup.
89    cleanup_bak(path);
90    Ok(())
91}
92
93// ---------------------------------------------------------------------------
94// Main entry
95// ---------------------------------------------------------------------------
96
97pub fn run(dry_run: bool, keep_config: bool, keep_binary: bool) {
98    let Some(home) = dirs::home_dir() else {
99        tracing::warn!("Could not determine home directory");
100        return;
101    };
102
103    let mode_label = if keep_config {
104        "uninstall --keep-config"
105    } else {
106        "uninstall"
107    };
108
109    if dry_run {
110        println!("\n  lean-ctx {mode_label} --dry-run\n  ──────────────────────────────────\n");
111        println!("  Preview mode — no files will be modified.\n");
112    } else {
113        println!("\n  lean-ctx {mode_label}\n  ──────────────────────────────────\n");
114    }
115
116    if keep_config {
117        println!("  Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
118    }
119
120    // Stop everything first so nothing respawns or holds the files/data we remove next.
121    binary::stop_processes(dry_run);
122
123    let mut removed_any = false;
124
125    removed_any |= remove_shell_hook(&home, dry_run);
126    if dry_run {
127        crate::proxy_setup::preview_proxy_cleanup(&home);
128    } else {
129        crate::proxy_setup::uninstall_proxy_env(&home, false);
130    }
131
132    if keep_config {
133        println!("  · Skipped: MCP configs (--keep-config)");
134        println!("  · Skipped: Rules files (--keep-config)");
135    } else {
136        removed_any |= remove_mcp_configs(&home, dry_run);
137        removed_any |= remove_rules_files(&home, dry_run);
138        if !dry_run {
139            try_claude_mcp_remove();
140        }
141    }
142
143    removed_any |= remove_hook_files(&home, dry_run);
144    removed_any |= remove_plan_mode_settings(&home, dry_run);
145    removed_any |= remove_skill_dirs(&home, dry_run);
146    removed_any |= remove_project_agent_files(dry_run);
147
148    if dry_run {
149        println!("  Would remove proxy autostart (LaunchAgent/systemd)");
150        println!("  Would remove daemon autostart (LaunchAgent/systemd)");
151    } else {
152        crate::proxy_autostart::uninstall(true);
153        crate::daemon_autostart::uninstall(true);
154    }
155
156    if !dry_run {
157        cleanup_bak_files(&home);
158    }
159
160    removed_any |= remove_data_dir(&home, dry_run);
161
162    // Last filesystem step: every file-removal pass above has run, so
163    // installer-created directories that are empty now stay empty.
164    if !dry_run {
165        sweep_empty_installer_dirs(&home);
166    }
167
168    // Remove the binary itself last: once it's gone we can't re-exec, and on Unix the
169    // running process keeps working until exit.
170    removed_any |= binary::remove_binaries(&home, dry_run, keep_binary);
171
172    println!();
173
174    if removed_any {
175        println!("  ──────────────────────────────────");
176        if dry_run {
177            println!(
178                "  The above changes WOULD be applied.\n  Run `lean-ctx {mode_label}` to execute.\n"
179            );
180        } else if keep_config {
181            println!(
182                "  Runtime data removed. MCP configs preserved for reinstall.\n  \
183                 Reinstall with: cargo install lean-ctx\n"
184            );
185        } else {
186            println!(
187                "  lean-ctx fully removed. Restart your shell to drop stale aliases.\n  \
188                 Verify with: command -v lean-ctx   # should print nothing\n"
189            );
190        }
191    } else {
192        println!("  Nothing to remove — lean-ctx was not configured.\n");
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Marked block removal (for AGENTS.md, SharedMarkdown)
198// ---------------------------------------------------------------------------
199
200pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
201    let s = content.find(start);
202    let e = content.find(end);
203    match (s, e) {
204        (Some(si), Some(ei)) if ei >= si => {
205            let after_end = ei + end.len();
206            let before = &content[..si];
207            let after = &content[after_end..];
208            let mut out = String::new();
209            out.push_str(before.trim_end_matches('\n'));
210            out.push('\n');
211            if !after.trim().is_empty() {
212                out.push('\n');
213                out.push_str(after.trim_start_matches('\n'));
214            }
215            out
216        }
217        _ => content.to_string(),
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Skill directories: lean-ctx SKILL.md + scripts
223// ---------------------------------------------------------------------------
224
225fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
226    let claude_state = crate::core::editor_registry::claude_state_dir(home);
227    let codebuddy_state = crate::core::editor_registry::codebuddy_state_dir(home);
228    let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
229        ("Claude Code", claude_state.join("skills/lean-ctx")),
230        ("CodeBuddy", codebuddy_state.join("skills/lean-ctx")),
231        ("Cursor", home.join(".cursor/skills/lean-ctx")),
232        (
233            "Codex CLI",
234            crate::core::home::resolve_codex_dir()
235                .unwrap_or_else(|| home.join(".codex"))
236                .join("skills/lean-ctx"),
237        ),
238        ("Copilot", home.join(".copilot/skills/lean-ctx")),
239        ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
240    ];
241
242    // If CLAUDE_CONFIG_DIR differs from ~/.claude, also clean default path
243    let default_claude_skill = home.join(".claude/skills/lean-ctx");
244    if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
245        skill_dirs.push(("Claude Code (default)", default_claude_skill));
246    }
247
248    // If CODEBUDDY_CONFIG_DIR differs from ~/.codebuddy, also clean default path
249    let default_codebuddy_skill = home.join(".codebuddy/skills/lean-ctx");
250    if !skill_dirs
251        .iter()
252        .any(|(_, p)| *p == default_codebuddy_skill)
253    {
254        skill_dirs.push(("CodeBuddy (default)", default_codebuddy_skill));
255    }
256
257    let mut removed = false;
258    for (name, dir) in &skill_dirs {
259        if !dir.exists() {
260            continue;
261        }
262        if dry_run {
263            println!("  Would remove {name} skill directory");
264            removed = true;
265        } else if let Err(e) = fs::remove_dir_all(dir) {
266            tracing::warn!("Failed to remove {name} skill dir: {e}");
267        } else {
268            println!("  ✓ {name} skill directory removed");
269            removed = true;
270        }
271    }
272    removed
273}
274
275// ---------------------------------------------------------------------------
276// Data directory
277// ---------------------------------------------------------------------------
278
279fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
280    let mut removed = false;
281
282    let mut dirs_to_remove = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
283
284    // Platform data dirs hold daemon logs + PID files (macOS:
285    // ~/Library/Application Support/lean-ctx, Windows: %LOCALAPPDATA%\lean-ctx,
286    // Linux: ~/.local/share/lean-ctx) — see daemon.rs `data_dir()`.
287    for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
288        .into_iter()
289        .flatten()
290    {
291        let p = platform_dir.join("lean-ctx");
292        if !dirs_to_remove.contains(&p) {
293            dirs_to_remove.push(p);
294        }
295    }
296
297    for data_dir in &dirs_to_remove {
298        if !data_dir.exists() {
299            continue;
300        }
301        let short = shorten(data_dir, home);
302        if dry_run {
303            println!("  Would remove data directory ({short})");
304            removed = true;
305            continue;
306        }
307        match fs::remove_dir_all(data_dir) {
308            Ok(()) => {
309                println!("  ✓ Data directory removed ({short})");
310                removed = true;
311            }
312            Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
313        }
314    }
315
316    // Project-local .lean-ctx/ and .lean-ctx-id in CWD
317    if let Ok(cwd) = std::env::current_dir() {
318        let project_dir = cwd.join(".lean-ctx");
319        let project_id = cwd.join(".lean-ctx-id");
320        for p in [&project_dir, &project_id] {
321            if p.exists() {
322                if dry_run {
323                    println!("  Would remove {}", p.display());
324                    removed = true;
325                } else if p.is_dir() {
326                    if fs::remove_dir_all(p).is_ok() {
327                        println!("  ✓ Removed {}", p.display());
328                        removed = true;
329                    }
330                } else if fs::remove_file(p).is_ok() {
331                    println!("  ✓ Removed {}", p.display());
332                    removed = true;
333                }
334            }
335        }
336    }
337
338    if !removed {
339        println!("  · No data directory found");
340    }
341    removed
342}
343
344fn try_claude_mcp_remove() {
345    let result = std::process::Command::new("claude")
346        .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
347        .stdout(std::process::Stdio::null())
348        .stderr(std::process::Stdio::null())
349        .status();
350    match result {
351        Ok(s) if s.success() => println!("  ✓ Removed lean-ctx from Claude MCP registry"),
352        _ => {} // claude CLI not available or already removed
353    }
354}
355
356// ---------------------------------------------------------------------------
357// .bak cleanup: remove orphaned backup files after successful surgical removal
358// ---------------------------------------------------------------------------
359
360/// Every directory the installer may have written backups or files into:
361/// agent config roots, their well-known subdirectories, and the project-local
362/// config dirs in CWD.
363fn scan_dirs(home: &Path) -> Vec<PathBuf> {
364    let base_dirs: Vec<PathBuf> = vec![
365        home.join(".cursor"),
366        home.join(".claude"),
367        crate::core::editor_registry::claude_state_dir(home),
368        home.join(".codebuddy"),
369        crate::core::editor_registry::codebuddy_state_dir(home),
370        crate::core::editor_registry::zed_config_dir(home),
371        home.join(".gemini"),
372        home.join(".gemini/antigravity"),
373        home.join(".gemini/antigravity-cli"),
374        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
375        home.join(".codeium"),
376        home.join(".codeium/windsurf"),
377        home.join(".config/opencode"),
378        home.join(".config/amp"),
379        home.join(".config/crush"),
380        home.join(".config/zed"),
381        home.join(".qwen"),
382        home.join(".trae"),
383        home.join(".aws/amazonq"),
384        home.join(".kiro"),
385        home.join(".kiro/settings"),
386        home.join(".ampcoder"),
387        home.join(".pi"),
388        home.join(".pi/agent"),
389        home.join(".hermes"),
390        home.join(".verdent"),
391        home.join(".cline"),
392        home.join(".roo"),
393        home.join(".continue"),
394        home.join(".jb-rules"),
395        home.join(".openclaw"),
396        home.join(".augment"),
397        home.join(".qoder"),
398        home.join(".qoderwork"),
399        home.join(".aider"),
400        home.join(".emacs.d"),
401        home.join(".copilot"),
402        home.join(".github"),
403        home.join(".config/mcphub"),
404        home.join(".config/sublime-text"),
405    ];
406
407    // Installers write into well-known subdirectories (hook scripts, rules
408    // files, steering docs, …). read_dir below is non-recursive, so backups in
409    // those subdirectories were previously missed (GL #558).
410    const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
411    let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
412    for dir in base_dirs {
413        for sub in KNOWN_SUBDIRS {
414            let p = dir.join(sub);
415            if p.is_dir() {
416                dirs_to_scan.push(p);
417            }
418        }
419        dirs_to_scan.push(dir);
420    }
421
422    // Project-local config dirs in CWD get the same backup treatment as HOME:
423    // setup writes (and uninstall removes) rules/hooks there too.
424    if let Ok(cwd) = std::env::current_dir() {
425        for rel in [
426            ".cursor/rules",
427            ".claude",
428            ".claude/rules",
429            ".claude/hooks",
430            ".codebuddy",
431            ".codebuddy/rules",
432            ".codebuddy/hooks",
433            ".kiro/steering",
434            ".github",
435            ".github/hooks",
436            ".vscode",
437        ] {
438            let p = cwd.join(rel);
439            if p.is_dir() {
440                dirs_to_scan.push(p);
441            }
442        }
443    }
444
445    dirs_to_scan
446}
447
448fn cleanup_bak_files(home: &Path) {
449    let dirs_to_scan = scan_dirs(home);
450    let mut cleaned = 0;
451    for dir in &dirs_to_scan {
452        if !dir.exists() {
453            continue;
454        }
455        if let Ok(entries) = fs::read_dir(dir) {
456            for entry in entries.flatten() {
457                let name = entry.file_name();
458                let name_str = name.to_string_lossy();
459                if name_str.ends_with(".lean-ctx.tmp") {
460                    let _ = fs::remove_file(entry.path());
461                    cleaned += 1;
462                    continue;
463                }
464                // Backups of our own hook scripts / rules files
465                // (lean-ctx-rewrite.sh.bak, lean-ctx.mdc.bak, …): the originals
466                // are lean-ctx-owned and already removed at this point, so the
467                // backups are pure leftovers.
468                if name_str.ends_with(".bak")
469                    && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
470                {
471                    let _ = fs::remove_file(entry.path());
472                    cleaned += 1;
473                    continue;
474                }
475                if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
476                    let _ = fs::remove_file(entry.path());
477                    cleaned += 1;
478                    continue;
479                }
480                if name_str.ends_with(".lean-ctx.bak") {
481                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
482                    let original = entry.path().with_file_name(original_name);
483                    if original.exists() {
484                        match fs::read_to_string(&original) {
485                            Ok(c) if !c.contains("lean-ctx") => {
486                                let _ = fs::remove_file(entry.path());
487                                cleaned += 1;
488                            }
489                            _ => {}
490                        }
491                    } else {
492                        let _ = fs::remove_file(entry.path());
493                        cleaned += 1;
494                    }
495                    continue;
496                }
497                // Plain .bak files next to known config files (created by
498                // config_io). Removed whether or not the original still exists:
499                // when uninstall deletes a config file that only contained
500                // lean-ctx content, its backup would otherwise be orphaned.
501                if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
502                    if let Ok(bak_content) = fs::read_to_string(entry.path()) {
503                        if bak_content.contains("lean-ctx") {
504                            let _ = fs::remove_file(entry.path());
505                            cleaned += 1;
506                        }
507                    }
508                }
509            }
510        }
511    }
512
513    // Also clean shell RC backups
514    let rc_baks = [
515        home.join(".zshrc.lean-ctx.bak"),
516        home.join(".zshenv.lean-ctx.bak"),
517        home.join(".bashrc.lean-ctx.bak"),
518        home.join(".bashenv.lean-ctx.bak"),
519    ];
520    for bak in &rc_baks {
521        if bak.exists() {
522            let original_name = bak
523                .file_name()
524                .unwrap_or_default()
525                .to_string_lossy()
526                .trim_end_matches(".lean-ctx.bak")
527                .to_string();
528            let original = bak.with_file_name(original_name);
529            if original.exists() {
530                if let Ok(c) = fs::read_to_string(&original) {
531                    if !c.contains("lean-ctx") {
532                        let _ = fs::remove_file(bak);
533                        cleaned += 1;
534                    }
535                }
536            } else {
537                let _ = fs::remove_file(bak);
538                cleaned += 1;
539            }
540        }
541    }
542
543    if cleaned > 0 {
544        println!("  ✓ Cleaned up {cleaned} backup file(s)");
545    }
546}
547
548/// Sweep now-empty installer-created directories (hooks/, rules/, skills/,
549/// steering/). `fs::remove_dir` refuses to delete non-empty directories, so
550/// anything still holding user content survives untouched. Runs as the last
551/// filesystem step of `run()` — after every file-removal pass has finished.
552fn sweep_empty_installer_dirs(home: &Path) {
553    let mut swept = 0;
554    for dir in scan_dirs(home) {
555        let is_installer_dir = dir
556            .file_name()
557            .and_then(|n| n.to_str())
558            .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
559        if is_installer_dir && fs::remove_dir(&dir).is_ok() {
560            swept += 1;
561        }
562    }
563    if swept > 0 {
564        println!("  ✓ Removed {swept} empty installer director(y/ies)");
565    }
566}