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 return home.join(".config/Code/User/github-copilot-instructions.md");
50 }
51 #[cfg(target_os = "windows")]
52 {
53 if let Ok(appdata) = std::env::var("APPDATA") {
54 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
55 }
56 }
57 #[allow(unreachable_code)]
58 home.join(".config/Code/User/github-copilot-instructions.md")
59}
60
61pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
63 if dry_run {
64 return Ok(());
65 }
66 fs::write(path, content)?;
67 cleanup_bak(path);
69 Ok(())
70}
71
72pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
74 if dry_run {
75 return Ok(());
76 }
77 fs::remove_file(path)?;
78 cleanup_bak(path);
80 Ok(())
81}
82
83pub fn run(dry_run: bool, keep_config: bool, keep_binary: bool) {
88 let Some(home) = dirs::home_dir() else {
89 tracing::warn!("Could not determine home directory");
90 return;
91 };
92
93 let mode_label = if keep_config {
94 "uninstall --keep-config"
95 } else {
96 "uninstall"
97 };
98
99 if dry_run {
100 println!("\n lean-ctx {mode_label} --dry-run\n ──────────────────────────────────\n");
101 println!(" Preview mode — no files will be modified.\n");
102 } else {
103 println!("\n lean-ctx {mode_label}\n ──────────────────────────────────\n");
104 }
105
106 if keep_config {
107 println!(" Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
108 }
109
110 binary::stop_processes(dry_run);
112
113 let mut removed_any = false;
114
115 removed_any |= remove_shell_hook(&home, dry_run);
116 if dry_run {
117 crate::proxy_setup::preview_proxy_cleanup(&home);
118 } else {
119 crate::proxy_setup::uninstall_proxy_env(&home, false);
120 }
121
122 if keep_config {
123 println!(" · Skipped: MCP configs (--keep-config)");
124 println!(" · Skipped: Rules files (--keep-config)");
125 } else {
126 removed_any |= remove_mcp_configs(&home, dry_run);
127 removed_any |= remove_rules_files(&home, dry_run);
128 if !dry_run {
129 try_claude_mcp_remove();
130 }
131 }
132
133 removed_any |= remove_hook_files(&home, dry_run);
134 removed_any |= remove_plan_mode_settings(&home, dry_run);
135 removed_any |= remove_skill_dirs(&home, dry_run);
136 removed_any |= remove_project_agent_files(dry_run);
137
138 if dry_run {
139 println!(" Would remove proxy autostart (LaunchAgent/systemd)");
140 println!(" Would remove daemon autostart (LaunchAgent/systemd)");
141 } else {
142 crate::proxy_autostart::uninstall(true);
143 crate::daemon_autostart::uninstall(true);
144 }
145
146 if !dry_run {
147 cleanup_bak_files(&home);
148 }
149
150 removed_any |= remove_data_dir(&home, dry_run);
151
152 removed_any |= binary::remove_binaries(&home, dry_run, keep_binary);
155
156 println!();
157
158 if removed_any {
159 println!(" ──────────────────────────────────");
160 if dry_run {
161 println!(
162 " The above changes WOULD be applied.\n Run `lean-ctx {mode_label}` to execute.\n"
163 );
164 } else if keep_config {
165 println!(
166 " Runtime data removed. MCP configs preserved for reinstall.\n \
167 Reinstall with: cargo install lean-ctx\n"
168 );
169 } else {
170 println!(
171 " lean-ctx fully removed. Restart your shell to drop stale aliases.\n \
172 Verify with: command -v lean-ctx # should print nothing\n"
173 );
174 }
175 } else {
176 println!(" Nothing to remove — lean-ctx was not configured.\n");
177 }
178}
179
180pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
185 let s = content.find(start);
186 let e = content.find(end);
187 match (s, e) {
188 (Some(si), Some(ei)) if ei >= si => {
189 let after_end = ei + end.len();
190 let before = &content[..si];
191 let after = &content[after_end..];
192 let mut out = String::new();
193 out.push_str(before.trim_end_matches('\n'));
194 out.push('\n');
195 if !after.trim().is_empty() {
196 out.push('\n');
197 out.push_str(after.trim_start_matches('\n'));
198 }
199 out
200 }
201 _ => content.to_string(),
202 }
203}
204
205fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
210 let claude_state = crate::core::editor_registry::claude_state_dir(home);
211 let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
212 ("Claude Code", claude_state.join("skills/lean-ctx")),
213 ("Cursor", home.join(".cursor/skills/lean-ctx")),
214 (
215 "Codex CLI",
216 crate::core::home::resolve_codex_dir()
217 .unwrap_or_else(|| home.join(".codex"))
218 .join("skills/lean-ctx"),
219 ),
220 ("Copilot", home.join(".copilot/skills/lean-ctx")),
221 ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
222 ];
223
224 let default_claude_skill = home.join(".claude/skills/lean-ctx");
226 if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
227 skill_dirs.push(("Claude Code (default)", default_claude_skill));
228 }
229
230 let mut removed = false;
231 for (name, dir) in &skill_dirs {
232 if !dir.exists() {
233 continue;
234 }
235 if dry_run {
236 println!(" Would remove {name} skill directory");
237 removed = true;
238 } else if let Err(e) = fs::remove_dir_all(dir) {
239 tracing::warn!("Failed to remove {name} skill dir: {e}");
240 } else {
241 println!(" ✓ {name} skill directory removed");
242 removed = true;
243 }
244 }
245 removed
246}
247
248fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
253 let mut removed = false;
254
255 let dirs_to_remove = [home.join(".lean-ctx"), home.join(".config/lean-ctx")];
256
257 for data_dir in &dirs_to_remove {
258 if !data_dir.exists() {
259 continue;
260 }
261 let short = shorten(data_dir, home);
262 if dry_run {
263 println!(" Would remove data directory ({short})");
264 removed = true;
265 continue;
266 }
267 match fs::remove_dir_all(data_dir) {
268 Ok(()) => {
269 println!(" ✓ Data directory removed ({short})");
270 removed = true;
271 }
272 Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
273 }
274 }
275
276 if let Ok(cwd) = std::env::current_dir() {
278 let project_dir = cwd.join(".lean-ctx");
279 let project_id = cwd.join(".lean-ctx-id");
280 for p in [&project_dir, &project_id] {
281 if p.exists() {
282 if dry_run {
283 println!(" Would remove {}", p.display());
284 removed = true;
285 } else if p.is_dir() {
286 if fs::remove_dir_all(p).is_ok() {
287 println!(" ✓ Removed {}", p.display());
288 removed = true;
289 }
290 } else if fs::remove_file(p).is_ok() {
291 println!(" ✓ Removed {}", p.display());
292 removed = true;
293 }
294 }
295 }
296 }
297
298 if !removed {
299 println!(" · No data directory found");
300 }
301 removed
302}
303
304fn try_claude_mcp_remove() {
305 let result = std::process::Command::new("claude")
306 .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
307 .stdout(std::process::Stdio::null())
308 .stderr(std::process::Stdio::null())
309 .status();
310 match result {
311 Ok(s) if s.success() => println!(" ✓ Removed lean-ctx from Claude MCP registry"),
312 _ => {} }
314}
315
316fn cleanup_bak_files(home: &Path) {
321 let dirs_to_scan: Vec<PathBuf> = vec![
322 home.join(".cursor"),
323 home.join(".claude"),
324 crate::core::editor_registry::claude_state_dir(home),
325 home.join(".gemini"),
326 home.join(".gemini/antigravity"),
327 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
328 home.join(".codeium"),
329 home.join(".codeium/windsurf"),
330 home.join(".config/opencode"),
331 home.join(".config/amp"),
332 home.join(".config/crush"),
333 home.join(".config/zed"),
334 home.join(".qwen"),
335 home.join(".trae"),
336 home.join(".aws/amazonq"),
337 home.join(".kiro"),
338 home.join(".kiro/settings"),
339 home.join(".ampcoder"),
340 home.join(".pi"),
341 home.join(".pi/agent"),
342 home.join(".hermes"),
343 home.join(".verdent"),
344 home.join(".cline"),
345 home.join(".roo"),
346 home.join(".continue"),
347 home.join(".jb-rules"),
348 home.join(".openclaw"),
349 home.join(".augment"),
350 home.join(".qoder"),
351 home.join(".qoderwork"),
352 home.join(".aider"),
353 home.join(".emacs.d"),
354 home.join(".copilot"),
355 home.join(".github"),
356 home.join(".github/hooks"),
357 home.join(".config/mcphub"),
358 home.join(".config/sublime-text"),
359 ];
360
361 let mut cleaned = 0;
362 for dir in &dirs_to_scan {
363 if !dir.exists() {
364 continue;
365 }
366 if let Ok(entries) = fs::read_dir(dir) {
367 for entry in entries.flatten() {
368 let name = entry.file_name();
369 let name_str = name.to_string_lossy();
370 if name_str.ends_with(".lean-ctx.tmp") {
371 let _ = fs::remove_file(entry.path());
372 cleaned += 1;
373 continue;
374 }
375 if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
376 let _ = fs::remove_file(entry.path());
377 cleaned += 1;
378 continue;
379 }
380 if name_str.ends_with(".lean-ctx.bak") {
381 let original_name = name_str.trim_end_matches(".lean-ctx.bak");
382 let original = entry.path().with_file_name(original_name);
383 if original.exists() {
384 match fs::read_to_string(&original) {
385 Ok(c) if !c.contains("lean-ctx") => {
386 let _ = fs::remove_file(entry.path());
387 cleaned += 1;
388 }
389 _ => {}
390 }
391 } else {
392 let _ = fs::remove_file(entry.path());
393 cleaned += 1;
394 }
395 continue;
396 }
397 if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
399 let original_name = name_str.trim_end_matches(".bak");
400 let original = entry.path().with_file_name(original_name);
401 if original.exists() {
402 if let Ok(bak_content) = fs::read_to_string(entry.path()) {
403 if bak_content.contains("lean-ctx") {
404 let _ = fs::remove_file(entry.path());
405 cleaned += 1;
406 }
407 }
408 }
409 }
410 }
411 }
412 }
413
414 let rc_baks = [
416 home.join(".zshrc.lean-ctx.bak"),
417 home.join(".zshenv.lean-ctx.bak"),
418 home.join(".bashrc.lean-ctx.bak"),
419 home.join(".bashenv.lean-ctx.bak"),
420 ];
421 for bak in &rc_baks {
422 if bak.exists() {
423 let original_name = bak
424 .file_name()
425 .unwrap_or_default()
426 .to_string_lossy()
427 .trim_end_matches(".lean-ctx.bak")
428 .to_string();
429 let original = bak.with_file_name(original_name);
430 if original.exists() {
431 if let Ok(c) = fs::read_to_string(&original) {
432 if !c.contains("lean-ctx") {
433 let _ = fs::remove_file(bak);
434 cleaned += 1;
435 }
436 }
437 } else {
438 let _ = fs::remove_file(bak);
439 cleaned += 1;
440 }
441 }
442 }
443
444 if cleaned > 0 {
445 println!(" ✓ Cleaned up {cleaned} backup file(s)");
446 }
447}