Skip to main content

dev_prune/
lib.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod adapters;
5pub mod commands;
6pub mod config;
7pub mod constants;
8pub mod daemon;
9pub mod engine;
10pub mod json;
11pub mod output;
12pub mod scanner;
13pub mod setup;
14pub mod tui;
15pub mod workspace;
16
17use clap::{Parser, Subcommand};
18
19/// Process exit codes, so scripts and CI can branch on the outcome.
20///
21/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
22/// changing one is a breaking change.
23pub mod exit_code {
24    /// The command did what it was asked to do. A prune that deleted nothing because
25    /// nothing was idle is still a success.
26    pub const OK: i32 = 0;
27    /// The command failed. The reason is on stderr.
28    pub const FAILURE: i32 = 1;
29    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
30    pub const USAGE: i32 = 2;
31}
32
33/// Restore the default disposition for `SIGPIPE`.
34///
35/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
36/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
37/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
38#[cfg(unix)]
39fn restore_sigpipe() {
40    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
41    // thread is spawned.
42    unsafe {
43        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
44    }
45}
46
47#[cfg(not(unix))]
48fn restore_sigpipe() {}
49
50/// Explain the rename, then answer the question the user was really asking.
51///
52/// Nobody types `--force` for fun. They type it because something was not pruned and
53/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
54/// them exactly as stuck as before. The list below is every reason a directory gets
55/// skipped, with the fix, because six of the seven are not what `--force` was for.
56///
57/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
58fn print_force_help() {
59    output::print_notice(
60        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
61         The old spelling still works.",
62    );
63    eprintln!(
64        "
65  Reaching for --force usually means something did not get pruned. It is one of these:
66
67    Not idle yet          A commit or a source edit inside idle_days (15 by default).
68                          This is the one --ignore-idle is for.
69    Lockfile unusable     The package manager could not confirm it. Run the command
70                          dev-prune printed, then try again. No flag skips this check.
71    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
72                          in `.devprune.json`.
73    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
74    Not registered        `devp link .` first; `devp status` shows what is tracked.
75    Nested or symlinked   A submodule is pruned as itself, never as part of its
76                          parent, and a linked directory is refused. By design.
77    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.
78
79  `devp run --dry-run` names the actual reason, per repository.
80
81  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
82  tree, including this list. It has read it. It wrote it.
83"
84    );
85}
86
87/// Whether a failure is just the reader at the other end of a pipe hanging up.
88///
89/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
90/// not an error worth printing — printing it would itself fail.
91fn is_broken_pipe(err: &anyhow::Error) -> bool {
92    err.chain().any(|cause| {
93        cause
94            .downcast_ref::<std::io::Error>()
95            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
96    })
97}
98
99/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
100///
101/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
102#[derive(Parser, Debug)]
103#[command(name = constants::APP_NAME)]
104#[command(version = constants::VERSION)]
105#[command(author = constants::AUTHOR)]
106#[command(long_version = constants::LONG_VERSION.as_str())]
107#[command(
108    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
109)]
110#[command(
111    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 status --top 10      Show only the ten biggest reclaims\n  devp stats                Lifetime totals, recent passes, biggest repositories\n  devp caches               Size every package manager cache (deletes nothing)\n  devp completions powershell   Emit a shell completion script\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.\n\ndev-prune is written by VKrishna04 and licensed Apache-2.0.\n  https://github.com/Life-Experimentalist/dev-prune"
112)]
113pub struct Cli {
114    #[command(subcommand)]
115    command: Commands,
116
117    /// Simulate pruning without deleting any files.
118    #[arg(long, global = true)]
119    dry_run: bool,
120
121    /// Prune repositories you are still working in, ignoring the idle-day threshold.
122    ///
123    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
124    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
125    #[arg(long, global = true)]
126    ignore_idle: bool,
127
128    /// Deprecated spelling of `--ignore-idle`.
129    ///
130    /// Renamed because "force" reads like "override the safety checks", which it never
131    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
132    #[arg(long, global = true)]
133    force: bool,
134
135    /// Bypass interactive confirmation prompts.
136    #[arg(long, short = 'y', global = true)]
137    yes: bool,
138}
139
140#[derive(Subcommand, Debug)]
141pub enum Commands {
142    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
143    #[command(alias = "scan", alias = "onboard")]
144    Init {
145        /// Paths to scan for Git repositories (defaults to current directory).
146        #[arg(default_value = ".")]
147        paths: Vec<String>,
148    },
149
150    /// Register a single Git repository for pruning (defaults to current directory `.`).
151    Link {
152        /// Path to the Git repository to register.
153        #[arg(default_value = ".")]
154        path: String,
155
156        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
157        #[arg(long)]
158        quiet: bool,
159    },
160
161    /// Remove a repository from the dev-prune registry (does not delete workspace files).
162    Unlink {
163        /// Path to the Git repository to unregister.
164        #[arg(default_value = ".")]
165        path: String,
166
167        /// Unregister every path that no longer exists, instead of one named repository.
168        #[arg(long, conflicts_with = "path")]
169        missing: bool,
170    },
171
172    /// Revert the most recent init or link action.
173    Undo,
174
175    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
176    Run {
177        /// Optional target workspace path. If omitted, runs across all registered repositories.
178        target_path: Option<String>,
179
180        /// Mark this as the scheduled background pass. Repositories that set
181        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
182        #[arg(long)]
183        daemon: bool,
184
185        /// Act only on these package managers (comma-separated),
186        /// e.g. `--only npm,pnpm`. Unknown names are an error.
187        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
188        only: Option<String>,
189
190        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
191        #[arg(long, value_name = "ADAPTERS")]
192        skip: Option<String>,
193
194        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
195        #[arg(long, value_name = "MIB")]
196        min_size: Option<u64>,
197
198        /// Prune everything except these repositories (comma-separated paths or names).
199        ///
200        /// The safe way to express "clean up but keep the API project": that project is
201        /// never verified, never deleted and never reinstalled, instead of being pruned
202        /// and then restored over the network.
203        #[arg(long, value_name = "REPOS")]
204        except: Option<String>,
205
206        /// Emit one JSON document instead of the human report. Implies non-interactive.
207        #[arg(long)]
208        json: bool,
209    },
210
211    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
212    Status {
213        /// Show only the N repositories with the most reclaimable space.
214        ///
215        /// The dashboard lists every registered repository, which on a machine with a
216        /// hundred of them buries the handful actually worth pruning. Applies to the TUI,
217        /// the plain table and `--json` alike.
218        ///
219        /// Zero is rejected up front: "show the top 0" can only be a typo, and an empty
220        /// dashboard that looks like an empty registry is worse than a usage error.
221        #[arg(long, value_name = "N", value_parser = clap::value_parser!(u64).range(1..))]
222        top: Option<u64>,
223
224        /// Report lockfile drift instead of the dashboard: environments holding packages
225        /// their lockfile never recorded — the installs a prune would refuse to delete
226        /// because nothing could bring them back.
227        ///
228        /// A pure read: no package manager runs, nothing is written. Checked where a
229        /// file-level comparison exists — npm, uv and venv projects.
230        #[arg(long, conflicts_with = "top")]
231        drift: bool,
232
233        /// Emit the dashboard as one JSON document instead of the TUI or text table.
234        #[arg(long)]
235        json: bool,
236    },
237
238    /// Show lifetime space reclaimed, recent prune passes, and the biggest repositories.
239    Stats {
240        /// Emit the figures as one JSON document instead of the text report.
241        #[arg(long)]
242        json: bool,
243    },
244
245    /// Print a shell completion script for bash, zsh, fish, PowerShell or elvish.
246    Completions {
247        /// Shell to generate for.
248        shell: clap_complete::Shell,
249    },
250
251    /// Report the size of every package manager cache on this machine (read-only, deletes nothing).
252    Caches {
253        /// Emit the report as one JSON document instead of the table.
254        #[arg(long)]
255        json: bool,
256    },
257
258    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
259    Config {
260        #[command(subcommand)]
261        action: Option<ConfigAction>,
262    },
263
264    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
265    Restore {
266        /// Path to the project to restore (defaults to current directory).
267        path: Option<String>,
268
269        /// Put back exactly what the most recent prune pass deleted, in every repository
270        /// it touched. The undo for a `run`.
271        #[arg(long, conflicts_with = "path")]
272        last_run: bool,
273    },
274
275    /// Print the installed version, check for a newer release, and show how to upgrade.
276    Update {
277        /// Skip the release check for this run. The check is the only thing in dev-prune
278        /// that opens a network connection; `devp config set update_check false` turns
279        /// it off for good.
280        #[arg(long)]
281        offline: bool,
282    },
283
284    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
285    Skill,
286
287    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
288    Setup {
289        /// Report what is installed without changing anything.
290        #[arg(long)]
291        status: bool,
292    },
293
294    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
295    Doctor {
296        /// Repository to diagnose. Omit to check the installation itself.
297        path: Option<String>,
298
299        /// Repair what the installation check finds broken: refresh a stale or missing
300        /// `devp` twin, re-export SKILL.md, re-register a scheduler or Git hooks whose
301        /// binary moved, and drop registry entries whose repository is gone.
302        ///
303        /// Repairs only what was installed and has since broken — it never installs an
304        /// integration that was never set up (that is `devp setup`), and it cannot fix a
305        /// corrupt registry file, which needs a human decision.
306        #[arg(long, conflicts_with = "path")]
307        fix: bool,
308    },
309
310    /// Uninstall background daemon, Git hooks, and optionally wipe configuration.
311    Uninstall {
312        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
313        #[arg(long)]
314        deep: bool,
315    },
316}
317
318impl Commands {
319    /// Whether this command's stdout is something another program reads.
320    ///
321    /// Two cases. `--json` promises stdout carries one document and nothing else, and
322    /// `completions` prints a script that gets sourced — a stray line in either is a
323    /// parse error rather than a nicety. `link --quiet` is the Git hook path, which runs
324    /// inside somebody's commit.
325    ///
326    /// Everything else defers to [`output::print_attribution`], which prints only when
327    /// stdout is a terminal. Neither function checks that the line is intact, and nothing
328    /// downstream depends on it having been printed.
329    fn suppresses_attribution(&self) -> bool {
330        match self {
331            Commands::Completions { .. } => true,
332            Commands::Run { json, .. }
333            | Commands::Status { json, .. }
334            | Commands::Stats { json }
335            | Commands::Caches { json } => *json,
336            Commands::Link { quiet, .. } => *quiet,
337            _ => false,
338        }
339    }
340}
341
342#[derive(Subcommand, Debug)]
343pub enum ConfigAction {
344    /// Display a global configuration value.
345    Get {
346        /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
347        /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
348        /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
349        /// update_check, update_check_interval_days, update_check_timeout_secs.
350        key: String,
351    },
352    /// Set a global configuration value.
353    Set {
354        /// Configuration key.
355        key: String,
356        /// New value.
357        value: String,
358    },
359    /// Show all global configuration values or sync per-repo configurations.
360    Show {
361        /// Force update/sync pass across all registered repos.
362        #[arg(long, short)]
363        update: bool,
364    },
365    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
366    Project {
367        /// Path to the repository (defaults to current directory).
368        #[arg(default_value = ".")]
369        path: String,
370        /// Force update/sync pass on this project config.
371        #[arg(long, short)]
372        update: bool,
373    },
374    /// Configure OS background daemon scheduler globally or for a workspace path.
375    Daemon {
376        /// Optional workspace path or sub-action (enable, disable, status).
377        target: Option<String>,
378        /// Sub-action if path was provided (enable, disable, status).
379        sub_action: Option<String>,
380    },
381    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
382    Hook {
383        /// Optional workspace path or sub-action (enable, disable, status).
384        target: Option<String>,
385        /// Sub-action if path was provided (enable, disable, status).
386        sub_action: Option<String>,
387        /// Install in front of the hooks directory already configured, forwarding to it,
388        /// instead of refusing to take a slot another tool is using.
389        #[arg(long)]
390        chain: bool,
391    },
392    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
393    Icon,
394    /// Walk through every global setting, confirming or changing each one.
395    Wizard,
396}
397
398/// Create the `devp` executable alias next to `dev-prune`, and keep it current.
399///
400/// Runs on every invocation because it is two `stat` calls in the settled case, and
401/// because the alias is how most people invoke this tool — it must never be the stale
402/// half of an upgrade.
403///
404/// `DEV_PRUNE_NO_AUTO_SETUP` suppresses it, and so does looking like CI or a container,
405/// because writing a second executable next to the first is a self-installation like any
406/// other — and those environments cannot set the variable before the first run. `devp
407/// setup` still creates the alias in either case: it governs the unattended pass, not
408/// the explicit request.
409pub fn ensure_devp_alias() {
410    if setup::no_auto_setup_requested() || setup::unattended_environment().is_some() {
411        return;
412    }
413    let _ = setup::ensure_alias();
414}
415
416/// Print rich version & system environment details for -v / -V / --version.
417///
418/// This, not clap, is what `devp --version` actually runs — [`normalize_args`] catches the
419/// flag first. The author and repository are printed here because a copy of this binary
420/// found on a machine with no package manager record should still be able to say where it
421/// came from, and `--version` is the first thing anyone runs on an unknown executable.
422pub fn print_version_info() {
423    output::print_banner();
424    println!("dev-prune (devp) v{}", constants::VERSION);
425    println!("  Binary Aliases:  dev-prune | devp (interchangeable)");
426    println!("  Author:          {}", constants::AUTHOR);
427    println!("  Repository:      {}", constants::REPO_URL);
428    println!("  Homepage:        {}", constants::HOMEPAGE_URL);
429    println!("  Target OS:       {}", std::env::consts::OS);
430    println!("  Architecture:    {}", std::env::consts::ARCH);
431    println!("  Compiler:        Rust 1.85+ (edition 2024)");
432    println!("  License:         Apache-2.0 (no analytics, no diagnostics)");
433    println!();
434    let reg_path = config::Registry::registry_path()
435        .map(|p| output::clean_path(&p))
436        .unwrap_or_else(|_| "unknown".to_string());
437    println!("  Config Path:     {reg_path}");
438
439    if let Ok(exe) = std::env::current_exe() {
440        if let Some(exe_dir) = exe.parent() {
441            let exe_dir_str = output::clean_path(exe_dir);
442            let path_var = std::env::var("PATH").unwrap_or_default();
443            let is_in_path = path_var
444                .split(if cfg!(windows) { ';' } else { ':' })
445                .any(|p| std::path::Path::new(p) == exe_dir);
446
447            println!("  Binary Dir:      {exe_dir_str}");
448            if is_in_path {
449                println!("  PATH Audit:      ✓ Executable directory is active in system PATH.");
450            } else {
451                println!("  PATH Audit:      ⚠ Executable directory is NOT in system PATH!");
452                println!("                   Add `{exe_dir_str}` to Environment Variables.");
453            }
454        }
455    }
456}
457
458/// Case-insensitive subcommand normalizer and status alias router.
459fn normalize_args() -> Vec<String> {
460    let args: Vec<String> = std::env::args().collect();
461    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
462        print_version_info();
463        std::process::exit(exit_code::OK);
464    }
465    if args.len() <= 1
466        || args
467            .iter()
468            .any(|a| a == "-h" || a == "--help" || a == "help")
469    {
470        output::print_banner();
471    }
472    if args.len() <= 1 {
473        return args;
474    }
475
476    let mut normalized = vec![args[0].clone()];
477    for (i, arg) in args.iter().enumerate().skip(1) {
478        if i == 1 && !arg.starts_with('-') {
479            normalized.push(arg.to_lowercase());
480        } else {
481            normalized.push(arg.clone());
482        }
483    }
484
485    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
486    //
487    // These live under `config` because that is where the rest of the persistent
488    // settings live, but nobody types `devp config hook install` when they mean
489    // "install the hook" — and the tool's own output has always said `devp hook
490    // install`. Accepting both costs one insert and removes a papercut.
491    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
492        normalized.insert(1, "config".to_string());
493    }
494
495    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
496    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
497    //
498    // Exactly one optional PATH, and never a flag: `devp status --json daemon` must
499    // reach clap as typed and fail there, not be rewritten with `--json` as a path.
500    if normalized[1] == "status"
501        && (normalized.len() == 3 || (normalized.len() == 4 && !normalized[2].starts_with('-')))
502    {
503        let last = normalized
504            .last()
505            .map(|s| s.to_lowercase())
506            .unwrap_or_default();
507        if last == "daemon" || last == "hook" {
508            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
509            if normalized.len() > 3 {
510                rewrited.push(normalized[2].clone());
511            }
512            rewrited.push("status".to_string());
513            return rewrited;
514        }
515    }
516
517    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
518    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
519    //
520    // Only when the second argument can actually be a path — a flag there means the
521    // user is talking to `config` itself and the rewrite would misfile it.
522    if normalized.len() >= 4 && normalized[1] == "config" && !normalized[2].starts_with('-') {
523        let third = normalized[3].to_lowercase();
524        if third == "daemon" || third == "hook" {
525            let mut rewrited = vec![
526                normalized[0].clone(),
527                "config".to_string(),
528                third,
529                normalized[2].clone(),
530            ];
531            for extra in &normalized[4..] {
532                rewrited.push(extra.clone());
533            }
534            return rewrited;
535        }
536    }
537
538    normalized
539}
540
541/// Whether the automatic setup pass may run for this invocation.
542///
543/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
544/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
545/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
546/// for the next command a human types. `uninstall` is excluded for the obvious reason,
547/// and `setup` because it is the pass, run deliberately.
548fn auto_setup_allowed(args: &[String]) -> bool {
549    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
550    !matches!(subcommand, "uninstall" | "setup")
551        && !args.iter().any(|a| a == "--quiet" || a == "--daemon")
552}
553
554/// Run the CLI application.
555pub fn run_cli() {
556    restore_sigpipe();
557    ensure_devp_alias();
558
559    let args = normalize_args();
560    if auto_setup_allowed(&args) {
561        setup::auto_setup_if_due();
562    }
563    let cli = Cli::parse_from(args);
564
565    // Both spellings mean the same thing; the old one just says so first.
566    let ignore_idle = cli.ignore_idle || cli.force;
567    if cli.force {
568        print_force_help();
569    }
570
571    // Decided before the match, because that is where `cli.command` is consumed.
572    let credit_the_author = !cli.command.suppresses_attribution();
573
574    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
575    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
576    // registers a directory literally named `~`.
577    let result = match cli.command {
578        Commands::Init { paths } => {
579            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
580            commands::init::run(&paths, cli.dry_run)
581        }
582        Commands::Link { path, quiet } => {
583            commands::link::run_link(&config::expand_tilde(&path), quiet)
584        }
585        Commands::Unlink { path, missing } => {
586            if missing {
587                commands::link::run_unlink_missing()
588            } else {
589                commands::link::run_unlink(&config::expand_tilde(&path))
590            }
591        }
592        Commands::Undo => commands::undo::run(),
593        Commands::Run {
594            target_path,
595            daemon,
596            only,
597            skip,
598            min_size,
599            except,
600            json,
601        } => {
602            let target_path = target_path.map(|p| config::expand_tilde(&p));
603            commands::run::run(commands::run::RunArgs {
604                target_path: target_path.as_deref(),
605                dry_run: cli.dry_run,
606                force: ignore_idle,
607                yes: cli.yes,
608                daemon,
609                only: only.as_deref(),
610                skip: skip.as_deref(),
611                min_size_mb: min_size,
612                except: except.as_deref(),
613                json,
614            })
615        }
616        Commands::Status { top, drift, json } => {
617            commands::status::run(top.map(|n| n as usize), drift, json)
618        }
619        Commands::Stats { json } => commands::stats::run(json),
620        Commands::Completions { shell } => commands::completions::run(shell),
621        Commands::Caches { json } => commands::caches::run(json),
622        Commands::Config { action } => match action {
623            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
624            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
625            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
626            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
627            Some(ConfigAction::Project { path, update }) => {
628                commands::config::run_path_config(&config::expand_tilde(&path), update)
629            }
630            Some(ConfigAction::Daemon { target, sub_action }) => {
631                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
632                // target before the match cannot turn one into a path.
633                let target = target.map(|t| config::expand_tilde(&t));
634                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
635                    (Some(t), Some(a)) => (Some(t), a),
636                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
637                    (Some(t), None) => (Some(t), "status"),
638                    (None, Some(a)) => (None, a),
639                    (None, None) => (None, "status"),
640                };
641                commands::config::run_daemon_toggle(path, action)
642            }
643            Some(ConfigAction::Hook {
644                target,
645                sub_action,
646                chain,
647            }) => {
648                let target = target.map(|t| config::expand_tilde(&t));
649                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
650                    (Some(t), Some(a)) => (Some(t), a),
651                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
652                    (Some(t), None) => (Some(t), "status"),
653                    (None, Some(a)) => (None, a),
654                    // `--chain` on its own is an install instruction, not a status query.
655                    (None, None) if chain => (None, "install"),
656                    (None, None) => (None, "status"),
657                };
658                commands::config::run_hook_toggle(path, action, chain)
659            }
660            Some(ConfigAction::Icon) => commands::icon::run_install(),
661            Some(ConfigAction::Wizard) => commands::config::run_wizard(),
662        },
663        Commands::Restore { path, last_run } => {
664            if last_run {
665                commands::restore::run_last_run()
666            } else {
667                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
668            }
669        }
670        Commands::Update { offline } => commands::update::run(offline),
671        Commands::Skill => commands::skill::run(),
672        Commands::Setup { status } => commands::setup::run(status),
673        Commands::Doctor { path, fix } => {
674            let path = path.map(|p| config::expand_tilde(&p));
675            commands::doctor::run(path.as_deref(), fix)
676        }
677        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
678    };
679
680    if let Err(e) = result {
681        if is_broken_pipe(&e) {
682            std::process::exit(exit_code::OK);
683        }
684        output::print_error(&format!("{e:#}"));
685        std::process::exit(exit_code::FAILURE);
686    }
687
688    // Only on the way out of a successful run: nobody reading an error message needs a
689    // credit under it.
690    if credit_the_author {
691        output::print_attribution();
692    }
693}