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_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        if !confirm("Uninstall mi6?")? {
107            eprintln!("Aborted.");
108            return Ok(());
109        }
110        println!();
111    }
112
113    // Execute Step 1: Disable frameworks (all scopes)
114    if !enabled_frameworks.is_empty() {
115        print!("{} mi6 hooks... ", bold_green("Disabling"));
116        std::io::stdout().flush().ok();
117        for (adapter, _scope) in &enabled_frameworks {
118            // Disable in all scope combinations - ignore errors for scopes that don't have hooks
119            let _ = adapter.uninstall_hooks(false, false); // global
120            let _ = adapter.uninstall_hooks(true, false); // local
121            let _ = adapter.uninstall_hooks(false, true); // settings_local
122        }
123        // Remove Claude marketplace registration
124        remove_claude_marketplace();
125        println!("{}", bold_green("done"));
126    }
127
128    // Execute Step 2: Stop OTel server
129    if otel_running {
130        print!("{} OTel server... ", bold_green("Stopping"));
131        std::io::stdout().flush().ok();
132        // Ignore errors - best effort cleanup
133        let _ = stop_server(DEFAULT_PORT);
134        println!("{}", bold_green("done"));
135    }
136
137    // Execute Step 3: Uninstall binary
138    print!("{} mi6 binary... ", bold_green("Uninstalling"));
139    std::io::stdout().flush().ok();
140    match &method {
141        InstallMethod::Homebrew => uninstall_homebrew()?,
142        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => uninstall_cargo()?,
143        InstallMethod::Standalone => uninstall_standalone()?,
144    }
145    println!("{}", bold_green("done"));
146
147    // Execute Step 4: Delete data (prompt user unless --yes or --keep-data)
148    if !options.keep_data
149        && let Some(ref dir) = mi6_dir
150        && dir.exists()
151    {
152        let should_delete = if options.yes {
153            true
154        } else {
155            println!();
156            eprintln!("{} {}", bold_yellow("mi6 data directory:"), dir.display());
157            confirm("Delete mi6 data (database, config, themes)?")?
158        };
159
160        if should_delete {
161            print!("{} {}... ", bold_green("Deleting"), dir.display());
162            std::io::stdout().flush().ok();
163            std::fs::remove_dir_all(dir)
164                .with_context(|| format!("failed to delete {}", dir.display()))?;
165            println!("{}", bold_green("done"));
166        } else {
167            eprintln!("Keeping mi6 data.");
168        }
169    }
170
171    println!();
172    println!("Done! mi6 has been uninstalled.");
173
174    Ok(())
175}
176
177/// Get the uninstall command string for display
178fn get_uninstall_command(method: &InstallMethod) -> String {
179    match method {
180        InstallMethod::Homebrew => "brew uninstall mi6".to_string(),
181        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
182            "cargo uninstall mi6".to_string()
183        }
184        InstallMethod::Standalone => {
185            if let Ok(exe_path) = std::env::current_exe() {
186                format!("rm {}", exe_path.display())
187            } else {
188                "(remove mi6 binary manually)".to_string()
189            }
190        }
191    }
192}
193
194/// Scope description for display
195fn scope_name(local: bool, settings_local: bool) -> &'static str {
196    if settings_local {
197        "settings-local"
198    } else if local {
199        "local"
200    } else {
201        "global"
202    }
203}
204
205/// Find all frameworks that have mi6 enabled in any scope.
206///
207/// Returns a list of (adapter, scope_description) tuples.
208fn find_all_enabled_frameworks() -> Vec<(&'static dyn FrameworkAdapter, &'static str)> {
209    let mut results = Vec::new();
210
211    for adapter in all_adapters() {
212        // Check all scope combinations
213        for (local, settings_local) in [(false, false), (true, false), (false, true)] {
214            if adapter.has_mi6_hooks(local, settings_local) {
215                results.push((adapter, scope_name(local, settings_local)));
216            }
217        }
218    }
219
220    results
221}
222
223/// Get the main mi6 data directory (MI6_DIR_PATH or ~/.mi6)
224fn get_mi6_dir() -> Option<PathBuf> {
225    Config::mi6_dir().ok()
226}
227
228/// Remove the Claude marketplace registration.
229///
230/// This is a best-effort cleanup - errors are silently ignored.
231fn remove_claude_marketplace() {
232    // Get the marketplace path
233    let Some(home) = dirs::home_dir() else {
234        return;
235    };
236    let cache_path = home.join(".mi6/claude-plugin");
237
238    // Only try to remove if it exists
239    if !cache_path.exists() {
240        return;
241    }
242
243    let cache_path_str = cache_path.to_string_lossy();
244
245    // Run: claude plugin marketplace remove <path>
246    // Ignore errors - the marketplace might not exist or claude might not be installed
247    let _ = Command::new("claude")
248        .args(["plugin", "marketplace", "remove", &cache_path_str])
249        .output();
250}
251
252/// Uninstall via Homebrew
253fn uninstall_homebrew() -> Result<()> {
254    let status = Command::new("brew")
255        .args(["uninstall", "mi6"])
256        .status()
257        .context("failed to run brew uninstall")?;
258
259    if !status.success() {
260        anyhow::bail!("brew uninstall failed with exit code: {:?}", status.code());
261    }
262
263    Ok(())
264}
265
266/// Uninstall via Cargo
267fn uninstall_cargo() -> Result<()> {
268    let status = Command::new("cargo")
269        .args(["uninstall", "mi6"])
270        .status()
271        .context("failed to run cargo uninstall")?;
272
273    if !status.success() {
274        anyhow::bail!("cargo uninstall failed with exit code: {:?}", status.code());
275    }
276
277    Ok(())
278}
279
280/// Uninstall standalone binary
281fn uninstall_standalone() -> Result<()> {
282    let exe_path = std::env::current_exe().context("failed to get current executable path")?;
283
284    // On Unix, we can delete ourselves while running
285    // On Windows, this would fail, but mi6 is primarily for Unix systems
286    std::fs::remove_file(&exe_path)
287        .with_context(|| format!("failed to delete {}", exe_path.display()))?;
288
289    Ok(())
290}