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 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 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 if !dry_run {
165 sweep_empty_installer_dirs(&home);
166 }
167
168 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
196pub(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
221fn 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 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 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
275fn 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 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 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 _ => {} }
354}
355
356fn 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 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 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 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 if name_str.ends_with(".bak")
502 && !name_str.contains(".lean-ctx")
503 && let Ok(bak_content) = fs::read_to_string(entry.path())
504 && bak_content.contains("lean-ctx")
505 {
506 let _ = fs::remove_file(entry.path());
507 cleaned += 1;
508 }
509 }
510 }
511 }
512
513 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 && !c.contains("lean-ctx")
532 {
533 let _ = fs::remove_file(bak);
534 cleaned += 1;
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
548fn 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}