use anyhow::Result;
use tracing::{Level, info, instrument, warn};
use crate::dialog;
use crate::settings::ClashSettings;
use crate::ui;
#[instrument(level = Level::TRACE)]
pub fn run(yes: bool) -> Result<()> {
ui::banner_section("Uninstall");
if !dialog::confirm(
"Uninstall clash? This will remove all clash configuration from Claude Code",
yes,
)
.unwrap_or(false)
{
ui::skip("Cancelled.");
return Ok(());
}
remove_status_line();
remove_hooks_and_plugin();
remove_settings_dir(yes);
remove_binary(yes);
println!();
ui::success("Clash has been uninstalled. To reinstall, run:");
println!(
" {}",
crate::style::dim(
"curl -fsSL https://raw.githubusercontent.com/empathic/clash/main/install.sh | bash"
)
);
println!(
" {}",
crate::style::dim("# or: cargo install clash, or: just install")
);
Ok(())
}
fn remove_status_line() {
match super::statusline::uninstall_for_teardown() {
Ok(true) => {
ui::success("Removed status line from Claude Code settings.");
}
Ok(false) => {
ui::skip("No clash status line configured.");
}
Err(e) => {
warn!(error = %e, "failed to remove status line");
ui::warn(&format!("Could not remove status line: {e}"));
}
}
}
fn remove_hooks_and_plugin() {
let claude = claude_settings::ClaudeSettings::new();
match claude.update(claude_settings::SettingsLevel::User, |settings| {
let hooks_removed = settings
.hooks
.as_mut()
.map(|h| super::init::uninstall_clash_hooks(h))
.unwrap_or(false);
if hooks_removed {
info!("removed clash hooks from settings");
}
if let Some(ref h) = settings.hooks {
if h.pre_tool_use.is_none()
&& h.post_tool_use.is_none()
&& h.permission_request.is_none()
&& h.session_start.is_none()
&& h.stop.is_none()
&& h.notification.is_none()
{
settings.hooks = None;
}
}
if let Some(ref mut plugins) = settings.enabled_plugins {
plugins.remove("clash");
if plugins.is_empty() {
settings.enabled_plugins = None;
}
}
settings.clear_clash_installed();
}) {
Ok(()) => {
ui::success("Removed clash hooks from Claude Code settings.");
}
Err(e) => {
warn!(error = %e, "failed to remove hooks from settings");
ui::warn(&format!("Could not update Claude Code settings: {e}"));
}
}
}
fn remove_settings_dir(yes: bool) {
let dir = match ClashSettings::settings_dir() {
Ok(d) => d,
Err(e) => {
warn!(error = %e, "could not determine settings directory");
return;
}
};
if !dir.exists() {
ui::skip(&format!(
"{} does not exist, nothing to remove.",
dir.display()
));
return;
}
if !dialog::confirm(
&format!("Remove {}? (contains your policy files)", dir.display()),
yes,
)
.unwrap_or(false)
{
ui::skip(&format!("Kept {}.", dir.display()));
return;
}
if let Err(e) = std::fs::remove_dir_all(&dir) {
warn!(error = %e, path = %dir.display(), "failed to remove settings directory");
ui::warn(&format!("Could not remove {}: {e}", dir.display()));
return;
}
ui::success(&format!("Removed {}.", dir.display()));
}
fn remove_binary(yes: bool) {
let binary_path = match find_clash_binary() {
Some(p) => p,
None => {
ui::skip("clash binary not found on PATH.");
return;
}
};
if binary_path.contains("/target/") {
ui::skip(&format!(
"Skipping binary removal (looks like a development build at {}).",
binary_path,
));
return;
}
if !dialog::confirm(&format!("Remove clash binary at {}?", binary_path), yes).unwrap_or(false) {
ui::skip(&format!("Kept binary at {}.", binary_path));
return;
}
if let Err(e) = std::fs::remove_file(&binary_path) {
warn!(error = %e, path = %binary_path, "failed to remove binary");
ui::warn(&format!("Could not remove {}: {e}", binary_path));
return;
}
ui::success(&format!("Removed {}.", binary_path));
}
fn find_clash_binary() -> Option<String> {
std::process::Command::new("which")
.arg("clash")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_clash_binary_does_not_panic() {
let _ = find_clash_binary();
}
}