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