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