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 cleaned = remove_lean_ctx_block(&content);
148 if cleaned.trim() != content.trim() {
149 let bak = rc.with_extension("lean-ctx.bak");
150 let _ = fs::copy(rc, &bak);
151 if let Err(e) = fs::write(rc, &cleaned) {
152 eprintln!(" ✗ Failed to update {}: {}", rc.display(), e);
153 } else {
154 let short = shorten(rc, home);
155 println!(" ✓ Shell hook removed from {short}");
156 println!(" Backup: {}", shorten(&bak, home));
157 removed = true;
158 }
159 }
160 }
161
162 if !removed && !shell.is_empty() {
163 println!(" · No shell hook found");
164 }
165
166 removed
167}
168
169fn remove_mcp_configs(home: &Path) -> bool {
170 let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR")
171 .ok()
172 .map(|d| PathBuf::from(d).join(".claude.json"))
173 .unwrap_or_else(|| PathBuf::from("/nonexistent"));
174 let configs: Vec<(&str, PathBuf)> = vec![
175 ("Cursor", home.join(".cursor/mcp.json")),
176 ("Claude Code (config dir)", claude_cfg_dir_json),
177 ("Claude Code (home)", home.join(".claude.json")),
178 ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
179 ("Gemini CLI", home.join(".gemini/settings.json")),
180 (
181 "Gemini CLI (legacy)",
182 home.join(".gemini/settings/mcp.json"),
183 ),
184 (
185 "Antigravity",
186 home.join(".gemini/antigravity/mcp_config.json"),
187 ),
188 ("Codex CLI", home.join(".codex/config.toml")),
189 ("OpenCode", home.join(".config/opencode/opencode.json")),
190 ("Qwen Code", home.join(".qwen/mcp.json")),
191 ("Trae", home.join(".trae/mcp.json")),
192 ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
193 ("JetBrains IDEs", home.join(".jb-mcp.json")),
194 ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
195 ("Verdent", home.join(".verdent/mcp.json")),
196 ("Aider", home.join(".aider/mcp.json")),
197 ("Amp", home.join(".config/amp/settings.json")),
198 ("Crush", home.join(".config/crush/crush.json")),
199 ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
200 ("Cline", crate::core::editor_registry::cline_mcp_path()),
201 ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
202 ("Hermes Agent", home.join(".hermes/config.yaml")),
203 ];
204
205 let mut removed = false;
206
207 for (name, path) in &configs {
208 if !path.exists() {
209 continue;
210 }
211 let content = match fs::read_to_string(path) {
212 Ok(c) => c,
213 Err(_) => continue,
214 };
215 if !content.contains("lean-ctx") {
216 continue;
217 }
218
219 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
220 let is_yaml = ext == "yaml" || ext == "yml";
221 let is_toml = ext == "toml";
222
223 let cleaned = if is_yaml {
224 Some(remove_lean_ctx_from_yaml(&content))
225 } else if is_toml {
226 Some(remove_lean_ctx_from_toml(&content))
227 } else {
228 remove_lean_ctx_from_json(&content)
229 };
230
231 if let Some(cleaned) = cleaned {
232 if let Err(e) = fs::write(path, &cleaned) {
233 eprintln!(" ✗ Failed to update {} config: {}", name, e);
234 } else {
235 println!(" ✓ MCP config removed from {name}");
236 removed = true;
237 }
238 }
239 }
240
241 let zed_path = crate::core::editor_registry::zed_settings_path(home);
242 if zed_path.exists() {
243 if let Ok(content) = fs::read_to_string(&zed_path) {
244 if content.contains("lean-ctx") {
245 println!(
246 " ⚠ Zed: manually remove lean-ctx from {}",
247 shorten(&zed_path, home)
248 );
249 }
250 }
251 }
252
253 let vscode_path = crate::core::editor_registry::vscode_mcp_path();
254 if vscode_path.exists() {
255 if let Ok(content) = fs::read_to_string(&vscode_path) {
256 if content.contains("lean-ctx") {
257 if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
258 if let Err(e) = fs::write(&vscode_path, &cleaned) {
259 eprintln!(" ✗ Failed to update VS Code config: {e}");
260 } else {
261 println!(" ✓ MCP config removed from VS Code / Copilot");
262 removed = true;
263 }
264 }
265 }
266 }
267 }
268
269 removed
270}
271
272fn remove_rules_files(home: &Path) -> bool {
273 let rules_files: Vec<(&str, PathBuf)> = vec![
274 (
275 "Claude Code",
276 crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
277 ),
278 (
280 "Claude Code (legacy)",
281 crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
282 ),
283 ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
285 ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
286 ("Gemini CLI", home.join(".gemini/GEMINI.md")),
287 (
288 "Gemini CLI (legacy)",
289 home.join(".gemini/rules/lean-ctx.md"),
290 ),
291 ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
292 ("Codex CLI", home.join(".codex/instructions.md")),
293 ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
294 ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
295 ("Cline", home.join(".cline/rules/lean-ctx.md")),
296 ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
297 ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
298 ("Continue", home.join(".continue/rules/lean-ctx.md")),
299 ("Aider", home.join(".aider/rules/lean-ctx.md")),
300 ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
301 ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
302 ("Trae", home.join(".trae/rules/lean-ctx.md")),
303 (
304 "Amazon Q Developer",
305 home.join(".aws/amazonq/rules/lean-ctx.md"),
306 ),
307 ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
308 (
309 "Antigravity",
310 home.join(".gemini/antigravity/rules/lean-ctx.md"),
311 ),
312 ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
313 ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
314 ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
315 ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
316 ];
317
318 let mut removed = false;
319 for (name, path) in &rules_files {
320 if !path.exists() {
321 continue;
322 }
323 if let Ok(content) = fs::read_to_string(path) {
324 if content.contains("lean-ctx") {
325 if let Err(e) = fs::remove_file(path) {
326 eprintln!(" ✗ Failed to remove {name} rules: {e}");
327 } else {
328 println!(" ✓ Rules removed from {name}");
329 removed = true;
330 }
331 }
332 }
333 }
334
335 let hermes_md = home.join(".hermes/HERMES.md");
336 if hermes_md.exists() {
337 if let Ok(content) = fs::read_to_string(&hermes_md) {
338 if content.contains("lean-ctx") {
339 let cleaned = remove_lean_ctx_block_from_md(&content);
340 if cleaned.trim().is_empty() {
341 let _ = fs::remove_file(&hermes_md);
342 } else {
343 let _ = fs::write(&hermes_md, &cleaned);
344 }
345 println!(" ✓ Rules removed from Hermes Agent");
346 removed = true;
347 }
348 }
349 }
350
351 if let Ok(cwd) = std::env::current_dir() {
352 let project_hermes = cwd.join(".hermes.md");
353 if project_hermes.exists() {
354 if let Ok(content) = fs::read_to_string(&project_hermes) {
355 if content.contains("lean-ctx") {
356 let cleaned = remove_lean_ctx_block_from_md(&content);
357 if cleaned.trim().is_empty() {
358 let _ = fs::remove_file(&project_hermes);
359 } else {
360 let _ = fs::write(&project_hermes, &cleaned);
361 }
362 println!(" ✓ Rules removed from .hermes.md");
363 removed = true;
364 }
365 }
366 }
367 }
368
369 if !removed {
370 println!(" · No rules files found");
371 }
372 removed
373}
374
375fn remove_lean_ctx_block_from_md(content: &str) -> String {
376 let mut out = String::with_capacity(content.len());
377 let mut in_block = false;
378
379 for line in content.lines() {
380 if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
381 in_block = true;
382 continue;
383 }
384 if in_block {
385 if line.starts_with('#') && !line.contains("lean-ctx") {
386 in_block = false;
387 out.push_str(line);
388 out.push('\n');
389 }
390 continue;
391 }
392 out.push_str(line);
393 out.push('\n');
394 }
395
396 while out.starts_with('\n') {
397 out.remove(0);
398 }
399 while out.ends_with("\n\n") {
400 out.pop();
401 }
402 out
403}
404
405fn remove_hook_files(home: &Path) -> bool {
406 let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
407 let hook_files: Vec<PathBuf> = vec![
408 claude_hooks_dir.join("lean-ctx-rewrite.sh"),
409 claude_hooks_dir.join("lean-ctx-redirect.sh"),
410 claude_hooks_dir.join("lean-ctx-rewrite-native"),
411 claude_hooks_dir.join("lean-ctx-redirect-native"),
412 home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
413 home.join(".cursor/hooks/lean-ctx-redirect.sh"),
414 home.join(".cursor/hooks/lean-ctx-rewrite-native"),
415 home.join(".cursor/hooks/lean-ctx-redirect-native"),
416 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
417 home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
418 home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
419 home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
420 ];
421
422 let mut removed = false;
423 for path in &hook_files {
424 if path.exists() {
425 if let Err(e) = fs::remove_file(path) {
426 eprintln!(" ✗ Failed to remove hook {}: {e}", path.display());
427 } else {
428 removed = true;
429 }
430 }
431 }
432
433 if removed {
434 println!(" ✓ Hook scripts removed");
435 }
436
437 for (label, hj_path) in [
438 ("Cursor", home.join(".cursor/hooks.json")),
439 ("Codex", home.join(".codex/hooks.json")),
440 ] {
441 if hj_path.exists() {
442 if let Ok(content) = fs::read_to_string(&hj_path) {
443 if content.contains("lean-ctx") {
444 if let Err(e) = fs::remove_file(&hj_path) {
445 eprintln!(" ✗ Failed to remove {label} hooks.json: {e}");
446 } else {
447 println!(" ✓ {label} hooks.json removed");
448 removed = true;
449 }
450 }
451 }
452 }
453 }
454
455 removed
456}
457
458fn remove_data_dir(home: &Path) -> bool {
459 let data_dir = home.join(".lean-ctx");
460 if !data_dir.exists() {
461 println!(" · No data directory found");
462 return false;
463 }
464
465 match fs::remove_dir_all(&data_dir) {
466 Ok(_) => {
467 println!(" ✓ Data directory removed (~/.lean-ctx/)");
468 true
469 }
470 Err(e) => {
471 eprintln!(" ✗ Failed to remove ~/.lean-ctx/: {e}");
472 false
473 }
474 }
475}
476
477fn print_binary_removal_instructions() {
478 let binary_path = std::env::current_exe()
479 .map(|p| p.display().to_string())
480 .unwrap_or_else(|_| "lean-ctx".to_string());
481
482 println!(" To complete uninstallation, remove the binary:\n");
483
484 if binary_path.contains(".cargo") {
485 println!(" cargo uninstall lean-ctx\n");
486 } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
487 println!(" brew uninstall lean-ctx\n");
488 } else {
489 println!(" rm {binary_path}\n");
490 }
491
492 println!(" Then restart your shell.\n");
493}
494
495fn remove_lean_ctx_block(content: &str) -> String {
496 if content.contains("# lean-ctx shell hook — end") {
497 return remove_lean_ctx_block_by_marker(content);
498 }
499 remove_lean_ctx_block_legacy(content)
500}
501
502fn remove_lean_ctx_block_by_marker(content: &str) -> String {
503 let mut result = String::new();
504 let mut in_block = false;
505
506 for line in content.lines() {
507 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
508 in_block = true;
509 continue;
510 }
511 if in_block {
512 if line.trim() == "# lean-ctx shell hook — end" {
513 in_block = false;
514 }
515 continue;
516 }
517 result.push_str(line);
518 result.push('\n');
519 }
520 result
521}
522
523fn remove_lean_ctx_block_legacy(content: &str) -> String {
524 let mut result = String::new();
525 let mut in_block = false;
526
527 for line in content.lines() {
528 if line.contains("lean-ctx shell hook") {
529 in_block = true;
530 continue;
531 }
532 if in_block {
533 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
534 if line.trim() == "fi" || line.trim() == "end" {
535 in_block = false;
536 }
537 continue;
538 }
539 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
540 in_block = false;
541 result.push_str(line);
542 result.push('\n');
543 }
544 continue;
545 }
546 result.push_str(line);
547 result.push('\n');
548 }
549 result
550}
551
552fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
553 let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
554 let mut modified = false;
555
556 if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
557 modified |= servers.remove("lean-ctx").is_some();
558 }
559
560 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
561 modified |= servers.remove("lean-ctx").is_some();
562 }
563
564 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
565 let before = servers.len();
566 servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
567 modified |= servers.len() < before;
568 }
569
570 if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
571 modified |= mcp.remove("lean-ctx").is_some();
572 }
573
574 if let Some(amp) = parsed
575 .get_mut("amp.mcpServers")
576 .and_then(|s| s.as_object_mut())
577 {
578 modified |= amp.remove("lean-ctx").is_some();
579 }
580
581 if modified {
582 Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
583 } else {
584 None
585 }
586}
587
588fn remove_lean_ctx_from_yaml(content: &str) -> String {
589 let mut out = String::with_capacity(content.len());
590 let mut skip_depth: Option<usize> = None;
591
592 for line in content.lines() {
593 if let Some(depth) = skip_depth {
594 let indent = line.len() - line.trim_start().len();
595 if indent > depth || line.trim().is_empty() {
596 continue;
597 }
598 skip_depth = None;
599 }
600
601 let trimmed = line.trim();
602 if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
603 let indent = line.len() - line.trim_start().len();
604 skip_depth = Some(indent);
605 continue;
606 }
607
608 out.push_str(line);
609 out.push('\n');
610 }
611
612 out
613}
614
615fn remove_lean_ctx_from_toml(content: &str) -> String {
616 let mut out = String::with_capacity(content.len());
617 let mut skip = false;
618
619 for line in content.lines() {
620 let trimmed = line.trim();
621
622 if trimmed.starts_with('[') && trimmed.ends_with(']') {
623 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
624 if section == "mcp_servers.lean-ctx"
625 || section == "mcp_servers.\"lean-ctx\""
626 || section.starts_with("mcp_servers.lean-ctx.")
627 || section.starts_with("mcp_servers.\"lean-ctx\".")
628 {
629 skip = true;
630 continue;
631 }
632 skip = false;
633 }
634
635 if skip {
636 continue;
637 }
638
639 if trimmed.contains("codex_hooks") && trimmed.contains("true") {
640 out.push_str(&line.replace("true", "false"));
641 out.push('\n');
642 continue;
643 }
644
645 out.push_str(line);
646 out.push('\n');
647 }
648
649 let cleaned: String = out
650 .lines()
651 .filter(|l| l.trim() != "[]")
652 .collect::<Vec<_>>()
653 .join("\n");
654 if cleaned.is_empty() {
655 cleaned
656 } else {
657 cleaned + "\n"
658 }
659}
660
661fn shorten(path: &Path, home: &Path) -> String {
662 match path.strip_prefix(home) {
663 Ok(rel) => format!("~/{}", rel.display()),
664 Err(_) => path.display().to_string(),
665 }
666}
667
668#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn remove_toml_mcp_server_section() {
676 let input = "\
677[features]
678codex_hooks = true
679
680[mcp_servers.lean-ctx]
681command = \"/usr/local/bin/lean-ctx\"
682args = []
683
684[mcp_servers.other-tool]
685command = \"/usr/bin/other\"
686";
687 let result = remove_lean_ctx_from_toml(input);
688 assert!(
689 !result.contains("lean-ctx"),
690 "lean-ctx section should be removed"
691 );
692 assert!(
693 result.contains("[mcp_servers.other-tool]"),
694 "other sections should be preserved"
695 );
696 assert!(
697 result.contains("codex_hooks = false"),
698 "codex_hooks should be set to false"
699 );
700 }
701
702 #[test]
703 fn remove_toml_only_lean_ctx() {
704 let input = "\
705[mcp_servers.lean-ctx]
706command = \"lean-ctx\"
707";
708 let result = remove_lean_ctx_from_toml(input);
709 assert!(
710 result.trim().is_empty(),
711 "should produce empty output: {result}"
712 );
713 }
714
715 #[test]
716 fn remove_toml_no_lean_ctx() {
717 let input = "\
718[mcp_servers.other]
719command = \"other\"
720";
721 let result = remove_lean_ctx_from_toml(input);
722 assert!(
723 result.contains("[mcp_servers.other]"),
724 "other content should be preserved"
725 );
726 }
727}