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 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 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
264fn 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 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 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 _ => {} }
343}
344
345fn 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 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 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 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 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 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
532fn 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}