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 mut skill_dirs: Vec<(&str, PathBuf)> = vec![
228        ("Claude Code", claude_state.join("skills/lean-ctx")),
229        ("Cursor", home.join(".cursor/skills/lean-ctx")),
230        (
231            "Codex CLI",
232            crate::core::home::resolve_codex_dir()
233                .unwrap_or_else(|| home.join(".codex"))
234                .join("skills/lean-ctx"),
235        ),
236        ("Copilot", home.join(".copilot/skills/lean-ctx")),
237        ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
238    ];
239
240    // If CLAUDE_CONFIG_DIR differs from ~/.claude, also clean default path
241    let default_claude_skill = home.join(".claude/skills/lean-ctx");
242    if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
243        skill_dirs.push(("Claude Code (default)", default_claude_skill));
244    }
245
246    let mut removed = false;
247    for (name, dir) in &skill_dirs {
248        if !dir.exists() {
249            continue;
250        }
251        if dry_run {
252            println!("  Would remove {name} skill directory");
253            removed = true;
254        } else if let Err(e) = fs::remove_dir_all(dir) {
255            tracing::warn!("Failed to remove {name} skill dir: {e}");
256        } else {
257            println!("  ✓ {name} skill directory removed");
258            removed = true;
259        }
260    }
261    removed
262}
263
264// ---------------------------------------------------------------------------
265// Data directory
266// ---------------------------------------------------------------------------
267
268fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
269    let mut removed = false;
270
271    let mut dirs_to_remove = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
272
273    // Platform data dirs hold daemon logs + PID files (macOS:
274    // ~/Library/Application Support/lean-ctx, Windows: %LOCALAPPDATA%\lean-ctx,
275    // Linux: ~/.local/share/lean-ctx) — see daemon.rs `data_dir()`.
276    for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
277        .into_iter()
278        .flatten()
279    {
280        let p = platform_dir.join("lean-ctx");
281        if !dirs_to_remove.contains(&p) {
282            dirs_to_remove.push(p);
283        }
284    }
285
286    for data_dir in &dirs_to_remove {
287        if !data_dir.exists() {
288            continue;
289        }
290        let short = shorten(data_dir, home);
291        if dry_run {
292            println!("  Would remove data directory ({short})");
293            removed = true;
294            continue;
295        }
296        match fs::remove_dir_all(data_dir) {
297            Ok(()) => {
298                println!("  ✓ Data directory removed ({short})");
299                removed = true;
300            }
301            Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
302        }
303    }
304
305    // Project-local .lean-ctx/ and .lean-ctx-id in CWD
306    if let Ok(cwd) = std::env::current_dir() {
307        let project_dir = cwd.join(".lean-ctx");
308        let project_id = cwd.join(".lean-ctx-id");
309        for p in [&project_dir, &project_id] {
310            if p.exists() {
311                if dry_run {
312                    println!("  Would remove {}", p.display());
313                    removed = true;
314                } else if p.is_dir() {
315                    if fs::remove_dir_all(p).is_ok() {
316                        println!("  ✓ Removed {}", p.display());
317                        removed = true;
318                    }
319                } else if fs::remove_file(p).is_ok() {
320                    println!("  ✓ Removed {}", p.display());
321                    removed = true;
322                }
323            }
324        }
325    }
326
327    if !removed {
328        println!("  · No data directory found");
329    }
330    removed
331}
332
333fn try_claude_mcp_remove() {
334    let result = std::process::Command::new("claude")
335        .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
336        .stdout(std::process::Stdio::null())
337        .stderr(std::process::Stdio::null())
338        .status();
339    match result {
340        Ok(s) if s.success() => println!("  ✓ Removed lean-ctx from Claude MCP registry"),
341        _ => {} // claude CLI not available or already removed
342    }
343}
344
345// ---------------------------------------------------------------------------
346// .bak cleanup: remove orphaned backup files after successful surgical removal
347// ---------------------------------------------------------------------------
348
349/// Every directory the installer may have written backups or files into:
350/// agent config roots, their well-known subdirectories, and the project-local
351/// config dirs in CWD.
352fn scan_dirs(home: &Path) -> Vec<PathBuf> {
353    let base_dirs: Vec<PathBuf> = vec![
354        home.join(".cursor"),
355        home.join(".claude"),
356        crate::core::editor_registry::claude_state_dir(home),
357        crate::core::editor_registry::zed_config_dir(home),
358        home.join(".gemini"),
359        home.join(".gemini/antigravity"),
360        home.join(".gemini/antigravity-cli"),
361        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
362        home.join(".codeium"),
363        home.join(".codeium/windsurf"),
364        home.join(".config/opencode"),
365        home.join(".config/amp"),
366        home.join(".config/crush"),
367        home.join(".config/zed"),
368        home.join(".qwen"),
369        home.join(".trae"),
370        home.join(".aws/amazonq"),
371        home.join(".kiro"),
372        home.join(".kiro/settings"),
373        home.join(".ampcoder"),
374        home.join(".pi"),
375        home.join(".pi/agent"),
376        home.join(".hermes"),
377        home.join(".verdent"),
378        home.join(".cline"),
379        home.join(".roo"),
380        home.join(".continue"),
381        home.join(".jb-rules"),
382        home.join(".openclaw"),
383        home.join(".augment"),
384        home.join(".qoder"),
385        home.join(".qoderwork"),
386        home.join(".aider"),
387        home.join(".emacs.d"),
388        home.join(".copilot"),
389        home.join(".github"),
390        home.join(".config/mcphub"),
391        home.join(".config/sublime-text"),
392    ];
393
394    // Installers write into well-known subdirectories (hook scripts, rules
395    // files, steering docs, …). read_dir below is non-recursive, so backups in
396    // those subdirectories were previously missed (GL #558).
397    const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
398    let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
399    for dir in base_dirs {
400        for sub in KNOWN_SUBDIRS {
401            let p = dir.join(sub);
402            if p.is_dir() {
403                dirs_to_scan.push(p);
404            }
405        }
406        dirs_to_scan.push(dir);
407    }
408
409    // Project-local config dirs in CWD get the same backup treatment as HOME:
410    // setup writes (and uninstall removes) rules/hooks there too.
411    if let Ok(cwd) = std::env::current_dir() {
412        for rel in [
413            ".cursor/rules",
414            ".claude",
415            ".claude/rules",
416            ".claude/hooks",
417            ".kiro/steering",
418            ".github",
419            ".github/hooks",
420            ".vscode",
421        ] {
422            let p = cwd.join(rel);
423            if p.is_dir() {
424                dirs_to_scan.push(p);
425            }
426        }
427    }
428
429    dirs_to_scan
430}
431
432fn cleanup_bak_files(home: &Path) {
433    let dirs_to_scan = scan_dirs(home);
434    let mut cleaned = 0;
435    for dir in &dirs_to_scan {
436        if !dir.exists() {
437            continue;
438        }
439        if let Ok(entries) = fs::read_dir(dir) {
440            for entry in entries.flatten() {
441                let name = entry.file_name();
442                let name_str = name.to_string_lossy();
443                if name_str.ends_with(".lean-ctx.tmp") {
444                    let _ = fs::remove_file(entry.path());
445                    cleaned += 1;
446                    continue;
447                }
448                // Backups of our own hook scripts / rules files
449                // (lean-ctx-rewrite.sh.bak, lean-ctx.mdc.bak, …): the originals
450                // are lean-ctx-owned and already removed at this point, so the
451                // backups are pure leftovers.
452                if name_str.ends_with(".bak")
453                    && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
454                {
455                    let _ = fs::remove_file(entry.path());
456                    cleaned += 1;
457                    continue;
458                }
459                if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
460                    let _ = fs::remove_file(entry.path());
461                    cleaned += 1;
462                    continue;
463                }
464                if name_str.ends_with(".lean-ctx.bak") {
465                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
466                    let original = entry.path().with_file_name(original_name);
467                    if original.exists() {
468                        match fs::read_to_string(&original) {
469                            Ok(c) if !c.contains("lean-ctx") => {
470                                let _ = fs::remove_file(entry.path());
471                                cleaned += 1;
472                            }
473                            _ => {}
474                        }
475                    } else {
476                        let _ = fs::remove_file(entry.path());
477                        cleaned += 1;
478                    }
479                    continue;
480                }
481                // Plain .bak files next to known config files (created by
482                // config_io). Removed whether or not the original still exists:
483                // when uninstall deletes a config file that only contained
484                // lean-ctx content, its backup would otherwise be orphaned.
485                if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
486                    if let Ok(bak_content) = fs::read_to_string(entry.path()) {
487                        if bak_content.contains("lean-ctx") {
488                            let _ = fs::remove_file(entry.path());
489                            cleaned += 1;
490                        }
491                    }
492                }
493            }
494        }
495    }
496
497    // Also clean shell RC backups
498    let rc_baks = [
499        home.join(".zshrc.lean-ctx.bak"),
500        home.join(".zshenv.lean-ctx.bak"),
501        home.join(".bashrc.lean-ctx.bak"),
502        home.join(".bashenv.lean-ctx.bak"),
503    ];
504    for bak in &rc_baks {
505        if bak.exists() {
506            let original_name = bak
507                .file_name()
508                .unwrap_or_default()
509                .to_string_lossy()
510                .trim_end_matches(".lean-ctx.bak")
511                .to_string();
512            let original = bak.with_file_name(original_name);
513            if original.exists() {
514                if let Ok(c) = fs::read_to_string(&original) {
515                    if !c.contains("lean-ctx") {
516                        let _ = fs::remove_file(bak);
517                        cleaned += 1;
518                    }
519                }
520            } else {
521                let _ = fs::remove_file(bak);
522                cleaned += 1;
523            }
524        }
525    }
526
527    if cleaned > 0 {
528        println!("  ✓ Cleaned up {cleaned} backup file(s)");
529    }
530}
531
532/// Sweep now-empty installer-created directories (hooks/, rules/, skills/,
533/// steering/). `fs::remove_dir` refuses to delete non-empty directories, so
534/// anything still holding user content survives untouched. Runs as the last
535/// filesystem step of `run()` — after every file-removal pass has finished.
536fn sweep_empty_installer_dirs(home: &Path) {
537    let mut swept = 0;
538    for dir in scan_dirs(home) {
539        let is_installer_dir = dir
540            .file_name()
541            .and_then(|n| n.to_str())
542            .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
543        if is_installer_dir && fs::remove_dir(&dir).is_ok() {
544            swept += 1;
545        }
546    }
547    if swept > 0 {
548        println!("  ✓ Removed {swept} empty installer director(y/ies)");
549    }
550}