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
71pub(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 cleanup_bak(path);
79 Ok(())
80}
81
82pub(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 cleanup_bak(path);
90 Ok(())
91}
92
93pub fn print_help() {
103 println!(
104 "\
105lean-ctx uninstall — remove lean-ctx cleanly
106
107USAGE:
108 lean-ctx uninstall [OPTIONS]
109
110OPTIONS:
111 --dry-run Preview every change without modifying anything
112 --keep-config Preserve MCP configs and rules (for a later reinstall)
113 --keep-binary Leave the lean-ctx binary in place
114 -h, --help Show this help and exit (does NOT uninstall)
115
116WHAT IT REMOVES:
117 • Running processes (daemon, proxy) and autostart entries
118 • Shell hooks and proxy environment from your shell rc files
119 • MCP server configs and rules from every detected AI tool/IDE
120 • Skill directories and project integration files
121 • The data directory and the lean-ctx binary
122
123 Modified files are backed up as <file>.lean-ctx.bak before removal.
124
125EXAMPLES:
126 lean-ctx uninstall --dry-run # see exactly what would change
127 lean-ctx uninstall # full clean removal"
128 );
129}
130
131pub fn run(dry_run: bool, keep_config: bool, keep_binary: bool) {
136 let Some(home) = dirs::home_dir() else {
137 tracing::warn!("Could not determine home directory");
138 return;
139 };
140
141 let mode_label = if keep_config {
142 "uninstall --keep-config"
143 } else {
144 "uninstall"
145 };
146
147 if dry_run {
148 println!("\n lean-ctx {mode_label} --dry-run\n ──────────────────────────────────\n");
149 println!(" Preview mode — no files will be modified.\n");
150 } else {
151 println!("\n lean-ctx {mode_label}\n ──────────────────────────────────\n");
152 }
153
154 if keep_config {
155 println!(" Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
156 }
157
158 binary::stop_processes(dry_run);
160
161 let mut removed_any = false;
162
163 removed_any |= remove_shell_hook(&home, dry_run);
164 if dry_run {
165 crate::proxy_setup::preview_proxy_cleanup(&home);
166 } else {
167 crate::proxy_setup::uninstall_proxy_env(&home, false);
168 }
169
170 if keep_config {
171 println!(" · Skipped: MCP configs (--keep-config)");
172 println!(" · Skipped: Rules files (--keep-config)");
173 } else {
174 removed_any |= remove_mcp_configs(&home, dry_run);
175 removed_any |= remove_rules_files(&home, dry_run);
176 if !dry_run {
177 try_claude_mcp_remove();
178 }
179 }
180
181 removed_any |= remove_hook_files(&home, dry_run);
182 removed_any |= remove_plan_mode_settings(&home, dry_run);
183 removed_any |= remove_skill_dirs(&home, dry_run);
184 removed_any |= remove_project_agent_files(dry_run);
185
186 if dry_run {
187 println!(" Would remove proxy autostart (LaunchAgent/systemd)");
188 println!(" Would remove daemon autostart (LaunchAgent/systemd)");
189 println!(" Would remove auto-update schedule (LaunchAgent/systemd/Task)");
190 } else {
191 crate::proxy_autostart::uninstall(true);
192 crate::daemon_autostart::uninstall(true);
193 let had_schedule = crate::core::update_scheduler::schedule_status().enabled;
198 match crate::core::update_scheduler::remove_schedule() {
199 Ok(()) if had_schedule => {
200 println!(" ✓ Auto-update schedule removed");
201 removed_any = true;
202 }
203 Ok(()) => {}
204 Err(e) => tracing::warn!("Failed to remove auto-update schedule: {e}"),
205 }
206 }
207
208 if !dry_run {
209 cleanup_bak_files(&home);
210 }
211
212 removed_any |= remove_data_dir(&home, dry_run);
213
214 if !dry_run {
217 sweep_empty_installer_dirs(&home);
218 }
219
220 removed_any |= binary::remove_binaries(&home, dry_run, keep_binary);
223
224 println!();
225
226 if removed_any {
227 println!(" ──────────────────────────────────");
228 if dry_run {
229 println!(
230 " The above changes WOULD be applied.\n Run `lean-ctx {mode_label}` to execute.\n"
231 );
232 } else if keep_config {
233 println!(
234 " Runtime data removed. MCP configs preserved for reinstall.\n \
235 Reinstall with: cargo install lean-ctx\n"
236 );
237 } else {
238 println!(
239 " lean-ctx fully removed. Restart your shell to drop stale aliases.\n \
240 Verify with: command -v lean-ctx # should print nothing\n"
241 );
242 }
243 } else {
244 println!(" Nothing to remove — lean-ctx was not configured.\n");
245 }
246}
247
248pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
253 let s = content.find(start);
254 let e = content.find(end);
255 match (s, e) {
256 (Some(si), Some(ei)) if ei >= si => {
257 let after_end = ei + end.len();
258 let before = &content[..si];
259 let after = &content[after_end..];
260 let mut out = String::new();
261 out.push_str(before.trim_end_matches('\n'));
262 out.push('\n');
263 if !after.trim().is_empty() {
264 out.push('\n');
265 out.push_str(after.trim_start_matches('\n'));
266 }
267 out
268 }
269 _ => content.to_string(),
270 }
271}
272
273fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
278 let claude_state = crate::core::editor_registry::claude_state_dir(home);
279 let codebuddy_state = crate::core::editor_registry::codebuddy_state_dir(home);
280 let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
281 ("Claude Code", claude_state.join("skills/lean-ctx")),
282 ("CodeBuddy", codebuddy_state.join("skills/lean-ctx")),
283 ("Cursor", home.join(".cursor/skills/lean-ctx")),
284 (
285 "Codex CLI",
286 crate::core::home::resolve_codex_dir()
287 .unwrap_or_else(|| home.join(".codex"))
288 .join("skills/lean-ctx"),
289 ),
290 ("Grok", home.join(".grok/skills/lean-ctx")),
291 ("Copilot", home.join(".copilot/skills/lean-ctx")),
292 ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
293 ];
294
295 let default_claude_skill = home.join(".claude/skills/lean-ctx");
297 if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
298 skill_dirs.push(("Claude Code (default)", default_claude_skill));
299 }
300
301 let default_codebuddy_skill = home.join(".codebuddy/skills/lean-ctx");
303 if !skill_dirs
304 .iter()
305 .any(|(_, p)| *p == default_codebuddy_skill)
306 {
307 skill_dirs.push(("CodeBuddy (default)", default_codebuddy_skill));
308 }
309
310 let mut removed = false;
311 for (name, dir) in &skill_dirs {
312 if !dir.exists() {
313 continue;
314 }
315 if dry_run {
316 println!(" Would remove {name} skill directory");
317 removed = true;
318 } else if let Err(e) = fs::remove_dir_all(dir) {
319 tracing::warn!("Failed to remove {name} skill dir: {e}");
320 } else {
321 println!(" ✓ {name} skill directory removed");
322 removed = true;
323 }
324 }
325 removed
326}
327
328fn data_dirs_to_remove(home: &Path) -> Vec<PathBuf> {
344 let mut dirs = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
345
346 let push = |dirs: &mut Vec<PathBuf>, p: PathBuf| {
347 if !dirs.contains(&p) {
348 dirs.push(p);
349 }
350 };
351
352 for resolved in [
354 crate::core::paths::config_dir(),
355 crate::core::paths::data_dir(),
356 crate::core::paths::state_dir(),
357 crate::core::paths::cache_dir(),
358 ]
359 .into_iter()
360 .flatten()
361 {
362 push(&mut dirs, resolved);
363 }
364
365 for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
367 .into_iter()
368 .flatten()
369 {
370 push(&mut dirs, platform_dir.join("lean-ctx"));
371 }
372
373 dirs
374}
375
376fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
377 let mut removed = false;
378
379 let dirs_to_remove = data_dirs_to_remove(home);
380
381 for data_dir in &dirs_to_remove {
382 if !data_dir.exists() {
383 continue;
384 }
385 let short = shorten(data_dir, home);
386 if dry_run {
387 println!(" Would remove data directory ({short})");
388 removed = true;
389 continue;
390 }
391 match fs::remove_dir_all(data_dir) {
392 Ok(()) => {
393 println!(" ✓ Data directory removed ({short})");
394 removed = true;
395 }
396 Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
397 }
398 }
399
400 if let Ok(cwd) = std::env::current_dir() {
402 let project_dir = cwd.join(".lean-ctx");
403 let project_id = cwd.join(".lean-ctx-id");
404 for p in [&project_dir, &project_id] {
405 if p.exists() {
406 if dry_run {
407 println!(" Would remove {}", p.display());
408 removed = true;
409 } else if p.is_dir() {
410 if fs::remove_dir_all(p).is_ok() {
411 println!(" ✓ Removed {}", p.display());
412 removed = true;
413 }
414 } else if fs::remove_file(p).is_ok() {
415 println!(" ✓ Removed {}", p.display());
416 removed = true;
417 }
418 }
419 }
420 }
421
422 if !removed {
423 println!(" · No data directory found");
424 }
425 removed
426}
427
428fn try_claude_mcp_remove() {
429 let result = std::process::Command::new("claude")
430 .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
431 .stdout(std::process::Stdio::null())
432 .stderr(std::process::Stdio::null())
433 .status();
434 match result {
435 Ok(s) if s.success() => println!(" ✓ Removed lean-ctx from Claude MCP registry"),
436 _ => {} }
438}
439
440fn scan_dirs(home: &Path) -> Vec<PathBuf> {
448 let base_dirs: Vec<PathBuf> = vec![
449 home.join(".cursor"),
450 home.join(".claude"),
451 crate::core::editor_registry::claude_state_dir(home),
452 home.join(".codebuddy"),
453 crate::core::editor_registry::codebuddy_state_dir(home),
454 crate::core::editor_registry::zed_config_dir(home),
455 home.join(".gemini"),
456 home.join(".gemini/antigravity"),
457 home.join(".gemini/antigravity-cli"),
458 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
459 home.join(".codeium"),
460 home.join(".codeium/windsurf"),
461 home.join(".config/opencode"),
462 home.join(".config/amp"),
463 home.join(".config/crush"),
464 home.join(".config/zed"),
465 home.join(".qwen"),
466 home.join(".trae"),
467 home.join(".aws/amazonq"),
468 home.join(".kiro"),
469 home.join(".kiro/settings"),
470 home.join(".ampcoder"),
471 home.join(".pi"),
472 home.join(".pi/agent"),
473 home.join(".hermes"),
474 home.join(".grok"),
475 home.join(".verdent"),
476 home.join(".cline"),
477 home.join(".roo"),
478 home.join(".continue"),
479 home.join(".jb-rules"),
480 home.join(".openclaw"),
481 home.join(".augment"),
482 home.join(".qoder"),
483 home.join(".qoderwork"),
484 home.join(".aider"),
485 home.join(".emacs.d"),
486 home.join(".copilot"),
487 home.join(".github"),
488 home.join(".config/mcphub"),
489 home.join(".config/sublime-text"),
490 ];
491
492 const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
496 let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
497 for dir in base_dirs {
498 for sub in KNOWN_SUBDIRS {
499 let p = dir.join(sub);
500 if p.is_dir() {
501 dirs_to_scan.push(p);
502 }
503 }
504 dirs_to_scan.push(dir);
505 }
506
507 if let Ok(cwd) = std::env::current_dir() {
510 for rel in [
511 ".cursor/rules",
512 ".claude",
513 ".claude/rules",
514 ".claude/hooks",
515 ".codebuddy",
516 ".codebuddy/rules",
517 ".codebuddy/hooks",
518 ".kiro/steering",
519 ".github",
520 ".github/hooks",
521 ".vscode",
522 ] {
523 let p = cwd.join(rel);
524 if p.is_dir() {
525 dirs_to_scan.push(p);
526 }
527 }
528 }
529
530 dirs_to_scan
531}
532
533fn cleanup_bak_files(home: &Path) {
534 let dirs_to_scan = scan_dirs(home);
535 let mut cleaned = 0;
536 for dir in &dirs_to_scan {
537 if !dir.exists() {
538 continue;
539 }
540 if let Ok(entries) = fs::read_dir(dir) {
541 for entry in entries.flatten() {
542 let name = entry.file_name();
543 let name_str = name.to_string_lossy();
544 if name_str.ends_with(".lean-ctx.tmp") {
545 let _ = fs::remove_file(entry.path());
546 cleaned += 1;
547 continue;
548 }
549 if name_str.ends_with(".bak")
554 && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
555 {
556 let _ = fs::remove_file(entry.path());
557 cleaned += 1;
558 continue;
559 }
560 if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
561 let _ = fs::remove_file(entry.path());
562 cleaned += 1;
563 continue;
564 }
565 if name_str.ends_with(".lean-ctx.bak") {
566 let original_name = name_str.trim_end_matches(".lean-ctx.bak");
567 let original = entry.path().with_file_name(original_name);
568 if original.exists() {
569 match fs::read_to_string(&original) {
570 Ok(c) if !c.contains("lean-ctx") => {
571 let _ = fs::remove_file(entry.path());
572 cleaned += 1;
573 }
574 _ => {}
575 }
576 } else {
577 let _ = fs::remove_file(entry.path());
578 cleaned += 1;
579 }
580 continue;
581 }
582 if name_str.ends_with(".bak")
587 && !name_str.contains(".lean-ctx")
588 && let Ok(bak_content) = fs::read_to_string(entry.path())
589 && bak_content.contains("lean-ctx")
590 {
591 let _ = fs::remove_file(entry.path());
592 cleaned += 1;
593 }
594 }
595 }
596 }
597
598 let rc_baks = [
600 home.join(".zshrc.lean-ctx.bak"),
601 home.join(".zshenv.lean-ctx.bak"),
602 home.join(".bashrc.lean-ctx.bak"),
603 home.join(".bashenv.lean-ctx.bak"),
604 ];
605 for bak in &rc_baks {
606 if bak.exists() {
607 let original_name = bak
608 .file_name()
609 .unwrap_or_default()
610 .to_string_lossy()
611 .trim_end_matches(".lean-ctx.bak")
612 .to_string();
613 let original = bak.with_file_name(original_name);
614 if original.exists() {
615 if let Ok(c) = fs::read_to_string(&original)
616 && !c.contains("lean-ctx")
617 {
618 let _ = fs::remove_file(bak);
619 cleaned += 1;
620 }
621 } else {
622 let _ = fs::remove_file(bak);
623 cleaned += 1;
624 }
625 }
626 }
627
628 if cleaned > 0 {
629 println!(" ✓ Cleaned up {cleaned} backup file(s)");
630 }
631}
632
633fn sweep_empty_installer_dirs(home: &Path) {
638 let mut swept = 0;
639 for dir in scan_dirs(home) {
640 let is_installer_dir = dir
641 .file_name()
642 .and_then(|n| n.to_str())
643 .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
644 if is_installer_dir && fs::remove_dir(&dir).is_ok() {
645 swept += 1;
646 }
647 }
648 if swept > 0 {
649 println!(" ✓ Removed {swept} empty installer director(y/ies)");
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use std::collections::HashSet;
657
658 #[test]
659 fn data_dirs_to_remove_covers_canonical_xdg_categories() {
660 let _lock = crate::core::data_dir::test_env_lock();
667 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/tester"));
668 let dirs = data_dirs_to_remove(&home);
669
670 assert!(dirs.contains(&home.join(".lean-ctx")));
672 assert!(dirs.contains(&home.join(".config/lean-ctx")));
673
674 for resolved in [
680 crate::core::paths::config_dir(),
681 crate::core::paths::data_dir(),
682 crate::core::paths::state_dir(),
683 crate::core::paths::cache_dir(),
684 ]
685 .into_iter()
686 .flatten()
687 {
688 assert!(
689 dirs.contains(&resolved),
690 "uninstall would NOT remove canonical dir: {}",
691 resolved.display()
692 );
693 }
694
695 let mut seen = HashSet::new();
697 for d in &dirs {
698 assert!(seen.insert(d.clone()), "duplicate dir: {}", d.display());
699 }
700 }
701}