1mod agents;
2mod parsers;
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use agents::{
8 remove_hook_files, remove_mcp_configs, remove_plan_mode_settings, remove_project_agent_files,
9 remove_rules_files, remove_shell_hook,
10};
11
12pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
13 if dry_run {
14 return;
15 }
16 if path.exists() {
17 let bak = bak_path_for(path);
18 let _ = fs::copy(path, &bak);
19 }
20}
21
22pub fn bak_path_for(path: &Path) -> PathBuf {
23 let filename = path.file_name().unwrap_or_default().to_string_lossy();
24 path.with_file_name(format!("{filename}.lean-ctx.bak"))
25}
26
27fn cleanup_bak(path: &Path) {
28 let bak = bak_path_for(path);
29 if bak.exists() {
30 let _ = fs::remove_file(&bak);
31 }
32}
33
34pub(super) fn shorten(path: &Path, home: &Path) -> String {
35 match path.strip_prefix(home) {
36 Ok(rel) => format!("~/{}", rel.display()),
37 Err(_) => path.display().to_string(),
38 }
39}
40
41pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
42 #[cfg(target_os = "macos")]
43 {
44 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
45 }
46 #[cfg(target_os = "linux")]
47 {
48 return home.join(".config/Code/User/github-copilot-instructions.md");
49 }
50 #[cfg(target_os = "windows")]
51 {
52 if let Ok(appdata) = std::env::var("APPDATA") {
53 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
54 }
55 }
56 #[allow(unreachable_code)]
57 home.join(".config/Code/User/github-copilot-instructions.md")
58}
59
60pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
62 if dry_run {
63 return Ok(());
64 }
65 fs::write(path, content)?;
66 cleanup_bak(path);
68 Ok(())
69}
70
71pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
73 if dry_run {
74 return Ok(());
75 }
76 fs::remove_file(path)?;
77 cleanup_bak(path);
79 Ok(())
80}
81
82pub fn run(dry_run: bool, keep_config: bool) {
87 let Some(home) = dirs::home_dir() else {
88 tracing::warn!("Could not determine home directory");
89 return;
90 };
91
92 let mode_label = if keep_config {
93 "uninstall --keep-config"
94 } else {
95 "uninstall"
96 };
97
98 if dry_run {
99 println!("\n lean-ctx {mode_label} --dry-run\n ──────────────────────────────────\n");
100 println!(" Preview mode — no files will be modified.\n");
101 } else {
102 println!("\n lean-ctx {mode_label}\n ──────────────────────────────────\n");
103 }
104
105 if keep_config {
106 println!(" Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
107 }
108
109 let mut removed_any = false;
110
111 removed_any |= remove_shell_hook(&home, dry_run);
112 if dry_run {
113 crate::proxy_setup::preview_proxy_cleanup(&home);
114 } else {
115 crate::proxy_setup::uninstall_proxy_env(&home, false);
116 }
117
118 if keep_config {
119 println!(" · Skipped: MCP configs (--keep-config)");
120 println!(" · Skipped: Rules files (--keep-config)");
121 } else {
122 removed_any |= remove_mcp_configs(&home, dry_run);
123 removed_any |= remove_rules_files(&home, dry_run);
124 if !dry_run {
125 try_claude_mcp_remove();
126 }
127 }
128
129 removed_any |= remove_hook_files(&home, dry_run);
130 removed_any |= remove_plan_mode_settings(&home, dry_run);
131 removed_any |= remove_skill_dirs(&home, dry_run);
132 removed_any |= remove_project_agent_files(dry_run);
133
134 if dry_run {
135 println!(" Would remove proxy autostart (LaunchAgent/systemd)");
136 println!(" Would remove daemon autostart (LaunchAgent/systemd)");
137 } else {
138 crate::proxy_autostart::uninstall(true);
139 crate::daemon_autostart::uninstall(true);
140 }
141
142 if !dry_run {
143 cleanup_bak_files(&home);
144 }
145
146 removed_any |= remove_data_dir(&home, dry_run);
147
148 println!();
149
150 if removed_any {
151 println!(" ──────────────────────────────────");
152 if dry_run {
153 println!(
154 " The above changes WOULD be applied.\n Run `lean-ctx {mode_label}` to execute.\n"
155 );
156 } else if keep_config {
157 println!(
158 " Runtime data removed. MCP configs preserved for reinstall.\n \
159 Reinstall with: cargo install lean-ctx\n"
160 );
161 } else {
162 println!(" lean-ctx configuration removed.\n");
163 }
164 } else {
165 println!(" Nothing to remove — lean-ctx was not configured.\n");
166 }
167
168 if !dry_run {
169 print_binary_removal_instructions();
170 }
171}
172
173pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
178 let s = content.find(start);
179 let e = content.find(end);
180 match (s, e) {
181 (Some(si), Some(ei)) if ei >= si => {
182 let after_end = ei + end.len();
183 let before = &content[..si];
184 let after = &content[after_end..];
185 let mut out = String::new();
186 out.push_str(before.trim_end_matches('\n'));
187 out.push('\n');
188 if !after.trim().is_empty() {
189 out.push('\n');
190 out.push_str(after.trim_start_matches('\n'));
191 }
192 out
193 }
194 _ => content.to_string(),
195 }
196}
197
198fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
203 let claude_state = crate::core::editor_registry::claude_state_dir(home);
204 let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
205 ("Claude Code", claude_state.join("skills/lean-ctx")),
206 ("Cursor", home.join(".cursor/skills/lean-ctx")),
207 (
208 "Codex CLI",
209 crate::core::home::resolve_codex_dir()
210 .unwrap_or_else(|| home.join(".codex"))
211 .join("skills/lean-ctx"),
212 ),
213 ("Copilot", home.join(".copilot/skills/lean-ctx")),
214 ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
215 ];
216
217 let default_claude_skill = home.join(".claude/skills/lean-ctx");
219 if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
220 skill_dirs.push(("Claude Code (default)", default_claude_skill));
221 }
222
223 let mut removed = false;
224 for (name, dir) in &skill_dirs {
225 if !dir.exists() {
226 continue;
227 }
228 if dry_run {
229 println!(" Would remove {name} skill directory");
230 removed = true;
231 } else if let Err(e) = fs::remove_dir_all(dir) {
232 tracing::warn!("Failed to remove {name} skill dir: {e}");
233 } else {
234 println!(" ✓ {name} skill directory removed");
235 removed = true;
236 }
237 }
238 removed
239}
240
241fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
246 let mut removed = false;
247
248 let dirs_to_remove = [home.join(".lean-ctx"), home.join(".config/lean-ctx")];
249
250 for data_dir in &dirs_to_remove {
251 if !data_dir.exists() {
252 continue;
253 }
254 let short = shorten(data_dir, home);
255 if dry_run {
256 println!(" Would remove data directory ({short})");
257 removed = true;
258 continue;
259 }
260 match fs::remove_dir_all(data_dir) {
261 Ok(()) => {
262 println!(" ✓ Data directory removed ({short})");
263 removed = true;
264 }
265 Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
266 }
267 }
268
269 if let Ok(cwd) = std::env::current_dir() {
271 let project_dir = cwd.join(".lean-ctx");
272 let project_id = cwd.join(".lean-ctx-id");
273 for p in [&project_dir, &project_id] {
274 if p.exists() {
275 if dry_run {
276 println!(" Would remove {}", p.display());
277 removed = true;
278 } else if p.is_dir() {
279 if fs::remove_dir_all(p).is_ok() {
280 println!(" ✓ Removed {}", p.display());
281 removed = true;
282 }
283 } else if fs::remove_file(p).is_ok() {
284 println!(" ✓ Removed {}", p.display());
285 removed = true;
286 }
287 }
288 }
289 }
290
291 if !removed {
292 println!(" · No data directory found");
293 }
294 removed
295}
296
297fn try_claude_mcp_remove() {
298 let result = std::process::Command::new("claude")
299 .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
300 .stdout(std::process::Stdio::null())
301 .stderr(std::process::Stdio::null())
302 .status();
303 match result {
304 Ok(s) if s.success() => println!(" ✓ Removed lean-ctx from Claude MCP registry"),
305 _ => {} }
307}
308
309fn cleanup_bak_files(home: &Path) {
314 let dirs_to_scan: Vec<PathBuf> = vec![
315 home.join(".cursor"),
316 home.join(".claude"),
317 crate::core::editor_registry::claude_state_dir(home),
318 home.join(".gemini"),
319 home.join(".gemini/antigravity"),
320 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
321 home.join(".codeium"),
322 home.join(".codeium/windsurf"),
323 home.join(".config/opencode"),
324 home.join(".config/amp"),
325 home.join(".config/crush"),
326 home.join(".config/zed"),
327 home.join(".qwen"),
328 home.join(".trae"),
329 home.join(".aws/amazonq"),
330 home.join(".kiro"),
331 home.join(".kiro/settings"),
332 home.join(".ampcoder"),
333 home.join(".pi"),
334 home.join(".pi/agent"),
335 home.join(".hermes"),
336 home.join(".verdent"),
337 home.join(".cline"),
338 home.join(".roo"),
339 home.join(".continue"),
340 home.join(".jb-rules"),
341 home.join(".openclaw"),
342 home.join(".augment"),
343 home.join(".qoder"),
344 home.join(".qoderwork"),
345 home.join(".aider"),
346 home.join(".emacs.d"),
347 home.join(".copilot"),
348 home.join(".github"),
349 home.join(".github/hooks"),
350 home.join(".config/mcphub"),
351 home.join(".config/sublime-text"),
352 ];
353
354 let mut cleaned = 0;
355 for dir in &dirs_to_scan {
356 if !dir.exists() {
357 continue;
358 }
359 if let Ok(entries) = fs::read_dir(dir) {
360 for entry in entries.flatten() {
361 let name = entry.file_name();
362 let name_str = name.to_string_lossy();
363 if name_str.ends_with(".lean-ctx.tmp") {
364 let _ = fs::remove_file(entry.path());
365 cleaned += 1;
366 continue;
367 }
368 if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
369 let _ = fs::remove_file(entry.path());
370 cleaned += 1;
371 continue;
372 }
373 if name_str.ends_with(".lean-ctx.bak") {
374 let original_name = name_str.trim_end_matches(".lean-ctx.bak");
375 let original = entry.path().with_file_name(original_name);
376 if original.exists() {
377 match fs::read_to_string(&original) {
378 Ok(c) if !c.contains("lean-ctx") => {
379 let _ = fs::remove_file(entry.path());
380 cleaned += 1;
381 }
382 _ => {}
383 }
384 } else {
385 let _ = fs::remove_file(entry.path());
386 cleaned += 1;
387 }
388 continue;
389 }
390 if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
392 let original_name = name_str.trim_end_matches(".bak");
393 let original = entry.path().with_file_name(original_name);
394 if original.exists() {
395 if let Ok(bak_content) = fs::read_to_string(entry.path()) {
396 if bak_content.contains("lean-ctx") {
397 let _ = fs::remove_file(entry.path());
398 cleaned += 1;
399 }
400 }
401 }
402 }
403 }
404 }
405 }
406
407 let rc_baks = [
409 home.join(".zshrc.lean-ctx.bak"),
410 home.join(".zshenv.lean-ctx.bak"),
411 home.join(".bashrc.lean-ctx.bak"),
412 home.join(".bashenv.lean-ctx.bak"),
413 ];
414 for bak in &rc_baks {
415 if bak.exists() {
416 let original_name = bak
417 .file_name()
418 .unwrap_or_default()
419 .to_string_lossy()
420 .trim_end_matches(".lean-ctx.bak")
421 .to_string();
422 let original = bak.with_file_name(original_name);
423 if original.exists() {
424 if let Ok(c) = fs::read_to_string(&original) {
425 if !c.contains("lean-ctx") {
426 let _ = fs::remove_file(bak);
427 cleaned += 1;
428 }
429 }
430 } else {
431 let _ = fs::remove_file(bak);
432 cleaned += 1;
433 }
434 }
435 }
436
437 if cleaned > 0 {
438 println!(" ✓ Cleaned up {cleaned} backup file(s)");
439 }
440}
441
442fn print_binary_removal_instructions() {
447 let binary_path = std::env::current_exe()
448 .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
449
450 println!(" To complete uninstallation, remove the binary:\n");
451
452 if binary_path.contains(".cargo") {
453 println!(" cargo uninstall lean-ctx\n");
454 } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
455 println!(" brew uninstall lean-ctx\n");
456 } else {
457 println!(" rm {binary_path}\n");
458 }
459
460 println!(" Then restart your shell.\n");
461}