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 removed_any |= remove_mcp_configs(&home);
19 removed_any |= remove_rules_files(&home);
20 removed_any |= remove_hook_files(&home);
21 removed_any |= remove_project_agent_files();
22 removed_any |= remove_data_dir(&home);
23
24 println!();
25
26 if removed_any {
27 println!(" ──────────────────────────────────");
28 println!(" lean-ctx configuration removed.\n");
29 } else {
30 println!(" Nothing to remove — lean-ctx was not configured.\n");
31 }
32
33 print_binary_removal_instructions();
34}
35
36fn remove_project_agent_files() -> bool {
37 let cwd = std::env::current_dir().unwrap_or_default();
38 let agents = cwd.join("AGENTS.md");
39 let lean_ctx_md = cwd.join("LEAN-CTX.md");
40
41 const START: &str = "<!-- lean-ctx -->";
42 const END: &str = "<!-- /lean-ctx -->";
43 const OWNED: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
44
45 let mut removed = false;
46
47 if agents.exists() {
48 if let Ok(content) = fs::read_to_string(&agents) {
49 if content.contains(START) {
50 let cleaned = remove_marked_block(&content, START, END);
51 if cleaned != content {
52 if let Err(e) = fs::write(&agents, cleaned) {
53 eprintln!(" ✗ Failed to update project AGENTS.md: {e}");
54 } else {
55 println!(" ✓ Project: removed lean-ctx block from AGENTS.md");
56 removed = true;
57 }
58 }
59 }
60 }
61 }
62
63 if lean_ctx_md.exists() {
64 if let Ok(content) = fs::read_to_string(&lean_ctx_md) {
65 if content.contains(OWNED) {
66 if let Err(e) = fs::remove_file(&lean_ctx_md) {
67 eprintln!(" ✗ Failed to remove project LEAN-CTX.md: {e}");
68 } else {
69 println!(" ✓ Project: removed LEAN-CTX.md");
70 removed = true;
71 }
72 }
73 }
74 }
75
76 let project_files = [
77 ".windsurfrules",
78 ".clinerules",
79 ".cursorrules",
80 ".kiro/steering/lean-ctx.md",
81 ".cursor/rules/lean-ctx.mdc",
82 ];
83 for rel in &project_files {
84 let path = cwd.join(rel);
85 if path.exists() {
86 if let Ok(content) = fs::read_to_string(&path) {
87 if content.contains("lean-ctx") {
88 let _ = fs::remove_file(&path);
89 println!(" ✓ Project: removed {rel}");
90 removed = true;
91 }
92 }
93 }
94 }
95
96 removed
97}
98
99fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
100 let s = content.find(start);
101 let e = content.find(end);
102 match (s, e) {
103 (Some(si), Some(ei)) if ei >= si => {
104 let after_end = ei + end.len();
105 let before = &content[..si];
106 let after = &content[after_end..];
107 let mut out = String::new();
108 out.push_str(before.trim_end_matches('\n'));
109 out.push('\n');
110 if !after.trim().is_empty() {
111 out.push('\n');
112 out.push_str(after.trim_start_matches('\n'));
113 }
114 out
115 }
116 _ => content.to_string(),
117 }
118}
119
120fn remove_shell_hook(home: &Path) -> bool {
121 let shell = std::env::var("SHELL").unwrap_or_default();
122 let mut removed = false;
123
124 let rc_files: Vec<PathBuf> = vec![
125 home.join(".zshrc"),
126 home.join(".bashrc"),
127 home.join(".config/fish/config.fish"),
128 #[cfg(windows)]
129 home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
130 ];
131
132 for rc in &rc_files {
133 if !rc.exists() {
134 continue;
135 }
136 let content = match fs::read_to_string(rc) {
137 Ok(c) => c,
138 Err(_) => continue,
139 };
140 if !content.contains("lean-ctx") {
141 continue;
142 }
143
144 let cleaned = remove_lean_ctx_block(&content);
145 if cleaned.trim() != content.trim() {
146 let bak = rc.with_extension("lean-ctx.bak");
147 let _ = fs::copy(rc, &bak);
148 if let Err(e) = fs::write(rc, &cleaned) {
149 eprintln!(" ✗ Failed to update {}: {}", rc.display(), e);
150 } else {
151 let short = shorten(rc, home);
152 println!(" ✓ Shell hook removed from {short}");
153 println!(" Backup: {}", shorten(&bak, home));
154 removed = true;
155 }
156 }
157 }
158
159 if !removed && !shell.is_empty() {
160 println!(" · No shell hook found");
161 }
162
163 removed
164}
165
166fn remove_mcp_configs(home: &Path) -> bool {
167 let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR")
168 .ok()
169 .map(|d| PathBuf::from(d).join(".claude.json"))
170 .unwrap_or_else(|| PathBuf::from("/nonexistent"));
171 let configs: Vec<(&str, PathBuf)> = vec![
172 ("Cursor", home.join(".cursor/mcp.json")),
173 ("Claude Code (config dir)", claude_cfg_dir_json),
174 ("Claude Code (home)", home.join(".claude.json")),
175 ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
176 ("Gemini CLI", home.join(".gemini/settings/mcp.json")),
177 (
178 "Antigravity",
179 home.join(".gemini/antigravity/mcp_config.json"),
180 ),
181 ("Codex CLI", home.join(".codex/config.toml")),
182 ("OpenCode", home.join(".config/opencode/opencode.json")),
183 ("Qwen Code", home.join(".qwen/mcp.json")),
184 ("Trae", home.join(".trae/mcp.json")),
185 ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
186 ("JetBrains IDEs", home.join(".jb-mcp.json")),
187 ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
188 ("Verdent", home.join(".verdent/mcp.json")),
189 ("Aider", home.join(".aider/mcp.json")),
190 ("Amp", home.join(".config/amp/settings.json")),
191 ("Crush", home.join(".config/crush/crush.json")),
192 ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
193 ("Cline", crate::core::editor_registry::cline_mcp_path()),
194 ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
195 ];
196
197 let mut removed = false;
198
199 for (name, path) in &configs {
200 if !path.exists() {
201 continue;
202 }
203 let content = match fs::read_to_string(path) {
204 Ok(c) => c,
205 Err(_) => continue,
206 };
207 if !content.contains("lean-ctx") {
208 continue;
209 }
210
211 if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
212 if let Err(e) = fs::write(path, &cleaned) {
213 eprintln!(" ✗ Failed to update {} config: {}", name, e);
214 } else {
215 println!(" ✓ MCP config removed from {name}");
216 removed = true;
217 }
218 }
219 }
220
221 let zed_path = crate::core::editor_registry::zed_settings_path(home);
222 if zed_path.exists() {
223 if let Ok(content) = fs::read_to_string(&zed_path) {
224 if content.contains("lean-ctx") {
225 println!(
226 " ⚠ Zed: manually remove lean-ctx from {}",
227 shorten(&zed_path, home)
228 );
229 }
230 }
231 }
232
233 let vscode_path = crate::core::editor_registry::vscode_mcp_path();
234 if vscode_path.exists() {
235 if let Ok(content) = fs::read_to_string(&vscode_path) {
236 if content.contains("lean-ctx") {
237 if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
238 if let Err(e) = fs::write(&vscode_path, &cleaned) {
239 eprintln!(" ✗ Failed to update VS Code config: {e}");
240 } else {
241 println!(" ✓ MCP config removed from VS Code / Copilot");
242 removed = true;
243 }
244 }
245 }
246 }
247 }
248
249 removed
250}
251
252fn remove_rules_files(home: &Path) -> bool {
253 let rules_files: Vec<(&str, PathBuf)> = vec![
254 (
255 "Claude Code",
256 crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
257 ),
258 (
260 "Claude Code (legacy)",
261 crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
262 ),
263 ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
265 ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
266 ("Gemini CLI", home.join(".gemini/GEMINI.md")),
267 (
268 "Gemini CLI (legacy)",
269 home.join(".gemini/rules/lean-ctx.md"),
270 ),
271 ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
272 ("Codex CLI", home.join(".codex/instructions.md")),
273 ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
274 ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
275 ("Cline", home.join(".cline/rules/lean-ctx.md")),
276 ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
277 ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
278 ("Continue", home.join(".continue/rules/lean-ctx.md")),
279 ("Aider", home.join(".aider/rules/lean-ctx.md")),
280 ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
281 ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
282 ("Trae", home.join(".trae/rules/lean-ctx.md")),
283 (
284 "Amazon Q Developer",
285 home.join(".aws/amazonq/rules/lean-ctx.md"),
286 ),
287 ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
288 (
289 "Antigravity",
290 home.join(".gemini/antigravity/rules/lean-ctx.md"),
291 ),
292 ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
293 ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
294 ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
295 ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
296 ];
297
298 let mut removed = false;
299 for (name, path) in &rules_files {
300 if !path.exists() {
301 continue;
302 }
303 if let Ok(content) = fs::read_to_string(path) {
304 if content.contains("lean-ctx") {
305 if let Err(e) = fs::remove_file(path) {
306 eprintln!(" ✗ Failed to remove {name} rules: {e}");
307 } else {
308 println!(" ✓ Rules removed from {name}");
309 removed = true;
310 }
311 }
312 }
313 }
314
315 if !removed {
316 println!(" · No rules files found");
317 }
318 removed
319}
320
321fn remove_hook_files(home: &Path) -> bool {
322 let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
323 let hook_files: Vec<PathBuf> = vec![
324 claude_hooks_dir.join("lean-ctx-rewrite.sh"),
325 claude_hooks_dir.join("lean-ctx-redirect.sh"),
326 claude_hooks_dir.join("lean-ctx-rewrite-native"),
327 claude_hooks_dir.join("lean-ctx-redirect-native"),
328 home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
329 home.join(".cursor/hooks/lean-ctx-redirect.sh"),
330 home.join(".cursor/hooks/lean-ctx-rewrite-native"),
331 home.join(".cursor/hooks/lean-ctx-redirect-native"),
332 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
333 home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
334 home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
335 home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
336 ];
337
338 let mut removed = false;
339 for path in &hook_files {
340 if path.exists() {
341 if let Err(e) = fs::remove_file(path) {
342 eprintln!(" ✗ Failed to remove hook {}: {e}", path.display());
343 } else {
344 removed = true;
345 }
346 }
347 }
348
349 if removed {
350 println!(" ✓ Hook scripts removed");
351 }
352
353 let hooks_json = home.join(".cursor/hooks.json");
354 if hooks_json.exists() {
355 if let Ok(content) = fs::read_to_string(&hooks_json) {
356 if content.contains("lean-ctx") {
357 if let Err(e) = fs::remove_file(&hooks_json) {
358 eprintln!(" ✗ Failed to remove Cursor hooks.json: {e}");
359 } else {
360 println!(" ✓ Cursor hooks.json removed");
361 removed = true;
362 }
363 }
364 }
365 }
366
367 removed
368}
369
370fn remove_data_dir(home: &Path) -> bool {
371 let data_dir = home.join(".lean-ctx");
372 if !data_dir.exists() {
373 println!(" · No data directory found");
374 return false;
375 }
376
377 match fs::remove_dir_all(&data_dir) {
378 Ok(_) => {
379 println!(" ✓ Data directory removed (~/.lean-ctx/)");
380 true
381 }
382 Err(e) => {
383 eprintln!(" ✗ Failed to remove ~/.lean-ctx/: {e}");
384 false
385 }
386 }
387}
388
389fn print_binary_removal_instructions() {
390 let binary_path = std::env::current_exe()
391 .map(|p| p.display().to_string())
392 .unwrap_or_else(|_| "lean-ctx".to_string());
393
394 println!(" To complete uninstallation, remove the binary:\n");
395
396 if binary_path.contains(".cargo") {
397 println!(" cargo uninstall lean-ctx\n");
398 } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
399 println!(" brew uninstall lean-ctx\n");
400 } else {
401 println!(" rm {binary_path}\n");
402 }
403
404 println!(" Then restart your shell.\n");
405}
406
407fn remove_lean_ctx_block(content: &str) -> String {
408 if content.contains("# lean-ctx shell hook — end") {
409 return remove_lean_ctx_block_by_marker(content);
410 }
411 remove_lean_ctx_block_legacy(content)
412}
413
414fn remove_lean_ctx_block_by_marker(content: &str) -> String {
415 let mut result = String::new();
416 let mut in_block = false;
417
418 for line in content.lines() {
419 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
420 in_block = true;
421 continue;
422 }
423 if in_block {
424 if line.trim() == "# lean-ctx shell hook — end" {
425 in_block = false;
426 }
427 continue;
428 }
429 result.push_str(line);
430 result.push('\n');
431 }
432 result
433}
434
435fn remove_lean_ctx_block_legacy(content: &str) -> String {
436 let mut result = String::new();
437 let mut in_block = false;
438
439 for line in content.lines() {
440 if line.contains("lean-ctx shell hook") {
441 in_block = true;
442 continue;
443 }
444 if in_block {
445 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
446 if line.trim() == "fi" || line.trim() == "end" {
447 in_block = false;
448 }
449 continue;
450 }
451 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
452 in_block = false;
453 result.push_str(line);
454 result.push('\n');
455 }
456 continue;
457 }
458 result.push_str(line);
459 result.push('\n');
460 }
461 result
462}
463
464fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
465 let mut parsed: serde_json::Value = serde_json::from_str(content).ok()?;
466 let mut modified = false;
467
468 if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
469 modified |= servers.remove("lean-ctx").is_some();
470 }
471
472 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
473 modified |= servers.remove("lean-ctx").is_some();
474 }
475
476 if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
477 let before = servers.len();
478 servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
479 modified |= servers.len() < before;
480 }
481
482 if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
483 modified |= mcp.remove("lean-ctx").is_some();
484 }
485
486 if let Some(amp) = parsed
487 .get_mut("amp.mcpServers")
488 .and_then(|s| s.as_object_mut())
489 {
490 modified |= amp.remove("lean-ctx").is_some();
491 }
492
493 if modified {
494 Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
495 } else {
496 None
497 }
498}
499
500fn shorten(path: &Path, home: &Path) -> String {
501 match path.strip_prefix(home) {
502 Ok(rel) => format!("~/{}", rel.display()),
503 Err(_) => path.display().to_string(),
504 }
505}
506
507