mod agents;
mod parsers;
use std::fs;
use std::path::{Path, PathBuf};
use agents::{
remove_hook_files, remove_mcp_configs, remove_plan_mode_settings, remove_project_agent_files,
remove_rules_files, remove_shell_hook,
};
pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
if dry_run {
return;
}
if path.exists() {
let bak = bak_path_for(path);
let _ = fs::copy(path, &bak);
}
}
pub fn bak_path_for(path: &Path) -> PathBuf {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
path.with_file_name(format!("{filename}.lean-ctx.bak"))
}
fn cleanup_bak(path: &Path) {
let bak = bak_path_for(path);
if bak.exists() {
let _ = fs::remove_file(&bak);
}
}
pub(super) fn shorten(path: &Path, home: &Path) -> String {
match path.strip_prefix(home) {
Ok(rel) => format!("~/{}", rel.display()),
Err(_) => path.display().to_string(),
}
}
pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
{
return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
}
#[cfg(target_os = "linux")]
{
return home.join(".config/Code/User/github-copilot-instructions.md");
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
}
}
#[allow(unreachable_code)]
home.join(".config/Code/User/github-copilot-instructions.md")
}
pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
if dry_run {
return Ok(());
}
fs::write(path, content)?;
cleanup_bak(path);
Ok(())
}
pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
if dry_run {
return Ok(());
}
fs::remove_file(path)?;
cleanup_bak(path);
Ok(())
}
pub fn run(dry_run: bool, keep_config: bool) {
let Some(home) = dirs::home_dir() else {
tracing::warn!("Could not determine home directory");
return;
};
let mode_label = if keep_config {
"uninstall --keep-config"
} else {
"uninstall"
};
if dry_run {
println!("\n lean-ctx {mode_label} --dry-run\n ──────────────────────────────────\n");
println!(" Preview mode — no files will be modified.\n");
} else {
println!("\n lean-ctx {mode_label}\n ──────────────────────────────────\n");
}
if keep_config {
println!(" Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
}
let mut removed_any = false;
removed_any |= remove_shell_hook(&home, dry_run);
if dry_run {
crate::proxy_setup::preview_proxy_cleanup(&home);
} else {
crate::proxy_setup::uninstall_proxy_env(&home, false);
}
if keep_config {
println!(" · Skipped: MCP configs (--keep-config)");
println!(" · Skipped: Rules files (--keep-config)");
} else {
removed_any |= remove_mcp_configs(&home, dry_run);
removed_any |= remove_rules_files(&home, dry_run);
if !dry_run {
try_claude_mcp_remove();
}
}
removed_any |= remove_hook_files(&home, dry_run);
removed_any |= remove_plan_mode_settings(&home, dry_run);
removed_any |= remove_skill_dirs(&home, dry_run);
removed_any |= remove_project_agent_files(dry_run);
if dry_run {
println!(" Would remove proxy autostart (LaunchAgent/systemd)");
println!(" Would remove daemon autostart (LaunchAgent/systemd)");
} else {
crate::proxy_autostart::uninstall(true);
crate::daemon_autostart::uninstall(true);
}
if !dry_run {
cleanup_bak_files(&home);
}
removed_any |= remove_data_dir(&home, dry_run);
println!();
if removed_any {
println!(" ──────────────────────────────────");
if dry_run {
println!(
" The above changes WOULD be applied.\n Run `lean-ctx {mode_label}` to execute.\n"
);
} else if keep_config {
println!(
" Runtime data removed. MCP configs preserved for reinstall.\n \
Reinstall with: cargo install lean-ctx\n"
);
} else {
println!(" lean-ctx configuration removed.\n");
}
} else {
println!(" Nothing to remove — lean-ctx was not configured.\n");
}
if !dry_run {
print_binary_removal_instructions();
}
}
pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
let s = content.find(start);
let e = content.find(end);
match (s, e) {
(Some(si), Some(ei)) if ei >= si => {
let after_end = ei + end.len();
let before = &content[..si];
let after = &content[after_end..];
let mut out = String::new();
out.push_str(before.trim_end_matches('\n'));
out.push('\n');
if !after.trim().is_empty() {
out.push('\n');
out.push_str(after.trim_start_matches('\n'));
}
out
}
_ => content.to_string(),
}
}
fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
let claude_state = crate::core::editor_registry::claude_state_dir(home);
let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
("Claude Code", claude_state.join("skills/lean-ctx")),
("Cursor", home.join(".cursor/skills/lean-ctx")),
(
"Codex CLI",
crate::core::home::resolve_codex_dir()
.unwrap_or_else(|| home.join(".codex"))
.join("skills/lean-ctx"),
),
("Copilot", home.join(".copilot/skills/lean-ctx")),
("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
];
let default_claude_skill = home.join(".claude/skills/lean-ctx");
if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
skill_dirs.push(("Claude Code (default)", default_claude_skill));
}
let mut removed = false;
for (name, dir) in &skill_dirs {
if !dir.exists() {
continue;
}
if dry_run {
println!(" Would remove {name} skill directory");
removed = true;
} else if let Err(e) = fs::remove_dir_all(dir) {
tracing::warn!("Failed to remove {name} skill dir: {e}");
} else {
println!(" ✓ {name} skill directory removed");
removed = true;
}
}
removed
}
fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
let mut removed = false;
let dirs_to_remove = [home.join(".lean-ctx"), home.join(".config/lean-ctx")];
for data_dir in &dirs_to_remove {
if !data_dir.exists() {
continue;
}
let short = shorten(data_dir, home);
if dry_run {
println!(" Would remove data directory ({short})");
removed = true;
continue;
}
match fs::remove_dir_all(data_dir) {
Ok(()) => {
println!(" ✓ Data directory removed ({short})");
removed = true;
}
Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
}
}
if let Ok(cwd) = std::env::current_dir() {
let project_dir = cwd.join(".lean-ctx");
let project_id = cwd.join(".lean-ctx-id");
for p in [&project_dir, &project_id] {
if p.exists() {
if dry_run {
println!(" Would remove {}", p.display());
removed = true;
} else if p.is_dir() {
if fs::remove_dir_all(p).is_ok() {
println!(" ✓ Removed {}", p.display());
removed = true;
}
} else if fs::remove_file(p).is_ok() {
println!(" ✓ Removed {}", p.display());
removed = true;
}
}
}
}
if !removed {
println!(" · No data directory found");
}
removed
}
fn try_claude_mcp_remove() {
let result = std::process::Command::new("claude")
.args(["mcp", "remove", "lean-ctx", "--scope", "user"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match result {
Ok(s) if s.success() => println!(" ✓ Removed lean-ctx from Claude MCP registry"),
_ => {} }
}
fn cleanup_bak_files(home: &Path) {
let dirs_to_scan: Vec<PathBuf> = vec![
home.join(".cursor"),
home.join(".claude"),
crate::core::editor_registry::claude_state_dir(home),
home.join(".gemini"),
home.join(".gemini/antigravity"),
crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
home.join(".codeium"),
home.join(".codeium/windsurf"),
home.join(".config/opencode"),
home.join(".config/amp"),
home.join(".config/crush"),
home.join(".config/zed"),
home.join(".qwen"),
home.join(".trae"),
home.join(".aws/amazonq"),
home.join(".kiro"),
home.join(".kiro/settings"),
home.join(".ampcoder"),
home.join(".pi"),
home.join(".pi/agent"),
home.join(".hermes"),
home.join(".verdent"),
home.join(".cline"),
home.join(".roo"),
home.join(".continue"),
home.join(".jb-rules"),
home.join(".openclaw"),
home.join(".augment"),
home.join(".qoder"),
home.join(".qoderwork"),
home.join(".aider"),
home.join(".emacs.d"),
home.join(".copilot"),
home.join(".github"),
home.join(".github/hooks"),
home.join(".config/mcphub"),
home.join(".config/sublime-text"),
];
let mut cleaned = 0;
for dir in &dirs_to_scan {
if !dir.exists() {
continue;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".lean-ctx.tmp") {
let _ = fs::remove_file(entry.path());
cleaned += 1;
continue;
}
if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
let _ = fs::remove_file(entry.path());
cleaned += 1;
continue;
}
if name_str.ends_with(".lean-ctx.bak") {
let original_name = name_str.trim_end_matches(".lean-ctx.bak");
let original = entry.path().with_file_name(original_name);
if original.exists() {
match fs::read_to_string(&original) {
Ok(c) if !c.contains("lean-ctx") => {
let _ = fs::remove_file(entry.path());
cleaned += 1;
}
_ => {}
}
} else {
let _ = fs::remove_file(entry.path());
cleaned += 1;
}
continue;
}
if name_str.ends_with(".bak") && !name_str.contains(".lean-ctx") {
let original_name = name_str.trim_end_matches(".bak");
let original = entry.path().with_file_name(original_name);
if original.exists() {
if let Ok(bak_content) = fs::read_to_string(entry.path()) {
if bak_content.contains("lean-ctx") {
let _ = fs::remove_file(entry.path());
cleaned += 1;
}
}
}
}
}
}
}
let rc_baks = [
home.join(".zshrc.lean-ctx.bak"),
home.join(".zshenv.lean-ctx.bak"),
home.join(".bashrc.lean-ctx.bak"),
home.join(".bashenv.lean-ctx.bak"),
];
for bak in &rc_baks {
if bak.exists() {
let original_name = bak
.file_name()
.unwrap_or_default()
.to_string_lossy()
.trim_end_matches(".lean-ctx.bak")
.to_string();
let original = bak.with_file_name(original_name);
if original.exists() {
if let Ok(c) = fs::read_to_string(&original) {
if !c.contains("lean-ctx") {
let _ = fs::remove_file(bak);
cleaned += 1;
}
}
} else {
let _ = fs::remove_file(bak);
cleaned += 1;
}
}
}
if cleaned > 0 {
println!(" ✓ Cleaned up {cleaned} backup file(s)");
}
}
fn print_binary_removal_instructions() {
let binary_path = std::env::current_exe()
.map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
println!(" To complete uninstallation, remove the binary:\n");
if binary_path.contains(".cargo") {
println!(" cargo uninstall lean-ctx\n");
} else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
println!(" brew uninstall lean-ctx\n");
} else {
println!(" rm {binary_path}\n");
}
println!(" Then restart your shell.\n");
}