Skip to main content

dev_prune/commands/
uninstall.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune uninstall`.
5//
6// Two modes, and both of them remove the program itself — an uninstall that leaves a
7// fully working binary on PATH is not an uninstall, it is a settings change:
8//
9// - Light (default): removes the scheduler, the Git hooks, the file-type icons, the
10//   agent skill, the PATH entry and the binaries. The config directory — registry,
11//   prune history, settings — is kept, so a later reinstall picks up where it left off.
12// - Deep (`--deep`): all of the above, plus `.devprune.json` in every registered
13//   repository and the config directory itself.
14//
15// Both modes also sweep for *other* copies of the pair — a machine that has tried
16// `pip install`, `cargo install` and the shell installer over time has binaries and
17// shims in `~/.cargo/bin`, `~/.local/bin`, npm's global directory, a venv's `Scripts`
18// — and offers to delete every one it finds, so "uninstall" means the command stops
19// resolving everywhere, not just in the managed directory.
20//
21// On Windows a running executable cannot delete itself, so whatever is still in use is
22// handed to a detached `cmd.exe` that waits for this process to exit and then deletes
23// it. That is scheduled work, not failure — the command reports it and exits `0`.
24
25use anyhow::Result;
26use std::collections::HashSet;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use crate::commands::hook;
31use crate::config::Registry;
32use crate::output;
33use crate::setup;
34
35pub fn run(deep: bool, yes: bool) -> Result<()> {
36    output::print_header(if deep {
37        "dev-prune Deep Uninstaller (Full Purge)"
38    } else {
39        "dev-prune Uninstaller"
40    });
41
42    let registry = Registry::load().ok();
43
44    // A deep uninstall deletes files inside the user's own repositories and destroys
45    // the prune history. That is not something to do on a mistyped flag.
46    if deep && !yes {
47        use std::io::{IsTerminal, Write};
48        let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
49        output::print_warning(&format!(
50            "This deletes the global config directory (including prune history) and \
51             removes `.devprune.json` from {repo_count} registered repositories."
52        ));
53        if !std::io::stdin().is_terminal() {
54            anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
55        }
56        print!("Continue? [y/N]: ");
57        std::io::stdout().flush()?;
58        let mut input = String::new();
59        std::io::stdin().read_line(&mut input)?;
60        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
61            output::print_info("Deep uninstall cancelled.");
62            return Ok(());
63        }
64    }
65
66    // Each step keeps going when another fails — a scheduler that refuses to uninstall
67    // must not stop the hooks being removed — but none of them is silent about it. What
68    // could not be removed is reported, and its presence makes the exit code `1`.
69    let mut left_behind: Vec<String> = Vec::new();
70    // Files and directories that are in use right now (Windows keeps a running image
71    // locked, under every one of its hard-linked names). Deleted by a detached helper
72    // the moment this process exits.
73    let mut pending_files: Vec<PathBuf> = Vec::new();
74    let mut pending_dirs: Vec<PathBuf> = Vec::new();
75
76    // 1. Background scheduler.
77    output::print_info("Removing background daemon scheduler...");
78    if let Err(e) = crate::daemon::uninstall_daemon() {
79        output::print_error(&format!("Background scheduler: {e:#}"));
80        left_behind.push("the background scheduler".to_string());
81    }
82
83    // 2. Global Git hooks.
84    output::print_info("Removing global Git auto-registration hooks...");
85    if let Err(e) = hook::run_uninstall() {
86        output::print_error(&format!("Git hooks: {e:#}"));
87        left_behind.push("the global Git hooks".to_string());
88    }
89
90    // 3. The `*.devprune.json` file type, out of the desktop database.
91    crate::commands::icon::unregister_file_type();
92
93    // 4. The skill installed into AI agents' own directories. Only directories named
94    // for this tool are touched — `~/.claude/skills/dev-prune/`, never a sibling.
95    for root in setup::agent_skill_roots() {
96        if !root.exists() {
97            continue;
98        }
99        match fs::remove_dir_all(&root) {
100            Ok(()) => output::print_info(&format!(
101                "Removed the agent skill at {}.",
102                output::clean_path(&root)
103            )),
104            Err(e) => {
105                output::print_error(&format!(
106                    "Could not remove {}: {e}",
107                    output::clean_path(&root)
108                ));
109                left_behind.push("the AI agent skill".to_string());
110            }
111        }
112    }
113
114    // 5. Reachability: the user-PATH entry on Windows, the `~/.local/bin` links
115    // elsewhere. Before the binaries go, so no window exists where PATH names a
116    // directory whose contents are gone.
117    if let Ok(bin_dir) = setup::managed_bin_dir() {
118        match crate::pathenv::remove_reachability(&bin_dir) {
119            Ok(true) => output::print_info("Removed dev-prune from your PATH."),
120            Ok(false) => {}
121            Err(e) => {
122                output::print_error(&format!("Could not update your PATH: {e:#}"));
123                left_behind.push("the PATH entry".to_string());
124            }
125        }
126    }
127
128    // 6. The binaries themselves.
129    let manager = std::env::current_exe()
130        .ok()
131        .as_deref()
132        .and_then(owning_package_manager);
133    remove_binaries(
134        deep,
135        manager.is_some(),
136        &mut left_behind,
137        &mut pending_files,
138        &mut pending_dirs,
139    );
140
141    // 7. Every other copy on the machine. A machine that has tried more than one
142    // install channel has more than one binary, and the ones not currently first on
143    // PATH would quietly *become* the installation the moment the managed pair above
144    // is gone.
145    let mut manager_hints: Vec<(&'static str, &'static str)> = Vec::new();
146    if let Some(hint) = manager {
147        manager_hints.push(hint);
148    }
149    sweep_stray_copies(
150        yes,
151        &mut manager_hints,
152        &mut left_behind,
153        &mut pending_files,
154    );
155
156    if deep {
157        // Per-repo configs, then the config directory itself.
158        if let Some(reg) = registry {
159            for repo_path in reg.repositories.keys() {
160                let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
161                if cfg_file.exists() {
162                    let _ = fs::remove_file(cfg_file);
163                }
164            }
165        }
166
167        if let Ok(config_dir) = Registry::config_dir() {
168            if config_dir.exists() {
169                match fs::remove_dir_all(&config_dir) {
170                    Ok(()) => output::print_info("Removed global configuration directory."),
171                    Err(e) => {
172                        // Routine on Windows: the managed copy under `<config>/bin` is
173                        // often the very binary running this command, and a running
174                        // executable cannot be deleted. That case is finished by the
175                        // helper; anything else really is left behind.
176                        let running_inside =
177                            std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
178                        if cfg!(windows) && running_inside {
179                            pending_dirs.push(config_dir);
180                        } else {
181                            output::print_error(&format!(
182                                "Could not remove {}: {e}",
183                                output::clean_path(&config_dir)
184                            ));
185                            left_behind.push("the global configuration directory".to_string());
186                        }
187                    }
188                }
189            }
190        }
191    } else {
192        // Stamp the current version so a surviving copy (a package-manager install, a
193        // dev build) does not reinstall, on its very next command, everything this
194        // command was run to remove.
195        setup::suppress_next_auto_setup();
196    }
197
198    // 8. One detached helper for everything that is in use right now.
199    let scheduled = !pending_files.is_empty() || !pending_dirs.is_empty();
200    if scheduled {
201        if spawn_deletion_helper(&pending_files, &pending_dirs) {
202            output::print_info(
203                "The running binary cannot delete itself — the rest is removed \
204                 automatically a few seconds after this command exits.",
205            );
206        } else {
207            output::print_error("Could not schedule removal of the running binary.");
208            left_behind.push("the binaries".to_string());
209        }
210    }
211
212    println!();
213    if left_behind.is_empty() {
214        output::print_success(if deep {
215            "Deep uninstall complete: program, integrations, configuration and registry removed."
216        } else {
217            "Uninstall complete: program and integrations removed. Configuration and \
218             prune history preserved for a future reinstall."
219        });
220    }
221    for (name, command) in &manager_hints {
222        output::print_info(&format!(
223            "{name} still lists dev-prune as installed — finish with `{command}` to clear \
224             its records."
225        ));
226    }
227    output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
228
229    if !left_behind.is_empty() {
230        anyhow::bail!("Uninstall finished, but {} is still installed.", {
231            left_behind.join(" and ")
232        });
233    }
234
235    Ok(())
236}
237
238/// Delete the managed pair, and the pair beside the running executable.
239///
240/// Skips a development build outright — deleting `target/debug/dev-prune` because a
241/// test or a contributor ran `uninstall` would destroy the build being worked on — and
242/// skips the copy beside a package-manager-owned executable, which the sweep offers to
243/// delete *with confirmation* rather than silently, because pulling files out from
244/// under a manager leaves its records dangling until its own uninstall command runs.
245fn remove_binaries(
246    deep: bool,
247    manager_owned: bool,
248    left_behind: &mut Vec<String>,
249    pending_files: &mut Vec<PathBuf>,
250    pending_dirs: &mut Vec<PathBuf>,
251) {
252    let mut candidates: Vec<PathBuf> = Vec::new();
253    let managed_bin_dir = setup::managed_bin_dir().ok();
254
255    if let Some(bin_dir) = &managed_bin_dir {
256        for stem in ["dev-prune", "devp"] {
257            candidates.push(bin_dir.join(exe_name(stem)));
258        }
259    }
260
261    if let Ok(current) = std::env::current_exe() {
262        if is_dev_build(&current) {
263            output::print_info(
264                "This is a development build — leaving the `target/` binaries alone.",
265            );
266        } else if manager_owned {
267            // The caller prints the manager's own uninstall command.
268        } else if let Some(parent) = current.parent() {
269            for stem in ["dev-prune", "devp"] {
270                let twin = parent.join(exe_name(stem));
271                if !candidates.contains(&twin) {
272                    candidates.push(twin);
273                }
274            }
275        }
276    }
277
278    let mut removed_any = false;
279    for exe in candidates {
280        if !exe.is_file() {
281            continue;
282        }
283        match fs::remove_file(&exe) {
284            Ok(()) => removed_any = true,
285            Err(e) => {
286                if cfg!(windows) {
287                    pending_files.push(exe);
288                } else {
289                    output::print_error(&format!(
290                        "Could not remove {}: {e}",
291                        output::clean_path(&exe)
292                    ));
293                    left_behind.push("the binaries".to_string());
294                }
295            }
296        }
297    }
298    if removed_any {
299        output::print_info("Removed the dev-prune binaries.");
300    }
301
302    // The managed `bin` directory should not outlive its contents. On a deep uninstall
303    // the whole config directory goes anyway; on a light one, remove it once empty, or
304    // let the helper do it after the pending deletions.
305    if !deep {
306        if let Some(bin_dir) = managed_bin_dir {
307            if bin_dir.is_dir() && fs::remove_dir(&bin_dir).is_err() && !pending_files.is_empty() {
308                pending_dirs.push(bin_dir);
309            }
310        }
311    }
312}
313
314/// One copy of dev-prune found somewhere other than the managed directory.
315struct StrayCopy {
316    path: PathBuf,
317    manager: Option<(&'static str, &'static str)>,
318}
319
320/// Find every other copy of the pair, show the list, and — with the user's yes —
321/// delete them all.
322///
323/// Discovery covers every directory on this process's PATH plus the well-known install
324/// directories that are often *not* on it any more: `~/.cargo/bin`, `~/.local/bin`
325/// (uv, pipx and the XDG convention), npm's global directory, pip's per-user `Scripts`
326/// directories, and whatever directory the running executable lives in. Only files
327/// carrying the pair's own names are ever considered, so nothing else in those
328/// directories can be touched.
329///
330/// Deletion is opt-in: the list is printed and confirmed first (`--yes` counts as
331/// confirmation; a non-terminal without it leaves everything in place). A declined
332/// prompt is a decision, not a failure — it does not change the exit code.
333fn sweep_stray_copies(
334    yes: bool,
335    manager_hints: &mut Vec<(&'static str, &'static str)>,
336    left_behind: &mut Vec<String>,
337    pending_files: &mut Vec<PathBuf>,
338) {
339    // Anything already queued for the deletion helper still exists on disk right now;
340    // finding it again here would list it as a stray and queue it twice.
341    let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
342    let strays: Vec<StrayCopy> = find_stray_copies()
343        .into_iter()
344        .filter(|s| !already_pending.contains(&canon_key(&s.path)))
345        .collect();
346    if strays.is_empty() {
347        return;
348    }
349
350    println!();
351    output::print_warning(&format!(
352        "Found {} more cop{} of dev-prune, from other install channels:",
353        strays.len(),
354        if strays.len() == 1 { "y" } else { "ies" }
355    ));
356    for stray in &strays {
357        match stray.manager {
358            Some((name, _)) => println!(
359                "   {}  (installed with {name})",
360                output::clean_path(&stray.path)
361            ),
362            None => println!("   {}", output::clean_path(&stray.path)),
363        }
364    }
365
366    if !confirm_sweep(yes) {
367        output::print_info(
368            "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
369        );
370        return;
371    }
372
373    let mut removed = 0usize;
374    for stray in strays {
375        if let Some(hint) = stray.manager {
376            if !manager_hints.iter().any(|(name, _)| *name == hint.0) {
377                manager_hints.push(hint);
378            }
379        }
380        match fs::remove_file(&stray.path) {
381            Ok(()) => removed += 1,
382            Err(e) => {
383                // The running executable itself is often in this list. Windows keeps
384                // it locked; the detached helper finishes the job.
385                if cfg!(windows) {
386                    pending_files.push(stray.path);
387                } else {
388                    output::print_error(&format!(
389                        "Could not remove {}: {e}",
390                        output::clean_path(&stray.path)
391                    ));
392                    left_behind.push("a stray copy".to_string());
393                }
394            }
395        }
396    }
397    if removed > 0 {
398        output::print_info(&format!(
399            "Removed {removed} stray cop{}.",
400            if removed == 1 { "y" } else { "ies" }
401        ));
402    }
403}
404
405/// Ask before the sweep deletes anything. `--yes` answers for the user; a pipe or a
406/// script without it gets a "no" plus the flag to pass next time.
407fn confirm_sweep(yes: bool) -> bool {
408    use std::io::{IsTerminal, Write};
409    if yes {
410        return true;
411    }
412    if !std::io::stdin().is_terminal() {
413        output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
414        return false;
415    }
416    print!("Remove them all? [Y/n]: ");
417    if std::io::stdout().flush().is_err() {
418        return false;
419    }
420    let mut input = String::new();
421    if std::io::stdin().read_line(&mut input).is_err() {
422        return false;
423    }
424    matches!(input.trim().to_lowercase().as_str(), "" | "y" | "yes")
425}
426
427/// Every dev-prune/devp file in the sweep directories, except the managed pair (the
428/// caller already removed it), development builds, and directories, deduplicated.
429///
430/// A dangling symlink still counts — it is exactly the kind of leftover the sweep
431/// exists to clean up — which is why this checks `symlink_metadata`, not `is_file`.
432fn find_stray_copies() -> Vec<StrayCopy> {
433    let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
434    let names = sweep_names();
435    let mut seen_dirs: HashSet<String> = HashSet::new();
436    let mut seen_files: HashSet<String> = HashSet::new();
437    let mut found = Vec::new();
438
439    for dir in sweep_dirs() {
440        let dir_key = canon_key(&dir);
441        if !seen_dirs.insert(dir_key.clone()) {
442            continue;
443        }
444        if managed.as_deref() == Some(dir_key.as_str()) {
445            continue;
446        }
447        for name in &names {
448            let candidate = dir.join(name);
449            let Ok(meta) = fs::symlink_metadata(&candidate) else {
450                continue;
451            };
452            if meta.is_dir() || is_dev_build(&candidate) {
453                continue;
454            }
455            if !seen_files.insert(canon_key(&candidate)) {
456                continue;
457            }
458            let manager = owning_package_manager(&candidate);
459            found.push(StrayCopy {
460                path: candidate,
461                manager,
462            });
463        }
464    }
465    found
466}
467
468/// The directories worth looking in: everything on PATH, plus the install directories
469/// each supported channel writes to — which stop being on PATH the moment a venv
470/// deactivates or a profile line is removed, without the files going anywhere.
471fn sweep_dirs() -> Vec<PathBuf> {
472    let mut dirs: Vec<PathBuf> = Vec::new();
473    if let Some(path_var) = std::env::var_os("PATH") {
474        dirs.extend(std::env::split_paths(&path_var));
475    }
476    if let Some(home) = dirs::home_dir() {
477        dirs.push(home.join(".cargo").join("bin"));
478        dirs.push(home.join(".local").join("bin"));
479        if !cfg!(windows) {
480            dirs.push(home.join(".npm-global").join("bin"));
481        }
482    }
483    if cfg!(windows) {
484        // `config_dir` is %APPDATA% — npm's global prefix and pip's per-user scripts
485        // both live under it.
486        if let Some(appdata) = dirs::config_dir() {
487            dirs.push(appdata.join("npm"));
488            if let Ok(entries) = fs::read_dir(appdata.join("Python")) {
489                for entry in entries.flatten() {
490                    let scripts = entry.path().join("Scripts");
491                    if scripts.is_dir() {
492                        dirs.push(scripts);
493                    }
494                }
495            }
496        }
497    }
498    if let Ok(exe) = std::env::current_exe() {
499        if let Some(parent) = exe.parent() {
500            dirs.push(parent.to_path_buf());
501        }
502    }
503    dirs
504}
505
506/// The file names one of the pair can appear under. On Windows that is more than the
507/// two `.exe`s: npm writes `.cmd` and `.ps1` shims plus an extensionless sh shim for
508/// Git Bash, and each is a separate file to delete.
509fn sweep_names() -> Vec<String> {
510    let stems = ["dev-prune", "devp"];
511    if cfg!(windows) {
512        let mut names: Vec<String> = Vec::new();
513        for stem in stems {
514            for ext in ["exe", "cmd", "ps1", "bat"] {
515                names.push(format!("{stem}.{ext}"));
516            }
517            names.push(stem.to_string());
518        }
519        names
520    } else {
521        stems.iter().map(|s| s.to_string()).collect()
522    }
523}
524
525/// One canonical string per path, so `C:\X\Bin\` and `c:\x\bin` count once.
526fn canon_key(path: &Path) -> String {
527    let key = path.to_string_lossy().replace('\\', "/");
528    let key = key.trim_end_matches('/').to_string();
529    if cfg!(windows) {
530        key.to_lowercase()
531    } else {
532        key
533    }
534}
535
536/// The on-disk file name for one of the pair, on this platform.
537fn exe_name(stem: &str) -> String {
538    if cfg!(windows) {
539        format!("{stem}.exe")
540    } else {
541        stem.to_string()
542    }
543}
544
545/// Whether this executable is running out of a Cargo build directory.
546fn is_dev_build(exe: &Path) -> bool {
547    let path = exe.to_string_lossy().replace('\\', "/");
548    path.contains("/target/debug/") || path.contains("/target/release/")
549}
550
551/// The package manager that owns the running executable's files, if one does, and the
552/// command that actually uninstalls through it.
553///
554/// dev-prune ships through cargo, npm, PyPI and uv as well as the installer scripts.
555/// Deleting files out from under one of those managers leaves *its* records pointing at
556/// nothing — `pip list` still shows the package, `cargo install` refuses to reinstall —
557/// so those copies are left for the manager's own uninstall, which is printed instead.
558fn owning_package_manager(exe: &Path) -> Option<(&'static str, &'static str)> {
559    let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
560    if path.contains("/.cargo/bin/") {
561        return Some(("cargo", "cargo uninstall dev-prune"));
562    }
563    if path.contains("/node_modules/") || path.contains("/_npx/") {
564        return Some(("npm", "npm uninstall -g dev-prune"));
565    }
566    if path.contains("/uv/tools/") {
567        return Some(("uv", "uv tool uninstall dev-prune"));
568    }
569    if path.contains("/pipx/") {
570        return Some(("pipx", "pipx uninstall dev-prune"));
571    }
572    if let Some(dir) = exe.parent() {
573        // npm's global shims sit *beside* its `node_modules`, not inside it.
574        if dir.join("node_modules").join("dev-prune").exists() {
575            return Some(("npm", "npm uninstall -g dev-prune"));
576        }
577        // pip puts console scripts beside a Python interpreter — the system
578        // `Scripts`/`bin` directory or a virtualenv's.
579        for interpreter in ["python.exe", "python", "python3"] {
580            if dir.join(interpreter).exists() {
581                return Some(("pip", "pip uninstall dev-prune"));
582            }
583        }
584    }
585    None
586}
587
588/// The install one-liner for this platform, for the goodbye message.
589fn reinstall_hint() -> &'static str {
590    if cfg!(windows) {
591        "iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex"
592    } else {
593        "curl -fsSL https://devprune.vkrishna04.me/install.sh | sh"
594    }
595}
596
597/// Hand the in-use files to a detached `cmd.exe` that deletes them after this process
598/// exits. The two-second `ping` is the canonical batch-file sleep — `timeout` refuses
599/// to run without a console, and this helper deliberately has none.
600#[cfg(windows)]
601fn spawn_deletion_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
602    use std::os::windows::process::CommandExt;
603    // Not in windows-sys's prelude of imported constants anywhere else in this crate;
604    // documented value of CREATE_NO_WINDOW.
605    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
606
607    // Three attempts, two seconds apart. One would cover the normal case — this
608    // process exits the moment the command returns, releasing the image lock — but a
609    // slow exit, an antivirus scan hooked on process teardown, or the user running
610    // `devp` again inside the first window would otherwise leave the binary behind
611    // with nothing ever retrying. `cmd /C` cannot use labels, so the loop is unrolled.
612    let mut attempt = String::new();
613    for file in files {
614        attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
615    }
616    for dir in dirs {
617        attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
618    }
619    let mut script = String::new();
620    for _ in 0..3 {
621        script.push_str("ping -n 3 127.0.0.1 >nul");
622        script.push_str(&attempt);
623        script.push_str(" & ");
624    }
625    script.push_str("exit");
626
627    std::process::Command::new("cmd")
628        // `raw_arg`, because std's quoting would wrap the whole script in quotes and
629        // `cmd /C` would then treat it as one file name rather than a command line.
630        .raw_arg(format!("/C {script}"))
631        .creation_flags(CREATE_NO_WINDOW)
632        .stdin(std::process::Stdio::null())
633        .stdout(std::process::Stdio::null())
634        .stderr(std::process::Stdio::null())
635        .spawn()
636        .is_ok()
637}
638
639/// On Unix an open file can be unlinked, so nothing ever needs scheduling; this exists
640/// so the call site compiles unconditionally and is unreachable in practice.
641#[cfg(not(windows))]
642fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> bool {
643    false
644}