1use std::fs;
2use std::path::{Path, PathBuf};
3
4pub fn run() {
5 let home = match dirs::home_dir() {
6 Some(h) => h,
7 None => {
8 eprintln!(" ✗ Could not determine home directory");
9 return;
10 }
11 };
12
13 println!("\n lean-ctx uninstall\n ──────────────────────────────────\n");
14
15 let mut removed_any = false;
16
17 removed_any |= remove_shell_hook(&home);
18 crate::proxy_setup::uninstall_proxy_env(&home, false);
19 removed_any |= remove_mcp_configs(&home);
20 removed_any |= remove_rules_files(&home);
21 removed_any |= remove_hook_files(&home);
22 removed_any |= remove_project_agent_files();
23 removed_any |= remove_data_dir(&home);
24
25 println!();
26
27 if removed_any {
28 println!(" ──────────────────────────────────");
29 println!(" lean-ctx configuration removed.\n");
30 } else {
31 println!(" Nothing to remove — lean-ctx was not configured.\n");
32 }
33
34 print_binary_removal_instructions();
35}
36
37fn remove_project_agent_files() -> bool {
38 let cwd = std::env::current_dir().unwrap_or_default();
39 let agents = cwd.join("AGENTS.md");
40 let lean_ctx_md = cwd.join("LEAN-CTX.md");
41
42 const START: &str = "<!-- lean-ctx -->";
43 const END: &str = "<!-- /lean-ctx -->";
44 const OWNED: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
45
46 let mut removed = false;
47
48 if agents.exists() {
49 if let Ok(content) = fs::read_to_string(&agents) {
50 if content.contains(START) {
51 let cleaned = remove_marked_block(&content, START, END);
52 if cleaned != content {
53 if let Err(e) = fs::write(&agents, cleaned) {
54 eprintln!(" ✗ Failed to update project AGENTS.md: {e}");
55 } else {
56 println!(" ✓ Project: removed lean-ctx block from AGENTS.md");
57 removed = true;
58 }
59 }
60 }
61 }
62 }
63
64 if lean_ctx_md.exists() {
65 if let Ok(content) = fs::read_to_string(&lean_ctx_md) {
66 if content.contains(OWNED) {
67 if let Err(e) = fs::remove_file(&lean_ctx_md) {
68 eprintln!(" ✗ Failed to remove project LEAN-CTX.md: {e}");
69 } else {
70 println!(" ✓ Project: removed LEAN-CTX.md");
71 removed = true;
72 }
73 }
74 }
75 }
76
77 let project_files = [
78 ".windsurfrules",
79 ".clinerules",
80 ".cursorrules",
81 ".kiro/steering/lean-ctx.md",
82 ".cursor/rules/lean-ctx.mdc",
83 ];
84 for rel in &project_files {
85 let path = cwd.join(rel);
86 if path.exists() {
87 if let Ok(content) = fs::read_to_string(&path) {
88 if content.contains("lean-ctx") {
89 let _ = fs::remove_file(&path);
90 println!(" ✓ Project: removed {rel}");
91 removed = true;
92 }
93 }
94 }
95 }
96
97 removed
98}
99
100fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
101 let s = content.find(start);
102 let e = content.find(end);
103 match (s, e) {
104 (Some(si), Some(ei)) if ei >= si => {
105 let after_end = ei + end.len();
106 let before = &content[..si];
107 let after = &content[after_end..];
108 let mut out = String::new();
109 out.push_str(before.trim_end_matches('\n'));
110 out.push('\n');
111 if !after.trim().is_empty() {
112 out.push('\n');
113 out.push_str(after.trim_start_matches('\n'));
114 }
115 out
116 }
117 _ => content.to_string(),
118 }
119}
120
121fn remove_shell_hook(home: &Path) -> bool {
122 let shell = std::env::var("SHELL").unwrap_or_default();
123 let mut removed = false;
124
125 crate::shell_hook::uninstall_all(false);
126
127 let rc_files: Vec<PathBuf> = vec![
128 home.join(".zshrc"),
129 home.join(".bashrc"),
130 home.join(".config/fish/config.fish"),
131 #[cfg(windows)]
132 home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
133 ];
134
135 for rc in &rc_files {
136 if !rc.exists() {
137 continue;
138 }
139 let content = match fs::read_to_string(rc) {
140 Ok(c) => c,
141 Err(_) => continue,
142 };
143 if !content.contains("lean-ctx") {
144 continue;
145 }
146
147 let mut cleaned = remove_lean_ctx_block(&content);
148 cleaned = remove_source_lines(&cleaned);
149 if cleaned.trim() != content.trim() {
150 let bak = rc.with_extension("lean-ctx.bak");
151 let _ = fs::copy(rc, &bak);
152 if let Err(e) = fs::write(rc, &cleaned) {
153 eprintln!(" ✗ Failed to update {}: {}", rc.display(), e);
154 } else {
155 let short = shorten(rc, home);
156 println!(" ✓ Shell hook removed from {short}");
157 println!(" Backup: {}", shorten(&bak, home));
158 removed = true;
159 }
160 }
161 }
162
163 let hook_files = [
164 "shell-hook.zsh",
165 "shell-hook.bash",
166 "shell-hook.fish",
167 "shell-hook.ps1",
168 ];
169 let lc_dir = home.join(".lean-ctx");
170 for f in &hook_files {
171 let path = lc_dir.join(f);
172 if path.exists() {
173 let _ = fs::remove_file(&path);
174 println!(" ✓ Removed ~/.lean-ctx/{f}");
175 removed = true;
176 }
177 }
178
179 if !removed && !shell.is_empty() {
180 println!(" · No shell hook found");
181 }
182
183 removed
184}
185
186fn remove_source_lines(content: &str) -> String {
187 content
188 .lines()
189 .filter(|line| !line.contains(".lean-ctx/shell-hook."))
190 .collect::<Vec<_>>()
191 .join("\n")
192 + "\n"
193}
194
195fn remove_mcp_configs(home: &Path) -> bool {
196 let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR")
197 .ok()
198 .map(|d| PathBuf::from(d).join(".claude.json"))
199 .unwrap_or_else(|| PathBuf::from("/nonexistent"));
200 let configs: Vec<(&str, PathBuf)> = vec![
201 ("Cursor", home.join(".cursor/mcp.json")),
202 ("Claude Code (config dir)", claude_cfg_dir_json),
203 ("Claude Code (home)", home.join(".claude.json")),
204 ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
205 ("Gemini CLI", home.join(".gemini/settings.json")),
206 (
207 "Gemini CLI (legacy)",
208 home.join(".gemini/settings/mcp.json"),
209 ),
210 (
211 "Antigravity",
212 home.join(".gemini/antigravity/mcp_config.json"),
213 ),
214 ("Codex CLI", home.join(".codex/config.toml")),
215 ("OpenCode", home.join(".config/opencode/opencode.json")),
216 ("Qwen Code", home.join(".qwen/mcp.json")),
217 ("Trae", home.join(".trae/mcp.json")),
218 ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
219 ("JetBrains IDEs", home.join(".jb-mcp.json")),
220 ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
221 ("Verdent", home.join(".verdent/mcp.json")),
222 ("Aider", home.join(".aider/mcp.json")),
223 ("Amp", home.join(".config/amp/settings.json")),
224 ("Crush", home.join(".config/crush/crush.json")),
225 ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
226 ("Cline", crate::core::editor_registry::cline_mcp_path()),
227 ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
228 ("Hermes Agent", home.join(".hermes/config.yaml")),
229 ];
230
231 let mut removed = false;
232
233 for (name, path) in &configs {
234 if !path.exists() {
235 continue;
236 }
237 let content = match fs::read_to_string(path) {
238 Ok(c) => c,
239 Err(_) => continue,
240 };
241 if !content.contains("lean-ctx") {
242 continue;
243 }
244
245 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
246 let is_yaml = ext == "yaml" || ext == "yml";
247 let is_toml = ext == "toml";
248
249 let cleaned = if is_yaml {
250 Some(remove_lean_ctx_from_yaml(&content))
251 } else if is_toml {
252 Some(remove_lean_ctx_from_toml(&content))
253 } else {
254 remove_lean_ctx_from_json(&content)
255 };
256
257 if let Some(cleaned) = cleaned {
258 if let Err(e) = fs::write(path, &cleaned) {
259 eprintln!(" ✗ Failed to update {} config: {}", name, e);
260 } else {
261 println!(" ✓ MCP config removed from {name}");
262 removed = true;
263 }
264 }
265 }
266
267 let zed_path = crate::core::editor_registry::zed_settings_path(home);
268 if zed_path.exists() {
269 if let Ok(content) = fs::read_to_string(&zed_path) {
270 if content.contains("lean-ctx") {
271 println!(
272 " ⚠ Zed: manually remove lean-ctx from {}",
273 shorten(&zed_path, home)
274 );
275 }
276 }
277 }
278
279 let vscode_path = crate::core::editor_registry::vscode_mcp_path();
280 if vscode_path.exists() {
281 if let Ok(content) = fs::read_to_string(&vscode_path) {
282 if content.contains("lean-ctx") {
283 if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
284 if let Err(e) = fs::write(&vscode_path, &cleaned) {
285 eprintln!(" ✗ Failed to update VS Code config: {e}");
286 } else {
287 println!(" ✓ MCP config removed from VS Code / Copilot");
288 removed = true;
289 }
290 }
291 }
292 }
293 }
294
295 removed
296}
297
298fn remove_rules_files(home: &Path) -> bool {
299 let rules_files: Vec<(&str, PathBuf)> = vec![
300 (
301 "Claude Code",
302 crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
303 ),
304 (
306 "Claude Code (legacy)",
307 crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
308 ),
309 ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
311 ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
312 ("Gemini CLI", home.join(".gemini/GEMINI.md")),
313 (
314 "Gemini CLI (legacy)",
315 home.join(".gemini/rules/lean-ctx.md"),
316 ),
317 ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
318 ("Codex CLI", home.join(".codex/instructions.md")),
319 ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
320 ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
321 ("Cline", home.join(".cline/rules/lean-ctx.md")),
322 ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
323 ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
324 ("Continue", home.join(".continue/rules/lean-ctx.md")),
325 ("Aider", home.join(".aider/rules/lean-ctx.md")),
326 ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
327 ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
328 ("Trae", home.join(".trae/rules/lean-ctx.md")),
329 (
330 "Amazon Q Developer",
331 home.join(".aws/amazonq/rules/lean-ctx.md"),
332 ),
333 ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
334 (
335 "Antigravity",
336 home.join(".gemini/antigravity/rules/lean-ctx.md"),
337 ),
338 ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
339 ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
340 ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
341 ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
342 ];
343
344 let mut removed = false;
345 for (name, path) in &rules_files {
346 if !path.exists() {
347 continue;
348 }
349 if let Ok(content) = fs::read_to_string(path) {
350 if content.contains("lean-ctx") {
351 if let Err(e) = fs::remove_file(path) {
352 eprintln!(" ✗ Failed to remove {name} rules: {e}");
353 } else {
354 println!(" ✓ Rules removed from {name}");
355 removed = true;
356 }
357 }
358 }
359 }
360
361 let hermes_md = home.join(".hermes/HERMES.md");
362 if hermes_md.exists() {
363 if let Ok(content) = fs::read_to_string(&hermes_md) {
364 if content.contains("lean-ctx") {
365 let cleaned = remove_lean_ctx_block_from_md(&content);
366 if cleaned.trim().is_empty() {
367 let _ = fs::remove_file(&hermes_md);
368 } else {
369 let _ = fs::write(&hermes_md, &cleaned);
370 }
371 println!(" ✓ Rules removed from Hermes Agent");
372 removed = true;
373 }
374 }
375 }
376
377 if let Ok(cwd) = std::env::current_dir() {
378 let project_hermes = cwd.join(".hermes.md");
379 if project_hermes.exists() {
380 if let Ok(content) = fs::read_to_string(&project_hermes) {
381 if content.contains("lean-ctx") {
382 let cleaned = remove_lean_ctx_block_from_md(&content);
383 if cleaned.trim().is_empty() {
384 let _ = fs::remove_file(&project_hermes);
385 } else {
386 let _ = fs::write(&project_hermes, &cleaned);
387 }
388 println!(" ✓ Rules removed from .hermes.md");
389 removed = true;
390 }
391 }
392 }
393 }
394
395 if !removed {
396 println!(" · No rules files found");
397 }
398 removed
399}
400
401fn remove_lean_ctx_block_from_md(content: &str) -> String {
402 let mut out = String::with_capacity(content.len());
403 let mut in_block = false;
404
405 for line in content.lines() {
406 if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
407 in_block = true;
408 continue;
409 }
410 if in_block {
411 if line.starts_with('#') && !line.contains("lean-ctx") {
412 in_block = false;
413 out.push_str(line);
414 out.push('\n');
415 }
416 continue;
417 }
418 out.push_str(line);
419 out.push('\n');
420 }
421
422 while out.starts_with('\n') {
423 out.remove(0);
424 }
425 while out.ends_with("\n\n") {
426 out.pop();
427 }
428 out
429}
430
431fn remove_hook_files(home: &Path) -> bool {
432 let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
433 let hook_files: Vec<PathBuf> = vec![
434 claude_hooks_dir.join("lean-ctx-rewrite.sh"),
435 claude_hooks_dir.join("lean-ctx-redirect.sh"),
436 claude_hooks_dir.join("lean-ctx-rewrite-native"),
437 claude_hooks_dir.join("lean-ctx-redirect-native"),
438 home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
439 home.join(".cursor/hooks/lean-ctx-redirect.sh"),
440 home.join(".cursor/hooks/lean-ctx-rewrite-native"),
441 home.join(".cursor/hooks/lean-ctx-redirect-native"),
442 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
443 home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
444 home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
445 home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
446 ];
447
448 let mut removed = false;
449 for path in &hook_files {
450 if path.exists() {
451 if let Err(e) = fs::remove_file(path) {
452 eprintln!(" ✗ Failed to remove hook {}: {e}", path.display());
453 } else {
454 removed = true;
455 }
456 }
457 }
458
459 if removed {
460 println!(" ✓ Hook scripts removed");
461 }
462
463 for (label, hj_path) in [
464 ("Cursor", home.join(".cursor/hooks.json")),
465 ("Codex", home.join(".codex/hooks.json")),
466 ] {
467 if hj_path.exists() {
468 if let Ok(content) = fs::read_to_string(&hj_path) {
469 if content.contains("lean-ctx") {
470 if let Err(e) = fs::remove_file(&hj_path) {
471 eprintln!(" ✗ Failed to remove {label} hooks.json: {e}");
472 } else {
473 println!(" ✓ {label} hooks.json removed");
474 removed = true;
475 }
476 }
477 }
478 }
479 }
480
481 removed
482}
483
484fn remove_data_dir(home: &Path) -> bool {
485 let data_dir = home.join(".lean-ctx");
486 if !data_dir.exists() {
487 println!(" · No data directory found");
488 return false;
489 }
490
491 match fs::remove_dir_all(&data_dir) {
492 Ok(_) => {
493 println!(" ✓ Data directory removed (~/.lean-ctx/)");
494 true
495 }
496 Err(e) => {
497 eprintln!(" ✗ Failed to remove ~/.lean-ctx/: {e}");
498 false
499 }
500 }
501}
502
503fn print_binary_removal_instructions() {
504 let binary_path = std::env::current_exe()
505 .map(|p| p.display().to_string())
506 .unwrap_or_else(|_| "lean-ctx".to_string());
507
508 println!(" To complete uninstallation, remove the binary:\n");
509
510 if binary_path.contains(".cargo") {
511 println!(" cargo uninstall lean-ctx\n");
512 } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
513 println!(" brew uninstall lean-ctx\n");
514 } else {
515 println!(" rm {binary_path}\n");
516 }
517
518 println!(" Then restart your shell.\n");
519}
520
521fn remove_lean_ctx_block(content: &str) -> String {
522 if content.contains("# lean-ctx shell hook — end") {
523 return remove_lean_ctx_block_by_marker(content);
524 }
525 remove_lean_ctx_block_legacy(content)
526}
527
528fn remove_lean_ctx_block_by_marker(content: &str) -> String {
529 let mut result = String::new();
530 let mut in_block = false;
531
532 for line in content.lines() {
533 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
534 in_block = true;
535 continue;
536 }
537 if in_block {
538 if line.trim() == "# lean-ctx shell hook — end" {
539 in_block = false;
540 }
541 continue;
542 }
543 result.push_str(line);
544 result.push('\n');
545 }
546 result
547}
548
549fn remove_lean_ctx_block_legacy(content: &str) -> String {
550 let mut result = String::new();
551 let mut in_block = false;
552
553 for line in content.lines() {
554 if line.contains("lean-ctx shell hook") {
555 in_block = true;
556 continue;
557 }
558 if in_block {
559 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
560 if line.trim() == "fi" || line.trim() == "end" {
561 in_block = false;
562 }
563 continue;
564 }
565 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
566 in_block = false;
567 result.push_str(line);
568 result.push('\n');
569 }
570 continue;
571 }
572 result.push_str(line);
573 result.push('\n');
574 }
575 result
576}
577
578fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
579 let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
580 let mut modified = false;
581
582 if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
583 modified |= servers.remove("lean-ctx").is_some();
584 }
585
586 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
587 modified |= servers.remove("lean-ctx").is_some();
588 }
589
590 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
591 let before = servers.len();
592 servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
593 modified |= servers.len() < before;
594 }
595
596 if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
597 modified |= mcp.remove("lean-ctx").is_some();
598 }
599
600 if let Some(amp) = parsed
601 .get_mut("amp.mcpServers")
602 .and_then(|s| s.as_object_mut())
603 {
604 modified |= amp.remove("lean-ctx").is_some();
605 }
606
607 if modified {
608 Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
609 } else {
610 None
611 }
612}
613
614fn remove_lean_ctx_from_yaml(content: &str) -> String {
615 let mut out = String::with_capacity(content.len());
616 let mut skip_depth: Option<usize> = None;
617
618 for line in content.lines() {
619 if let Some(depth) = skip_depth {
620 let indent = line.len() - line.trim_start().len();
621 if indent > depth || line.trim().is_empty() {
622 continue;
623 }
624 skip_depth = None;
625 }
626
627 let trimmed = line.trim();
628 if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
629 let indent = line.len() - line.trim_start().len();
630 skip_depth = Some(indent);
631 continue;
632 }
633
634 out.push_str(line);
635 out.push('\n');
636 }
637
638 out
639}
640
641fn remove_lean_ctx_from_toml(content: &str) -> String {
642 let mut out = String::with_capacity(content.len());
643 let mut skip = false;
644
645 for line in content.lines() {
646 let trimmed = line.trim();
647
648 if trimmed.starts_with('[') && trimmed.ends_with(']') {
649 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
650 if section == "mcp_servers.lean-ctx"
651 || section == "mcp_servers.\"lean-ctx\""
652 || section.starts_with("mcp_servers.lean-ctx.")
653 || section.starts_with("mcp_servers.\"lean-ctx\".")
654 {
655 skip = true;
656 continue;
657 }
658 skip = false;
659 }
660
661 if skip {
662 continue;
663 }
664
665 if trimmed.contains("codex_hooks") && trimmed.contains("true") {
666 out.push_str(&line.replace("true", "false"));
667 out.push('\n');
668 continue;
669 }
670
671 out.push_str(line);
672 out.push('\n');
673 }
674
675 let cleaned: String = out
676 .lines()
677 .filter(|l| l.trim() != "[]")
678 .collect::<Vec<_>>()
679 .join("\n");
680 if cleaned.is_empty() {
681 cleaned
682 } else {
683 cleaned + "\n"
684 }
685}
686
687fn shorten(path: &Path, home: &Path) -> String {
688 match path.strip_prefix(home) {
689 Ok(rel) => format!("~/{}", rel.display()),
690 Err(_) => path.display().to_string(),
691 }
692}
693
694#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn remove_toml_mcp_server_section() {
702 let input = "\
703[features]
704codex_hooks = true
705
706[mcp_servers.lean-ctx]
707command = \"/usr/local/bin/lean-ctx\"
708args = []
709
710[mcp_servers.other-tool]
711command = \"/usr/bin/other\"
712";
713 let result = remove_lean_ctx_from_toml(input);
714 assert!(
715 !result.contains("lean-ctx"),
716 "lean-ctx section should be removed"
717 );
718 assert!(
719 result.contains("[mcp_servers.other-tool]"),
720 "other sections should be preserved"
721 );
722 assert!(
723 result.contains("codex_hooks = false"),
724 "codex_hooks should be set to false"
725 );
726 }
727
728 #[test]
729 fn remove_toml_only_lean_ctx() {
730 let input = "\
731[mcp_servers.lean-ctx]
732command = \"lean-ctx\"
733";
734 let result = remove_lean_ctx_from_toml(input);
735 assert!(
736 result.trim().is_empty(),
737 "should produce empty output: {result}"
738 );
739 }
740
741 #[test]
742 fn remove_toml_no_lean_ctx() {
743 let input = "\
744[mcp_servers.other]
745command = \"other\"
746";
747 let result = remove_lean_ctx_from_toml(input);
748 assert!(
749 result.contains("[mcp_servers.other]"),
750 "other content should be preserved"
751 );
752 }
753}