dev-prune 1.0.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// Copyright 2026 VKrishna04
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

pub mod adapters;
pub mod commands;
pub mod config;
pub mod constants;
pub mod daemon;
pub mod engine;
pub mod json;
pub mod output;
pub mod scanner;
pub mod setup;
pub mod tui;
pub mod workspace;

use clap::{Parser, Subcommand};

/// Process exit codes, so scripts and CI can branch on the outcome.
///
/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
/// changing one is a breaking change.
pub mod exit_code {
    /// The command did what it was asked to do. A prune that deleted nothing because
    /// nothing was idle is still a success.
    pub const OK: i32 = 0;
    /// The command failed. The reason is on stderr.
    pub const FAILURE: i32 = 1;
    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
    pub const USAGE: i32 = 2;
}

/// Restore the default disposition for `SIGPIPE`.
///
/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
#[cfg(unix)]
fn restore_sigpipe() {
    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
    // thread is spawned.
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
    }
}

#[cfg(not(unix))]
fn restore_sigpipe() {}

/// Explain the rename, then answer the question the user was really asking.
///
/// Nobody types `--force` for fun. They type it because something was not pruned and
/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
/// them exactly as stuck as before. The list below is every reason a directory gets
/// skipped, with the fix, because six of the seven are not what `--force` was for.
///
/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
fn print_force_help() {
    output::print_notice(
        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
         The old spelling still works.",
    );
    eprintln!(
        "
  Reaching for --force usually means something did not get pruned. It is one of these:

    Not idle yet          A commit or a source edit inside idle_days (15 by default).
                          This is the one --ignore-idle is for.
    Lockfile unusable     The package manager could not confirm it. Run the command
                          dev-prune printed, then try again. No flag skips this check.
    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
                          in `.devprune.json`.
    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
    Not registered        `devp link .` first; `devp status` shows what is tracked.
    Nested or symlinked   A submodule is pruned as itself, never as part of its
                          parent, and a linked directory is refused. By design.
    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.

  `devp run --dry-run` names the actual reason, per repository.

  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
  tree, including this list. It has read it. It wrote it.
"
    );
}

/// Whether a failure is just the reader at the other end of a pipe hanging up.
///
/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
/// not an error worth printing — printing it would itself fail.
fn is_broken_pipe(err: &anyhow::Error) -> bool {
    err.chain().any(|cause| {
        cause
            .downcast_ref::<std::io::Error>()
            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
    })
}

/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
///
/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
#[derive(Parser, Debug)]
#[command(name = constants::APP_NAME)]
#[command(version = constants::VERSION)]
#[command(
    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
)]
#[command(
    after_help = "EXAMPLES:\n  devp init ~/Code          Scan directory trees & onboard workspaces\n  devp link                 Register current repository\n  devp run                  Execute prune pass across inactive repositories\n  devp status               View system status dashboard\n  devp caches               Size every package manager cache (deletes nothing)\n  devp status daemon        Check background daemon status (alias for `devp config daemon status`)\n  devp status . hook        Check workspace Git hook status (alias for `devp config . hook status`)\n  devp config . daemon disable  Disable daemon background pass for current workspace\n  devp restore .            Restore missing node_modules/.venv via lockfile\n  devp undo                 Revert most recent init or link action\n\nBINARY ALIAS:\n  `dev-prune` and `devp` invoke the exact same executable."
)]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Simulate pruning without deleting any files.
    #[arg(long, global = true)]
    dry_run: bool,

    /// Prune repositories you are still working in, ignoring the idle-day threshold.
    ///
    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
    #[arg(long, global = true)]
    ignore_idle: bool,

    /// Deprecated spelling of `--ignore-idle`.
    ///
    /// Renamed because "force" reads like "override the safety checks", which it never
    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
    #[arg(long, global = true)]
    force: bool,

    /// Bypass interactive confirmation prompts.
    #[arg(long, short = 'y', global = true)]
    yes: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
    #[command(alias = "scan", alias = "onboard")]
    Init {
        /// Paths to scan for Git repositories (defaults to current directory).
        #[arg(default_value = ".")]
        paths: Vec<String>,
    },

    /// Register a single Git repository for pruning (defaults to current directory `.`).
    Link {
        /// Path to the Git repository to register.
        #[arg(default_value = ".")]
        path: String,

        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
        #[arg(long)]
        quiet: bool,
    },

    /// Remove a repository from the dev-prune registry (does not delete workspace files).
    Unlink {
        /// Path to the Git repository to unregister.
        #[arg(default_value = ".")]
        path: String,

        /// Unregister every path that no longer exists, instead of one named repository.
        #[arg(long, conflicts_with = "path")]
        missing: bool,
    },

    /// Revert the most recent init or link action.
    Undo,

    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
    Run {
        /// Optional target workspace path. If omitted, runs across all registered repositories.
        target_path: Option<String>,

        /// Mark this as the scheduled background pass. Repositories that set
        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
        #[arg(long)]
        daemon: bool,

        /// Act only on these package managers (comma-separated),
        /// e.g. `--only npm,pnpm`. Unknown names are an error.
        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
        only: Option<String>,

        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
        #[arg(long, value_name = "ADAPTERS")]
        skip: Option<String>,

        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
        #[arg(long, value_name = "MIB")]
        min_size: Option<u64>,

        /// Prune everything except these repositories (comma-separated paths or names).
        ///
        /// The safe way to express "clean up but keep the API project": that project is
        /// never verified, never deleted and never reinstalled, instead of being pruned
        /// and then restored over the network.
        #[arg(long, value_name = "REPOS")]
        except: Option<String>,

        /// Emit one JSON document instead of the human report. Implies non-interactive.
        #[arg(long)]
        json: bool,
    },

    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
    Status {
        /// Emit the dashboard as one JSON document instead of the TUI or text table.
        #[arg(long)]
        json: bool,
    },

    /// Report the size of every package manager cache on this machine (read-only, deletes nothing).
    Caches {
        /// Emit the report as one JSON document instead of the table.
        #[arg(long)]
        json: bool,
    },

    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
    Config {
        #[command(subcommand)]
        action: Option<ConfigAction>,
    },

    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
    Restore {
        /// Path to the project to restore (defaults to current directory).
        path: Option<String>,

        /// Put back exactly what the most recent prune pass deleted, in every repository
        /// it touched. The undo for a `run`.
        #[arg(long, conflicts_with = "path")]
        last_run: bool,
    },

    /// Print the installed version, check for a newer release, and show how to upgrade.
    Update {
        /// Skip the release check for this run. The check is the only thing in dev-prune
        /// that opens a network connection; `devp config set update_check false` turns
        /// it off for good.
        #[arg(long)]
        offline: bool,
    },

    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
    Skill,

    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
    Setup {
        /// Report what is installed without changing anything.
        #[arg(long)]
        status: bool,
    },

    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
    Doctor {
        /// Repository to diagnose. Omit to check the installation itself.
        path: Option<String>,
    },

    /// Uninstall background daemon, Git hooks, and optionally wipe configuration.
    Uninstall {
        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
        #[arg(long)]
        deep: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigAction {
    /// Display a global configuration value.
    Get {
        /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
        /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
        /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
        /// update_check, update_check_interval_days, update_check_timeout_secs.
        key: String,
    },
    /// Set a global configuration value.
    Set {
        /// Configuration key.
        key: String,
        /// New value.
        value: String,
    },
    /// Show all global configuration values or sync per-repo configurations.
    Show {
        /// Force update/sync pass across all registered repos.
        #[arg(long, short)]
        update: bool,
    },
    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
    Project {
        /// Path to the repository (defaults to current directory).
        #[arg(default_value = ".")]
        path: String,
        /// Force update/sync pass on this project config.
        #[arg(long, short)]
        update: bool,
    },
    /// Configure OS background daemon scheduler globally or for a workspace path.
    Daemon {
        /// Optional workspace path or sub-action (enable, disable, status).
        target: Option<String>,
        /// Sub-action if path was provided (enable, disable, status).
        sub_action: Option<String>,
    },
    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
    Hook {
        /// Optional workspace path or sub-action (enable, disable, status).
        target: Option<String>,
        /// Sub-action if path was provided (enable, disable, status).
        sub_action: Option<String>,
        /// Install in front of the hooks directory already configured, forwarding to it,
        /// instead of refusing to take a slot another tool is using.
        #[arg(long)]
        chain: bool,
    },
    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
    Icon,
    /// Walk through every global setting, confirming or changing each one.
    Wizard,
}

/// Create the `devp` executable alias next to `dev-prune`, and keep it current.
///
/// Runs on every invocation because it is two `stat` calls in the settled case, and
/// because the alias is how most people invoke this tool — it must never be the stale
/// half of an upgrade.
///
/// `DEV_PRUNE_NO_AUTO_SETUP` suppresses it, because writing a second executable next to
/// the first is a self-installation like any other. `devp setup` still creates the alias
/// when the variable is set: the variable governs the unattended pass, not the explicit
/// request.
pub fn ensure_devp_alias() {
    if std::env::var_os(setup::ENV_NO_AUTO_SETUP).is_some() {
        return;
    }
    let _ = setup::ensure_alias();
}

/// Print rich version & system environment details for -v / -V / --version.
pub fn print_version_info() {
    output::print_banner();
    println!("dev-prune (devp) v{}", constants::VERSION);
    println!("  Binary Aliases:  dev-prune | devp (interchangeable)");
    println!("  Target OS:       {}", std::env::consts::OS);
    println!("  Architecture:    {}", std::env::consts::ARCH);
    println!("  Compiler:        Rust 1.85+ (edition 2024)");
    println!("  License:         Apache-2.0 (no analytics, no diagnostics)");
    println!();
    let reg_path = config::Registry::registry_path()
        .map(|p| output::clean_path(&p))
        .unwrap_or_else(|_| "unknown".to_string());
    println!("  Config Path:     {reg_path}");

    if let Ok(exe) = std::env::current_exe() {
        if let Some(exe_dir) = exe.parent() {
            let exe_dir_str = output::clean_path(exe_dir);
            let path_var = std::env::var("PATH").unwrap_or_default();
            let is_in_path = path_var
                .split(if cfg!(windows) { ';' } else { ':' })
                .any(|p| std::path::Path::new(p) == exe_dir);

            println!("  Binary Dir:      {exe_dir_str}");
            if is_in_path {
                println!("  PATH Audit:      ✓ Executable directory is active in system PATH.");
            } else {
                println!("  PATH Audit:      ⚠ Executable directory is NOT in system PATH!");
                println!("                   Add `{exe_dir_str}` to Environment Variables.");
            }
        }
    }
}

/// Case-insensitive subcommand normalizer and status alias router.
fn normalize_args() -> Vec<String> {
    let args: Vec<String> = std::env::args().collect();
    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
        print_version_info();
        std::process::exit(exit_code::OK);
    }
    if args.len() <= 1
        || args
            .iter()
            .any(|a| a == "-h" || a == "--help" || a == "help")
    {
        output::print_banner();
    }
    if args.len() <= 1 {
        return args;
    }

    let mut normalized = vec![args[0].clone()];
    for (i, arg) in args.iter().enumerate().skip(1) {
        if i == 1 && !arg.starts_with('-') {
            normalized.push(arg.to_lowercase());
        } else {
            normalized.push(arg.clone());
        }
    }

    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
    //
    // These live under `config` because that is where the rest of the persistent
    // settings live, but nobody types `devp config hook install` when they mean
    // "install the hook" — and the tool's own output has always said `devp hook
    // install`. Accepting both costs one insert and removes a papercut.
    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
        normalized.insert(1, "config".to_string());
    }

    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
    if normalized.len() >= 3 && normalized[1] == "status" {
        let last = normalized
            .last()
            .map(|s| s.to_lowercase())
            .unwrap_or_default();
        if last == "daemon" || last == "hook" {
            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
            if normalized.len() > 3 {
                rewrited.push(normalized[2].clone());
            }
            rewrited.push("status".to_string());
            return rewrited;
        }
    }

    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
    if normalized.len() >= 4 && normalized[1] == "config" {
        let third = normalized[3].to_lowercase();
        if third == "daemon" || third == "hook" {
            let mut rewrited = vec![
                normalized[0].clone(),
                "config".to_string(),
                third,
                normalized[2].clone(),
            ];
            for extra in &normalized[4..] {
                rewrited.push(extra.clone());
            }
            return rewrited;
        }
    }

    normalized
}

/// Whether the automatic setup pass may run for this invocation.
///
/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
/// for the next command a human types. `uninstall` is excluded for the obvious reason,
/// and `setup` because it is the pass, run deliberately.
fn auto_setup_allowed(args: &[String]) -> bool {
    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
    !matches!(subcommand, "uninstall" | "setup")
        && !args.iter().any(|a| a == "--quiet" || a == "--daemon")
}

/// Run the CLI application.
pub fn run_cli() {
    restore_sigpipe();
    ensure_devp_alias();

    let args = normalize_args();
    if auto_setup_allowed(&args) {
        setup::auto_setup_if_due();
    }
    let cli = Cli::parse_from(args);

    // Both spellings mean the same thing; the old one just says so first.
    let ignore_idle = cli.ignore_idle || cli.force;
    if cli.force {
        print_force_help();
    }

    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
    // registers a directory literally named `~`.
    let result = match cli.command {
        Commands::Init { paths } => {
            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
            commands::init::run(&paths, cli.dry_run)
        }
        Commands::Link { path, quiet } => {
            commands::link::run_link(&config::expand_tilde(&path), quiet)
        }
        Commands::Unlink { path, missing } => {
            if missing {
                commands::link::run_unlink_missing()
            } else {
                commands::link::run_unlink(&config::expand_tilde(&path))
            }
        }
        Commands::Undo => commands::undo::run(),
        Commands::Run {
            target_path,
            daemon,
            only,
            skip,
            min_size,
            except,
            json,
        } => {
            let target_path = target_path.map(|p| config::expand_tilde(&p));
            commands::run::run(commands::run::RunArgs {
                target_path: target_path.as_deref(),
                dry_run: cli.dry_run,
                force: ignore_idle,
                yes: cli.yes,
                daemon,
                only: only.as_deref(),
                skip: skip.as_deref(),
                min_size_mb: min_size,
                except: except.as_deref(),
                json,
            })
        }
        Commands::Status { json } => commands::status::run(json),
        Commands::Caches { json } => commands::caches::run(json),
        Commands::Config { action } => match action {
            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
            Some(ConfigAction::Project { path, update }) => {
                commands::config::run_path_config(&config::expand_tilde(&path), update)
            }
            Some(ConfigAction::Daemon { target, sub_action }) => {
                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
                // target before the match cannot turn one into a path.
                let target = target.map(|t| config::expand_tilde(&t));
                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
                    (Some(t), Some(a)) => (Some(t), a),
                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
                    (Some(t), None) => (Some(t), "status"),
                    (None, Some(a)) => (None, a),
                    (None, None) => (None, "status"),
                };
                commands::config::run_daemon_toggle(path, action)
            }
            Some(ConfigAction::Hook {
                target,
                sub_action,
                chain,
            }) => {
                let target = target.map(|t| config::expand_tilde(&t));
                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
                    (Some(t), Some(a)) => (Some(t), a),
                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
                    (Some(t), None) => (Some(t), "status"),
                    (None, Some(a)) => (None, a),
                    // `--chain` on its own is an install instruction, not a status query.
                    (None, None) if chain => (None, "install"),
                    (None, None) => (None, "status"),
                };
                commands::config::run_hook_toggle(path, action, chain)
            }
            Some(ConfigAction::Icon) => commands::icon::run_install(),
            Some(ConfigAction::Wizard) => commands::config::run_wizard(),
        },
        Commands::Restore { path, last_run } => {
            if last_run {
                commands::restore::run_last_run()
            } else {
                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
            }
        }
        Commands::Update { offline } => commands::update::run(offline),
        Commands::Skill => commands::skill::run(),
        Commands::Setup { status } => commands::setup::run(status),
        Commands::Doctor { path } => {
            let path = path.map(|p| config::expand_tilde(&p));
            commands::doctor::run(path.as_deref())
        }
        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
    };

    if let Err(e) = result {
        if is_broken_pipe(&e) {
            std::process::exit(exit_code::OK);
        }
        output::print_error(&format!("{e:#}"));
        std::process::exit(exit_code::FAILURE);
    }
}