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 ("Copilot", home.join(".copilot/skills/lean-ctx")),
291 ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
292 ];
293
294 let default_claude_skill = home.join(".claude/skills/lean-ctx");
296 if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
297 skill_dirs.push(("Claude Code (default)", default_claude_skill));
298 }
299
300 let default_codebuddy_skill = home.join(".codebuddy/skills/lean-ctx");
302 if !skill_dirs
303 .iter()
304 .any(|(_, p)| *p == default_codebuddy_skill)
305 {
306 skill_dirs.push(("CodeBuddy (default)", default_codebuddy_skill));
307 }
308
309 let mut removed = false;
310 for (name, dir) in &skill_dirs {
311 if !dir.exists() {
312 continue;
313 }
314 if dry_run {
315 println!(" Would remove {name} skill directory");
316 removed = true;
317 } else if let Err(e) = fs::remove_dir_all(dir) {
318 tracing::warn!("Failed to remove {name} skill dir: {e}");
319 } else {
320 println!(" ✓ {name} skill directory removed");
321 removed = true;
322 }
323 }
324 removed
325}
326
327fn data_dirs_to_remove(home: &Path) -> Vec<PathBuf> {
343 let mut dirs = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
344
345 let push = |dirs: &mut Vec<PathBuf>, p: PathBuf| {
346 if !dirs.contains(&p) {
347 dirs.push(p);
348 }
349 };
350
351 for resolved in [
353 crate::core::paths::config_dir(),
354 crate::core::paths::data_dir(),
355 crate::core::paths::state_dir(),
356 crate::core::paths::cache_dir(),
357 ]
358 .into_iter()
359 .flatten()
360 {
361 push(&mut dirs, resolved);
362 }
363
364 for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
366 .into_iter()
367 .flatten()
368 {
369 push(&mut dirs, platform_dir.join("lean-ctx"));
370 }
371
372 dirs
373}
374
375fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
376 let mut removed = false;
377
378 let dirs_to_remove = data_dirs_to_remove(home);
379
380 for data_dir in &dirs_to_remove {
381 if !data_dir.exists() {
382 continue;
383 }
384 let short = shorten(data_dir, home);
385 if dry_run {
386 println!(" Would remove data directory ({short})");
387 removed = true;
388 continue;
389 }
390 match fs::remove_dir_all(data_dir) {
391 Ok(()) => {
392 println!(" ✓ Data directory removed ({short})");
393 removed = true;
394 }
395 Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
396 }
397 }
398
399 if let Ok(cwd) = std::env::current_dir() {
401 let project_dir = cwd.join(".lean-ctx");
402 let project_id = cwd.join(".lean-ctx-id");
403 for p in [&project_dir, &project_id] {
404 if p.exists() {
405 if dry_run {
406 println!(" Would remove {}", p.display());
407 removed = true;
408 } else if p.is_dir() {
409 if fs::remove_dir_all(p).is_ok() {
410 println!(" ✓ Removed {}", p.display());
411 removed = true;
412 }
413 } else if fs::remove_file(p).is_ok() {
414 println!(" ✓ Removed {}", p.display());
415 removed = true;
416 }
417 }
418 }
419 }
420
421 if !removed {
422 println!(" · No data directory found");
423 }
424 removed
425}
426
427fn try_claude_mcp_remove() {
428 let result = std::process::Command::new("claude")
429 .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
430 .stdout(std::process::Stdio::null())
431 .stderr(std::process::Stdio::null())
432 .status();
433 match result {
434 Ok(s) if s.success() => println!(" ✓ Removed lean-ctx from Claude MCP registry"),
435 _ => {} }
437}
438
439fn scan_dirs(home: &Path) -> Vec<PathBuf> {
447 let base_dirs: Vec<PathBuf> = vec![
448 home.join(".cursor"),
449 home.join(".claude"),
450 crate::core::editor_registry::claude_state_dir(home),
451 home.join(".codebuddy"),
452 crate::core::editor_registry::codebuddy_state_dir(home),
453 crate::core::editor_registry::zed_config_dir(home),
454 home.join(".gemini"),
455 home.join(".gemini/antigravity"),
456 home.join(".gemini/antigravity-cli"),
457 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
458 home.join(".codeium"),
459 home.join(".codeium/windsurf"),
460 home.join(".config/opencode"),
461 home.join(".config/amp"),
462 home.join(".config/crush"),
463 home.join(".config/zed"),
464 home.join(".qwen"),
465 home.join(".trae"),
466 home.join(".aws/amazonq"),
467 home.join(".kiro"),
468 home.join(".kiro/settings"),
469 home.join(".ampcoder"),
470 home.join(".pi"),
471 home.join(".pi/agent"),
472 home.join(".hermes"),
473 home.join(".verdent"),
474 home.join(".cline"),
475 home.join(".roo"),
476 home.join(".continue"),
477 home.join(".jb-rules"),
478 home.join(".openclaw"),
479 home.join(".augment"),
480 home.join(".qoder"),
481 home.join(".qoderwork"),
482 home.join(".aider"),
483 home.join(".emacs.d"),
484 home.join(".copilot"),
485 home.join(".github"),
486 home.join(".config/mcphub"),
487 home.join(".config/sublime-text"),
488 ];
489
490 const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
494 let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
495 for dir in base_dirs {
496 for sub in KNOWN_SUBDIRS {
497 let p = dir.join(sub);
498 if p.is_dir() {
499 dirs_to_scan.push(p);
500 }
501 }
502 dirs_to_scan.push(dir);
503 }
504
505 if let Ok(cwd) = std::env::current_dir() {
508 for rel in [
509 ".cursor/rules",
510 ".claude",
511 ".claude/rules",
512 ".claude/hooks",
513 ".codebuddy",
514 ".codebuddy/rules",
515 ".codebuddy/hooks",
516 ".kiro/steering",
517 ".github",
518 ".github/hooks",
519 ".vscode",
520 ] {
521 let p = cwd.join(rel);
522 if p.is_dir() {
523 dirs_to_scan.push(p);
524 }
525 }
526 }
527
528 dirs_to_scan
529}
530
531fn cleanup_bak_files(home: &Path) {
532 let dirs_to_scan = scan_dirs(home);
533 let mut cleaned = 0;
534 for dir in &dirs_to_scan {
535 if !dir.exists() {
536 continue;
537 }
538 if let Ok(entries) = fs::read_dir(dir) {
539 for entry in entries.flatten() {
540 let name = entry.file_name();
541 let name_str = name.to_string_lossy();
542 if name_str.ends_with(".lean-ctx.tmp") {
543 let _ = fs::remove_file(entry.path());
544 cleaned += 1;
545 continue;
546 }
547 if name_str.ends_with(".bak")
552 && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
553 {
554 let _ = fs::remove_file(entry.path());
555 cleaned += 1;
556 continue;
557 }
558 if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
559 let _ = fs::remove_file(entry.path());
560 cleaned += 1;
561 continue;
562 }
563 if name_str.ends_with(".lean-ctx.bak") {
564 let original_name = name_str.trim_end_matches(".lean-ctx.bak");
565 let original = entry.path().with_file_name(original_name);
566 if original.exists() {
567 match fs::read_to_string(&original) {
568 Ok(c) if !c.contains("lean-ctx") => {
569 let _ = fs::remove_file(entry.path());
570 cleaned += 1;
571 }
572 _ => {}
573 }
574 } else {
575 let _ = fs::remove_file(entry.path());
576 cleaned += 1;
577 }
578 continue;
579 }
580 if name_str.ends_with(".bak")
585 && !name_str.contains(".lean-ctx")
586 && let Ok(bak_content) = fs::read_to_string(entry.path())
587 && bak_content.contains("lean-ctx")
588 {
589 let _ = fs::remove_file(entry.path());
590 cleaned += 1;
591 }
592 }
593 }
594 }
595
596 let rc_baks = [
598 home.join(".zshrc.lean-ctx.bak"),
599 home.join(".zshenv.lean-ctx.bak"),
600 home.join(".bashrc.lean-ctx.bak"),
601 home.join(".bashenv.lean-ctx.bak"),
602 ];
603 for bak in &rc_baks {
604 if bak.exists() {
605 let original_name = bak
606 .file_name()
607 .unwrap_or_default()
608 .to_string_lossy()
609 .trim_end_matches(".lean-ctx.bak")
610 .to_string();
611 let original = bak.with_file_name(original_name);
612 if original.exists() {
613 if let Ok(c) = fs::read_to_string(&original)
614 && !c.contains("lean-ctx")
615 {
616 let _ = fs::remove_file(bak);
617 cleaned += 1;
618 }
619 } else {
620 let _ = fs::remove_file(bak);
621 cleaned += 1;
622 }
623 }
624 }
625
626 if cleaned > 0 {
627 println!(" ✓ Cleaned up {cleaned} backup file(s)");
628 }
629}
630
631fn sweep_empty_installer_dirs(home: &Path) {
636 let mut swept = 0;
637 for dir in scan_dirs(home) {
638 let is_installer_dir = dir
639 .file_name()
640 .and_then(|n| n.to_str())
641 .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
642 if is_installer_dir && fs::remove_dir(&dir).is_ok() {
643 swept += 1;
644 }
645 }
646 if swept > 0 {
647 println!(" ✓ Removed {swept} empty installer director(y/ies)");
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use std::collections::HashSet;
655
656 #[test]
657 fn data_dirs_to_remove_covers_canonical_xdg_categories() {
658 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/tester"));
659 let dirs = data_dirs_to_remove(&home);
660
661 assert!(dirs.contains(&home.join(".lean-ctx")));
663 assert!(dirs.contains(&home.join(".config/lean-ctx")));
664
665 for resolved in [
671 crate::core::paths::config_dir(),
672 crate::core::paths::data_dir(),
673 crate::core::paths::state_dir(),
674 crate::core::paths::cache_dir(),
675 ]
676 .into_iter()
677 .flatten()
678 {
679 assert!(
680 dirs.contains(&resolved),
681 "uninstall would NOT remove canonical dir: {}",
682 resolved.display()
683 );
684 }
685
686 let mut seen = HashSet::new();
688 for d in &dirs {
689 assert!(seen.insert(d.clone()), "duplicate dir: {}", d.display());
690 }
691 }
692}