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