lean_ctx/uninstall/
mod.rs1mod agents;
2mod parsers;
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use agents::{
8 remove_hook_files, remove_mcp_configs, remove_project_agent_files, remove_rules_files,
9 remove_shell_hook,
10};
11
12pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
13 if dry_run {
14 return;
15 }
16 if path.exists() {
17 let bak = bak_path_for(path);
18 let _ = fs::copy(path, &bak);
19 }
20}
21
22pub(super) fn bak_path_for(path: &Path) -> PathBuf {
23 let filename = path.file_name().unwrap_or_default().to_string_lossy();
24 path.with_file_name(format!("{filename}.lean-ctx.bak"))
25}
26
27fn cleanup_bak(path: &Path) {
28 let bak = bak_path_for(path);
29 if bak.exists() {
30 let _ = fs::remove_file(&bak);
31 }
32}
33
34pub(super) fn shorten(path: &Path, home: &Path) -> String {
35 match path.strip_prefix(home) {
36 Ok(rel) => format!("~/{}", rel.display()),
37 Err(_) => path.display().to_string(),
38 }
39}
40
41pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
42 #[cfg(target_os = "macos")]
43 {
44 return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
45 }
46 #[cfg(target_os = "linux")]
47 {
48 return home.join(".config/Code/User/github-copilot-instructions.md");
49 }
50 #[cfg(target_os = "windows")]
51 {
52 if let Ok(appdata) = std::env::var("APPDATA") {
53 return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
54 }
55 }
56 #[allow(unreachable_code)]
57 home.join(".config/Code/User/github-copilot-instructions.md")
58}
59
60pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
62 if dry_run {
63 return Ok(());
64 }
65 fs::write(path, content)?;
66 cleanup_bak(path);
68 Ok(())
69}
70
71pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
73 if dry_run {
74 return Ok(());
75 }
76 fs::remove_file(path)?;
77 cleanup_bak(path);
79 Ok(())
80}
81
82pub fn run(dry_run: bool) {
87 let Some(home) = dirs::home_dir() else {
88 tracing::warn!("Could not determine home directory");
89 return;
90 };
91
92 if dry_run {
93 println!("\n lean-ctx uninstall --dry-run\n ──────────────────────────────────\n");
94 println!(" Preview mode — no files will be modified.\n");
95 } else {
96 println!("\n lean-ctx uninstall\n ──────────────────────────────────\n");
97 }
98
99 let mut removed_any = false;
100
101 removed_any |= remove_shell_hook(&home, dry_run);
102 if dry_run {
103 crate::proxy_setup::preview_proxy_cleanup(&home);
104 } else {
105 crate::proxy_setup::uninstall_proxy_env(&home, false);
106 }
107 removed_any |= remove_mcp_configs(&home, dry_run);
108 removed_any |= remove_rules_files(&home, dry_run);
109 removed_any |= remove_hook_files(&home, dry_run);
110 removed_any |= remove_project_agent_files(dry_run);
111
112 if dry_run {
113 println!(" Would remove proxy autostart (LaunchAgent/systemd)");
114 } else {
115 crate::proxy_autostart::uninstall(true);
116 }
117
118 if !dry_run {
119 cleanup_bak_files(&home);
120 }
121
122 removed_any |= remove_data_dir(&home, dry_run);
123
124 println!();
125
126 if removed_any {
127 println!(" ──────────────────────────────────");
128 if dry_run {
129 println!(
130 " The above changes WOULD be applied.\n Run `lean-ctx uninstall` to execute.\n"
131 );
132 } else {
133 println!(" lean-ctx configuration removed.\n");
134 }
135 } else {
136 println!(" Nothing to remove — lean-ctx was not configured.\n");
137 }
138
139 if !dry_run {
140 print_binary_removal_instructions();
141 }
142}
143
144pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
149 let s = content.find(start);
150 let e = content.find(end);
151 match (s, e) {
152 (Some(si), Some(ei)) if ei >= si => {
153 let after_end = ei + end.len();
154 let before = &content[..si];
155 let after = &content[after_end..];
156 let mut out = String::new();
157 out.push_str(before.trim_end_matches('\n'));
158 out.push('\n');
159 if !after.trim().is_empty() {
160 out.push('\n');
161 out.push_str(after.trim_start_matches('\n'));
162 }
163 out
164 }
165 _ => content.to_string(),
166 }
167}
168
169fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
174 let data_dir = home.join(".lean-ctx");
175 if !data_dir.exists() {
176 println!(" · No data directory found");
177 return false;
178 }
179
180 if dry_run {
181 println!(" Would remove Data directory (~/.lean-ctx/)");
182 return true;
183 }
184
185 match fs::remove_dir_all(&data_dir) {
186 Ok(()) => {
187 println!(" ✓ Data directory removed (~/.lean-ctx/)");
188 true
189 }
190 Err(e) => {
191 tracing::warn!("Failed to remove ~/.lean-ctx/: {e}");
192 false
193 }
194 }
195}
196
197fn cleanup_bak_files(home: &Path) {
202 let dirs_to_scan: Vec<PathBuf> = vec![
203 home.join(".cursor"),
204 home.join(".claude"),
205 crate::core::editor_registry::claude_state_dir(home),
206 home.join(".gemini"),
207 home.join(".gemini/antigravity"),
208 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
209 home.join(".codeium"),
210 home.join(".codeium/windsurf"),
211 home.join(".config/opencode"),
212 home.join(".config/amp"),
213 home.join(".config/crush"),
214 home.join(".config/zed"),
215 home.join(".qwen"),
216 home.join(".trae"),
217 home.join(".aws/amazonq"),
218 home.join(".kiro"),
219 home.join(".kiro/settings"),
220 home.join(".ampcoder"),
221 home.join(".pi"),
222 home.join(".pi/agent"),
223 home.join(".hermes"),
224 home.join(".verdent"),
225 home.join(".cline"),
226 home.join(".roo"),
227 home.join(".continue"),
228 home.join(".jb-rules"),
229 ];
230
231 let mut cleaned = 0;
232 for dir in &dirs_to_scan {
233 if !dir.exists() {
234 continue;
235 }
236 if let Ok(entries) = fs::read_dir(dir) {
237 for entry in entries.flatten() {
238 let name = entry.file_name();
239 let name_str = name.to_string_lossy();
240 if name_str.ends_with(".lean-ctx.tmp") {
241 let _ = fs::remove_file(entry.path());
242 cleaned += 1;
243 continue;
244 }
245 if name_str.ends_with(".lean-ctx.bak") {
246 let original_name = name_str.trim_end_matches(".lean-ctx.bak");
247 let original = entry.path().with_file_name(original_name);
248 if original.exists() {
249 match fs::read_to_string(&original) {
251 Ok(c) if !c.contains("lean-ctx") => {
252 let _ = fs::remove_file(entry.path());
253 cleaned += 1;
254 }
255 _ => {}
256 }
257 } else {
258 let _ = fs::remove_file(entry.path());
260 cleaned += 1;
261 }
262 }
263 }
264 }
265 }
266
267 let rc_baks = [
269 home.join(".zshrc.lean-ctx.bak"),
270 home.join(".zshenv.lean-ctx.bak"),
271 home.join(".bashrc.lean-ctx.bak"),
272 home.join(".bashenv.lean-ctx.bak"),
273 ];
274 for bak in &rc_baks {
275 if bak.exists() {
276 let original_name = bak
277 .file_name()
278 .unwrap_or_default()
279 .to_string_lossy()
280 .trim_end_matches(".lean-ctx.bak")
281 .to_string();
282 let original = bak.with_file_name(original_name);
283 if original.exists() {
284 if let Ok(c) = fs::read_to_string(&original) {
285 if !c.contains("lean-ctx") {
286 let _ = fs::remove_file(bak);
287 cleaned += 1;
288 }
289 }
290 } else {
291 let _ = fs::remove_file(bak);
292 cleaned += 1;
293 }
294 }
295 }
296
297 if cleaned > 0 {
298 println!(" ✓ Cleaned up {cleaned} backup file(s)");
299 }
300}
301
302fn print_binary_removal_instructions() {
307 let binary_path = std::env::current_exe()
308 .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
309
310 println!(" To complete uninstallation, remove the binary:\n");
311
312 if binary_path.contains(".cargo") {
313 println!(" cargo uninstall lean-ctx\n");
314 } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
315 println!(" brew uninstall lean-ctx\n");
316 } else {
317 println!(" rm {binary_path}\n");
318 }
319
320 println!(" Then restart your shell.\n");
321}