mi6_cli/commands/
uninstall.rs

1//! Uninstall command - completely remove mi6 from the system.
2//!
3//! This command performs a complete uninstallation in the following order:
4//! 1. Disables mi6 hooks for all enabled frameworks (all scopes)
5//! 2. Stops the OTel server if running
6//! 3. Uninstalls the mi6 binary based on detected installation method
7//! 4. Optionally deletes the mi6 data directory (prompts user unless --yes or --keep-data)
8//!
9//! The order is designed so that if any step fails, the user hasn't lost their data yet.
10
11use std::io::Write;
12use std::path::PathBuf;
13use std::process::Command;
14
15use anyhow::{Context, Result};
16
17use mi6_core::{Config, FrameworkAdapter, all_adapters};
18use mi6_otel_server::lifecycle::{DEFAULT_PORT, is_mi6_server, stop_server};
19
20use super::upgrade::InstallMethod;
21use crate::display::{bold_green, bold_red, bold_white, bold_yellow, confirm};
22
23/// Options for the uninstall command
24pub struct UninstallOptions {
25    /// Skip all confirmation prompts (auto-delete data)
26    pub yes: bool,
27    /// Keep mi6 data (database, config, themes)
28    pub keep_data: bool,
29    /// Show what would happen without actually doing it
30    pub dry_run: bool,
31}
32
33/// Run the uninstall command
34pub fn run_uninstall(options: UninstallOptions) -> Result<()> {
35    let current_version = env!("CARGO_PKG_VERSION");
36    let method = InstallMethod::detect()?;
37
38    println!("mi6 v{}", current_version);
39    println!();
40    println!("{} {}", bold_green("Installation method:"), method.name());
41    if let InstallMethod::CargoPath(path) = &method {
42        println!("{} {}", bold_green("   Source code path:"), path.display());
43    }
44    println!();
45
46    // Gather information about what will be done
47    let enabled_frameworks = find_all_enabled_frameworks();
48    let mi6_dir = get_mi6_dir();
49    let data_exists = mi6_dir.as_ref().is_some_and(|p| p.exists());
50    let otel_running = is_mi6_server(DEFAULT_PORT);
51
52    println!("{}", bold_green("Uninstall steps:"));
53
54    // Step 1: Show frameworks that will be disabled
55    if enabled_frameworks.is_empty() {
56        println!("{} No enabled frameworks found", bold_green("1."));
57    } else {
58        println!("{} Disable mi6 hooks", bold_green("1."));
59        for (adapter, scope) in &enabled_frameworks {
60            println!("   - {} ({})", bold_white(adapter.display_name()), scope);
61        }
62    }
63
64    // Step 2: Show OTel server status
65    if otel_running {
66        println!(
67            "{} Stop OTel server ({})",
68            bold_green("2."),
69            bold_white(&format!("port {}", DEFAULT_PORT))
70        );
71    } else {
72        println!("{} OTel server not running", bold_green("2."));
73    }
74
75    // Step 3: Show uninstall command
76    println!(
77        "{} Uninstall mi6 binary ({})",
78        bold_green("3."),
79        bold_white(get_uninstall_command(&method).as_str())
80    );
81
82    // Step 4: Show data deletion status
83    if options.keep_data {
84        println!("{} Keeping mi6 data (--keep-data)", bold_green("4."));
85    } else if data_exists {
86        if let Some(ref dir) = mi6_dir {
87            println!("{} Delete mi6 data (will prompt)", bold_green("4."));
88            println!(
89                "   - {} (database, config, themes)",
90                bold_white(&dir.display().to_string())
91            );
92        }
93    } else {
94        println!("{} No mi6 data to delete", bold_green("4."));
95    }
96    println!();
97
98    // If dry run, stop here
99    if options.dry_run {
100        println!("{} No changes made.", bold_yellow("Dry run:"));
101        return Ok(());
102    }
103
104    // Confirm with user before proceeding
105    if !options.yes {
106        eprintln!("{}", bold_red("This will permanently uninstall mi6."));
107        if !confirm("Uninstall mi6?")? {
108            eprintln!("Aborted.");
109            return Ok(());
110        }
111        println!();
112    }
113
114    // Execute Step 1: Disable frameworks (all scopes)
115    if !enabled_frameworks.is_empty() {
116        print!("{} mi6 hooks... ", bold_green("Disabling"));
117        std::io::stdout().flush().ok();
118        for (adapter, _scope) in &enabled_frameworks {
119            // Disable in all scope combinations - ignore errors for scopes that don't have hooks
120            let _ = adapter.uninstall_hooks(false, false); // global
121            let _ = adapter.uninstall_hooks(true, false); // local
122            let _ = adapter.uninstall_hooks(false, true); // settings_local
123        }
124        // Remove Claude marketplace registration
125        remove_claude_marketplace();
126        println!("{}", bold_green("done"));
127    }
128
129    // Execute Step 2: Stop OTel server
130    if otel_running {
131        print!("{} OTel server... ", bold_green("Stopping"));
132        std::io::stdout().flush().ok();
133        // Ignore errors - best effort cleanup
134        let _ = stop_server(DEFAULT_PORT);
135        println!("{}", bold_green("done"));
136    }
137
138    // Execute Step 3: Uninstall binary
139    print!("{} mi6 binary... ", bold_green("Uninstalling"));
140    std::io::stdout().flush().ok();
141    match &method {
142        InstallMethod::Homebrew => uninstall_homebrew()?,
143        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => uninstall_cargo()?,
144        InstallMethod::Standalone => uninstall_standalone()?,
145    }
146    println!("{}", bold_green("done"));
147
148    // Execute Step 4: Delete data (prompt user unless --yes or --keep-data)
149    if !options.keep_data
150        && let Some(ref dir) = mi6_dir
151        && dir.exists()
152    {
153        let should_delete = if options.yes {
154            true
155        } else {
156            println!();
157            eprintln!("{} {}", bold_yellow("mi6 data directory:"), dir.display());
158            confirm("Delete mi6 data (database, config, themes)?")?
159        };
160
161        if should_delete {
162            print!("{} {}... ", bold_green("Deleting"), dir.display());
163            std::io::stdout().flush().ok();
164            std::fs::remove_dir_all(dir)
165                .with_context(|| format!("failed to delete {}", dir.display()))?;
166            println!("{}", bold_green("done"));
167        } else {
168            eprintln!("Keeping mi6 data.");
169        }
170    }
171
172    println!();
173    println!("Done! mi6 has been uninstalled.");
174
175    Ok(())
176}
177
178/// Get the uninstall command string for display
179fn get_uninstall_command(method: &InstallMethod) -> String {
180    match method {
181        InstallMethod::Homebrew => "brew uninstall mi6".to_string(),
182        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
183            "cargo uninstall mi6".to_string()
184        }
185        InstallMethod::Standalone => {
186            if let Ok(exe_path) = std::env::current_exe() {
187                format!("rm {}", exe_path.display())
188            } else {
189                "(remove mi6 binary manually)".to_string()
190            }
191        }
192    }
193}
194
195/// Scope description for display
196fn scope_name(local: bool, settings_local: bool) -> &'static str {
197    if settings_local {
198        "settings-local"
199    } else if local {
200        "local"
201    } else {
202        "global"
203    }
204}
205
206/// Find all frameworks that have mi6 enabled in any scope.
207///
208/// Returns a list of (adapter, scope_description) tuples.
209fn find_all_enabled_frameworks() -> Vec<(&'static dyn FrameworkAdapter, &'static str)> {
210    let mut results = Vec::new();
211
212    for adapter in all_adapters() {
213        // Check all scope combinations
214        for (local, settings_local) in [(false, false), (true, false), (false, true)] {
215            if adapter.has_mi6_hooks(local, settings_local) {
216                results.push((adapter, scope_name(local, settings_local)));
217            }
218        }
219    }
220
221    results
222}
223
224/// Get the main mi6 data directory (MI6_DIR_PATH or ~/.mi6)
225fn get_mi6_dir() -> Option<PathBuf> {
226    Config::mi6_dir().ok()
227}
228
229/// Remove the Claude marketplace registration.
230///
231/// This is a best-effort cleanup - errors are silently ignored.
232fn remove_claude_marketplace() {
233    // Get the marketplace path
234    let Some(home) = dirs::home_dir() else {
235        return;
236    };
237    let cache_path = home.join(".mi6/claude-plugin");
238
239    // Only try to remove if it exists
240    if !cache_path.exists() {
241        return;
242    }
243
244    let cache_path_str = cache_path.to_string_lossy();
245
246    // Run: claude plugin marketplace remove <path>
247    // Ignore errors - the marketplace might not exist or claude might not be installed
248    let _ = Command::new("claude")
249        .args(["plugin", "marketplace", "remove", &cache_path_str])
250        .output();
251}
252
253/// Uninstall via Homebrew
254fn uninstall_homebrew() -> Result<()> {
255    let status = Command::new("brew")
256        .args(["uninstall", "mi6"])
257        .status()
258        .context("failed to run brew uninstall")?;
259
260    if !status.success() {
261        anyhow::bail!("brew uninstall failed with exit code: {:?}", status.code());
262    }
263
264    Ok(())
265}
266
267/// Uninstall via Cargo
268fn uninstall_cargo() -> Result<()> {
269    let status = Command::new("cargo")
270        .args(["uninstall", "mi6"])
271        .status()
272        .context("failed to run cargo uninstall")?;
273
274    if !status.success() {
275        anyhow::bail!("cargo uninstall failed with exit code: {:?}", status.code());
276    }
277
278    Ok(())
279}
280
281/// Uninstall standalone binary
282fn uninstall_standalone() -> Result<()> {
283    let exe_path = std::env::current_exe().context("failed to get current executable path")?;
284
285    // On Unix, we can delete ourselves while running
286    // On Windows, this would fail, but mi6 is primarily for Unix systems
287    std::fs::remove_file(&exe_path)
288        .with_context(|| format!("failed to delete {}", exe_path.display()))?;
289
290    Ok(())
291}