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 channel;
6pub mod commands;
7pub mod config;
8pub mod constants;
9pub mod daemon;
10pub mod engine;
11pub mod help;
12pub mod json;
13pub mod output;
14pub mod pathenv;
15pub mod scanner;
16pub mod setup;
17pub mod spawn;
18pub mod tui;
19pub mod workspace;
20
21use clap::{Parser, Subcommand};
22
23/// Process exit codes, so scripts and CI can branch on the outcome.
24///
25/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
26/// changing one is a breaking change.
27pub mod exit_code {
28    /// The command did what it was asked to do. A prune that deleted nothing because
29    /// nothing was idle is still a success.
30    pub const OK: i32 = 0;
31    /// The command failed. The reason is on stderr.
32    pub const FAILURE: i32 = 1;
33    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
34    pub const USAGE: i32 = 2;
35}
36
37/// The machine's own architecture, reported only when it differs from this build's.
38///
39/// `std::env::consts::ARCH` is baked in at compile time, so a 32-bit build on a 64-bit
40/// machine reports `x86` and looks, to anyone reading it, like a claim about the
41/// hardware. Windows sets [`constants::ENV_NATIVE_ARCH`] under WOW64 and under ARM64
42/// emulation; it is the only thing an emulated process can ask. The names are mapped to
43/// Rust's spellings so the two halves of "x86, but this machine is x86_64" match.
44///
45/// `None` means the build and the machine agree, or the question cannot be answered —
46/// both of which are reported as nothing at all rather than as a guess.
47pub fn native_arch_if_emulated() -> Option<String> {
48    let native = std::env::var(constants::ENV_NATIVE_ARCH).ok()?;
49    let native = native.trim();
50    if native.is_empty() {
51        return None;
52    }
53    let mapped = match native.to_ascii_uppercase().as_str() {
54        "AMD64" => "x86_64".to_string(),
55        "ARM64" => "aarch64".to_string(),
56        "X86" => "x86".to_string(),
57        other => other.to_ascii_lowercase(),
58    };
59    (mapped != std::env::consts::ARCH).then_some(mapped)
60}
61
62/// Marker for errors that are usage mistakes rather than runtime failures.
63///
64/// clap exits `USAGE` for conflicts it can see at parse time; combinations only the
65/// command logic can judge — `run --json` with neither `--dry-run` nor `--yes` — used
66/// to exit `FAILURE`, which told a script "the prune broke" when the truth was "the
67/// command line was incomplete". Raising this instead routes them to `USAGE`.
68#[derive(Debug)]
69pub struct UsageError(pub String);
70
71impl std::fmt::Display for UsageError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(&self.0)
74    }
75}
76
77impl std::error::Error for UsageError {}
78
79/// Restore the default disposition for `SIGPIPE`.
80///
81/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
82/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
83/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
84#[cfg(unix)]
85fn restore_sigpipe() {
86    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
87    // thread is spawned.
88    unsafe {
89        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
90    }
91}
92
93#[cfg(not(unix))]
94fn restore_sigpipe() {}
95
96/// Explain the rename, then answer the question the user was really asking.
97///
98/// Nobody types `--force` for fun. They type it because something was not pruned and
99/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
100/// them exactly as stuck as before. The list below is every reason a directory gets
101/// skipped, with the fix, because six of the seven are not what `--force` was for.
102///
103/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
104fn print_force_help() {
105    output::print_notice(
106        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
107         The old spelling still works.",
108    );
109    eprintln!(
110        "
111  Reaching for --force usually means something did not get pruned. It is one of these:
112
113    Not idle yet          A commit or a source edit inside idle_days (15 by default).
114                          This is the one --ignore-idle is for.
115    Lockfile unusable     The package manager could not confirm it. Run the command
116                          dev-prune printed, then try again. No flag skips this check.
117    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
118                          in `.devprune.json`.
119    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
120    Not registered        `devp link .` first; `devp status` shows what is tracked.
121    Nested or symlinked   A submodule is pruned as itself, never as part of its
122                          parent, and a linked directory is refused. By design.
123    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.
124
125  `devp run --dry-run` names the actual reason, per repository.
126
127  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
128  tree, including this list. It has read it. It wrote it.
129"
130    );
131}
132
133/// Whether a failure is just the reader at the other end of a pipe hanging up.
134///
135/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
136/// not an error worth printing — printing it would itself fail.
137fn is_broken_pipe(err: &anyhow::Error) -> bool {
138    err.chain().any(|cause| {
139        cause
140            .downcast_ref::<std::io::Error>()
141            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
142    })
143}
144
145/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
146///
147/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
148#[derive(Parser, Debug)]
149#[command(name = constants::APP_NAME)]
150#[command(version = constants::VERSION)]
151#[command(author = constants::AUTHOR)]
152#[command(long_version = constants::LONG_VERSION.as_str())]
153#[command(
154    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
155)]
156#[command(
157    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"
158)]
159pub struct Cli {
160    #[command(subcommand)]
161    command: Commands,
162
163    /// Simulate pruning without deleting any files.
164    #[arg(long, global = true)]
165    dry_run: bool,
166
167    /// Prune repositories you are still working in, ignoring the idle-day threshold.
168    ///
169    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
170    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
171    #[arg(long, global = true)]
172    ignore_idle: bool,
173
174    /// Deprecated spelling of `--ignore-idle`.
175    ///
176    /// Renamed because "force" reads like "override the safety checks", which it never
177    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
178    #[arg(long, global = true)]
179    force: bool,
180
181    /// Bypass interactive confirmation prompts.
182    #[arg(long, short = 'y', global = true)]
183    yes: bool,
184}
185
186#[derive(Subcommand, Debug)]
187pub enum Commands {
188    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
189    #[command(alias = "scan", alias = "onboard")]
190    #[command(long_about = help::INIT_LONG, after_long_help = help::INIT_EXAMPLES)]
191    Init {
192        /// Paths to scan for Git repositories (defaults to current directory).
193        #[arg(default_value = ".")]
194        paths: Vec<String>,
195    },
196
197    /// Register a single Git repository for pruning (defaults to current directory `.`).
198    #[command(long_about = help::LINK_LONG, after_long_help = help::LINK_EXAMPLES)]
199    Link {
200        /// Path to the Git repository to register.
201        #[arg(default_value = ".")]
202        path: String,
203
204        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
205        #[arg(long)]
206        quiet: bool,
207    },
208
209    /// Remove a repository from the dev-prune registry (does not delete workspace files).
210    #[command(long_about = help::UNLINK_LONG, after_long_help = help::UNLINK_EXAMPLES)]
211    Unlink {
212        /// Path to the Git repository to unregister.
213        #[arg(default_value = ".")]
214        path: String,
215
216        /// Unregister every path that no longer exists, instead of one named repository.
217        #[arg(long, conflicts_with = "path")]
218        missing: bool,
219    },
220
221    /// Revert the most recent init or link action.
222    #[command(long_about = help::UNDO_LONG, after_long_help = help::UNDO_EXAMPLES)]
223    Undo,
224
225    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
226    #[command(long_about = help::RUN_LONG, after_long_help = help::RUN_EXAMPLES)]
227    Run {
228        /// Optional target workspace path. If omitted, runs across all registered repositories.
229        target_path: Option<String>,
230
231        /// Mark this as the scheduled background pass. Repositories that set
232        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
233        #[arg(long)]
234        daemon: bool,
235
236        /// Act only on these package managers (comma-separated),
237        /// e.g. `--only npm,pnpm`. Unknown names are an error.
238        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
239        only: Option<String>,
240
241        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
242        #[arg(long, value_name = "ADAPTERS")]
243        skip: Option<String>,
244
245        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
246        #[arg(long, value_name = "MIB")]
247        min_size: Option<u64>,
248
249        /// Prune everything except these repositories (comma-separated paths or names).
250        ///
251        /// The safe way to express "clean up but keep the API project": that project is
252        /// never verified, never deleted and never reinstalled, instead of being pruned
253        /// and then restored over the network.
254        #[arg(long, value_name = "REPOS")]
255        except: Option<String>,
256
257        /// Emit one JSON document instead of the human report. Implies non-interactive.
258        #[arg(long)]
259        json: bool,
260
261        /// Explain every decision instead of pruning: each repository and directory,
262        /// with the reason it would or would not be touched — including the states a
263        /// normal pass keeps quiet about (still active, opted out, under the size
264        /// floor). Read-only; nothing is verified or deleted.
265        #[arg(long, conflicts_with = "json")]
266        explain: bool,
267    },
268
269    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
270    #[command(long_about = help::STATUS_LONG, after_long_help = help::STATUS_EXAMPLES)]
271    Status {
272        /// Show only the N repositories with the most reclaimable space.
273        ///
274        /// The dashboard lists every registered repository, which on a machine with a
275        /// hundred of them buries the handful actually worth pruning. Applies to the TUI,
276        /// the plain table and `--json` alike.
277        ///
278        /// Zero is rejected up front: "show the top 0" can only be a typo, and an empty
279        /// dashboard that looks like an empty registry is worse than a usage error.
280        #[arg(long, value_name = "N", value_parser = clap::value_parser!(u64).range(1..))]
281        top: Option<u64>,
282
283        /// Report lockfile drift instead of the dashboard: environments holding packages
284        /// their lockfile never recorded — the installs a prune would refuse to delete
285        /// because nothing could bring them back.
286        ///
287        /// A pure read: no package manager runs, nothing is written. Checked where a
288        /// file-level comparison exists — npm, uv and venv projects.
289        #[arg(long, conflicts_with = "top")]
290        drift: bool,
291
292        /// Emit the dashboard as one JSON document instead of the TUI or text table.
293        #[arg(long)]
294        json: bool,
295    },
296
297    /// Show lifetime space reclaimed, recent prune passes, and the biggest repositories.
298    #[command(long_about = help::STATS_LONG, after_long_help = help::STATS_EXAMPLES)]
299    Stats {
300        /// Emit the figures as one JSON document instead of the text report.
301        #[arg(long)]
302        json: bool,
303    },
304
305    /// Report what dev-prune is allowed to do on this machine, and what it has been given permission to do.
306    #[command(long_about = help::TRUST_LONG, after_long_help = help::TRUST_EXAMPLES)]
307    Trust {
308        /// Emit the report as one JSON document instead of the table.
309        #[arg(long)]
310        json: bool,
311
312        /// Let Git read registered repositories it currently refuses on ownership.
313        #[arg(long, conflicts_with = "json")]
314        fix_ownership: bool,
315
316        /// Answer yes to the confirmation `--fix-ownership` asks.
317        #[arg(long, requires = "fix_ownership")]
318        yes: bool,
319    },
320
321    /// Print a shell completion script for bash, zsh, fish, PowerShell or elvish.
322    #[command(long_about = help::COMPLETIONS_LONG, after_long_help = help::COMPLETIONS_EXAMPLES)]
323    Completions {
324        /// Shell to generate for.
325        shell: clap_complete::Shell,
326    },
327
328    /// Print or write man pages, generated from the same definitions `--help` prints.
329    #[command(long_about = help::MAN_LONG, after_long_help = help::MAN_EXAMPLES)]
330    Man {
331        /// The command whose page to read, e.g. `devp man run`. Omit for the
332        /// contents page.
333        command: Option<String>,
334
335        /// Write `devp.1` plus one `devp-<command>.1` per subcommand into this
336        /// directory, instead of rendering the main page to stdout.
337        #[arg(long, value_name = "DIR")]
338        dir: Option<String>,
339
340        /// Print the roff source even when stdout is a terminal, instead of the
341        /// readable manual.
342        #[arg(long)]
343        roff: bool,
344    },
345
346    /// Report the size of every package manager cache on this machine (read-only unless you ask for `clear`).
347    #[command(long_about = help::CACHES_LONG, after_long_help = help::CACHES_EXAMPLES)]
348    Caches {
349        /// Emit the report as one JSON document instead of the table.
350        ///
351        /// Global within `caches` so it can be written after the subcommand too —
352        /// `devp caches clear npm --json` is what everyone types.
353        #[arg(long, global = true)]
354        json: bool,
355
356        #[command(subcommand)]
357        action: Option<CachesAction>,
358    },
359
360    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
361    #[command(long_about = help::CONFIG_LONG, after_long_help = help::CONFIG_EXAMPLES)]
362    Config {
363        #[command(subcommand)]
364        action: Option<ConfigAction>,
365    },
366
367    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
368    #[command(long_about = help::RESTORE_LONG, after_long_help = help::RESTORE_EXAMPLES)]
369    Restore {
370        /// Path to the project to restore (defaults to current directory).
371        path: Option<String>,
372
373        /// Put back exactly what the most recent prune pass deleted, in every repository
374        /// it touched. The undo for a `run`.
375        #[arg(long, conflicts_with = "path")]
376        last_run: bool,
377    },
378
379    /// Print the installed version, check for a newer release, and show how to upgrade.
380    #[command(long_about = help::UPDATE_LONG, after_long_help = help::UPDATE_EXAMPLES)]
381    Update {
382        /// Skip the release check for this run. The check is the only thing in dev-prune
383        /// that opens a network connection; `devp config set update_check false` turns
384        /// it off for good.
385        #[arg(long)]
386        offline: bool,
387
388        /// Download and install the newer release, through whichever package manager
389        /// installed this copy (cargo, npm, uv, pipx, or the installer script). Needs
390        /// the network, so it cannot be combined with `--offline`.
391        #[arg(long, conflicts_with = "offline")]
392        install: bool,
393    },
394
395    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
396    #[command(long_about = help::SKILL_LONG, after_long_help = help::SKILL_EXAMPLES)]
397    Skill {
398        /// Write rules for one editor's agent into the current repository instead.
399        /// Each value below names the exact file it writes. Five of them —
400        /// `agents-md`, `copilot`, `gemini`, `junie`, `zed` — share a file with
401        /// other tools, so dev-prune owns a marked block inside it and leaves every
402        /// byte outside the markers as found. Claude Code needs no per-repo file:
403        /// plain `devp skill` installs its skill globally.
404        #[arg(long, value_enum, value_name = "EDITOR")]
405        agent: Option<commands::skill::AgentEditor>,
406    },
407
408    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
409    #[command(long_about = help::SETUP_LONG, after_long_help = help::SETUP_EXAMPLES)]
410    Setup {
411        /// Report what is installed without changing anything.
412        #[arg(long)]
413        status: bool,
414    },
415
416    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
417    #[command(long_about = help::DOCTOR_LONG, after_long_help = help::DOCTOR_EXAMPLES)]
418    Doctor {
419        /// Repository to diagnose. Omit to check the installation itself.
420        path: Option<String>,
421
422        /// Repair what the installation check finds broken: refresh a stale or missing
423        /// `devp` twin, re-export SKILL.md, re-register a scheduler or Git hooks whose
424        /// binary moved, and drop registry entries whose repository is gone.
425        ///
426        /// Repairs only what was installed and has since broken — it never installs an
427        /// integration that was never set up (that is `devp setup`), and it cannot fix a
428        /// corrupt registry file, which needs a human decision.
429        #[arg(long, conflicts_with = "path")]
430        fix: bool,
431    },
432
433    /// Move this install to another package manager: `devp install --channel uv`.
434    #[command(long_about = help::INSTALL_LONG, after_long_help = help::INSTALL_EXAMPLES)]
435    Install {
436        /// The package manager to move this installation to. Omit to print which one
437        /// owns the running copy, and the names this flag accepts.
438        #[arg(long, value_enum, value_name = "NAME")]
439        channel: Option<commands::install::TargetChannel>,
440
441        /// Print the commands that would run, and run none of them.
442        #[arg(long)]
443        dry_run: bool,
444    },
445
446    /// Remove dev-prune: scheduler, hooks, PATH entry, agent skill, and every copy of the binary.
447    #[command(long_about = help::UNINSTALL_LONG, after_long_help = help::UNINSTALL_EXAMPLES)]
448    Uninstall {
449        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
450        #[arg(long)]
451        deep: bool,
452    },
453}
454
455impl Commands {
456    /// Whether this command's stdout is something another program reads.
457    ///
458    /// Two cases. `--json` promises stdout carries one document and nothing else, and
459    /// `completions` prints a script that gets sourced — a stray line in either is a
460    /// parse error rather than a nicety. `link --quiet` is the Git hook path, which runs
461    /// inside somebody's commit.
462    ///
463    /// Everything else defers to [`output::print_attribution`], which prints only when
464    /// stdout is a terminal. Neither function checks that the line is intact, and nothing
465    /// downstream depends on it having been printed.
466    fn suppresses_attribution(&self) -> bool {
467        match self {
468            Commands::Completions { .. } | Commands::Man { .. } => true,
469            Commands::Run { json, .. }
470            | Commands::Status { json, .. }
471            | Commands::Stats { json }
472            | Commands::Trust { json, .. }
473            | Commands::Caches { json, .. } => *json,
474            Commands::Link { quiet, .. } => *quiet,
475            _ => false,
476        }
477    }
478}
479
480#[derive(Subcommand, Debug)]
481pub enum CachesAction {
482    /// Empty one manager's cache, or every one of them, after showing what goes and asking.
483    #[command(long_about = help::CACHES_CLEAR_LONG, after_long_help = help::CACHES_CLEAR_EXAMPLES)]
484    Clear {
485        /// Which cache to empty: a manager name (npm, go, cargo, gradle, …) or `all`.
486        #[arg(value_name = "MANAGER")]
487        target: String,
488
489        /// Only empty caches that are over the size cap set for them in
490        /// `cache_max_gb`. Without a cap set for anything, this clears nothing.
491        #[arg(long)]
492        over_cap: bool,
493
494        /// Only empty caches that no registered repository uses. Refuses to run when
495        /// there are no registered repositories to check against.
496        #[arg(long)]
497        unused: bool,
498    },
499}
500
501#[derive(Subcommand, Debug)]
502pub enum ConfigAction {
503    /// Display a global configuration value.
504    #[command(long_about = help::CONFIG_GET_LONG, after_long_help = help::CONFIG_GET_EXAMPLES)]
505    Get {
506        /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
507        /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
508        /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
509        /// update_check, update_check_interval_days, update_check_timeout_secs.
510        key: String,
511    },
512    /// Set a global configuration value.
513    #[command(long_about = help::CONFIG_SET_LONG, after_long_help = help::CONFIG_SET_EXAMPLES)]
514    Set {
515        /// Configuration key.
516        key: String,
517        /// New value.
518        value: String,
519    },
520    /// Show all global configuration values or sync per-repo configurations.
521    #[command(long_about = help::CONFIG_SHOW_LONG, after_long_help = help::CONFIG_SHOW_EXAMPLES)]
522    Show {
523        /// Force update/sync pass across all registered repos.
524        #[arg(long, short)]
525        update: bool,
526    },
527    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
528    #[command(long_about = help::CONFIG_PROJECT_LONG, after_long_help = help::CONFIG_PROJECT_EXAMPLES)]
529    Project {
530        /// Path to the repository (defaults to current directory).
531        #[arg(default_value = ".")]
532        path: String,
533        /// Force update/sync pass on this project config.
534        #[arg(long, short)]
535        update: bool,
536    },
537    /// Configure OS background daemon scheduler globally or for a workspace path.
538    #[command(long_about = help::CONFIG_DAEMON_LONG, after_long_help = help::CONFIG_DAEMON_EXAMPLES)]
539    Daemon {
540        /// Optional workspace path or sub-action (enable, disable, status).
541        target: Option<String>,
542        /// Sub-action if path was provided (enable, disable, status).
543        sub_action: Option<String>,
544    },
545    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
546    #[command(long_about = help::CONFIG_HOOK_LONG, after_long_help = help::CONFIG_HOOK_EXAMPLES)]
547    Hook {
548        /// Optional workspace path or sub-action (enable, disable, status).
549        target: Option<String>,
550        /// Sub-action if path was provided (enable, disable, status).
551        sub_action: Option<String>,
552        /// Install in front of the hooks directory already configured, forwarding to it,
553        /// instead of refusing to take a slot another tool is using.
554        #[arg(long)]
555        chain: bool,
556    },
557    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
558    #[command(long_about = help::CONFIG_ICON_LONG, after_long_help = help::CONFIG_ICON_EXAMPLES)]
559    Icon,
560    /// Walk through every global setting, confirming or changing each one.
561    #[command(long_about = help::CONFIG_WIZARD_LONG, after_long_help = help::CONFIG_WIZARD_EXAMPLES)]
562    Wizard {
563        /// Ask one question per line instead of opening the full-screen configurator.
564        #[arg(long)]
565        no_tui: bool,
566    },
567}
568
569/// Print rich version & system environment details for -v / -V / --version.
570///
571/// This, not clap, is what `devp --version` actually runs — [`normalize_args`] catches the
572/// flag first. The author and repository are printed here because a copy of this binary
573/// found on a machine with no package manager record should still be able to say where it
574/// came from, and `--version` is the first thing anyone runs on an unknown executable.
575pub fn print_version_info() {
576    use colored::Colorize;
577    output::print_banner();
578    println!(
579        "dev-prune (devp) {}",
580        format!("v{}", constants::VERSION).green().bold()
581    );
582    println!(
583        "  Binary Aliases:  {} | {}",
584        "dev-prune".cyan(),
585        "devp".cyan()
586    );
587    // These lines are facts, not status, so most of them stay in the terminal's own
588    // colour. The author line was turquoise and the OS and architecture yellow, which
589    // marked nothing and put five hues on one short screen; yellow now only ever means a
590    // warning, and cyan is reserved for the two things worth clicking.
591    println!("  Author:          {}", constants::AUTHOR);
592    println!(
593        "  Repository:      {}",
594        constants::REPO_URL.cyan().underline()
595    );
596    println!(
597        "  Homepage:        {}",
598        constants::HOMEPAGE_URL.cyan().underline()
599    );
600    println!("  Target OS:       {}", std::env::consts::OS);
601    match native_arch_if_emulated() {
602        // Without this the line reads as a statement about the machine, and a 32-bit
603        // build on a 64-bit laptop looks like the laptop is 32-bit.
604        Some(native) => println!(
605            "  Architecture:    {} {}",
606            std::env::consts::ARCH,
607            format!(
608                "(this build — the machine is {native}; `devp update` installs the native one)"
609            )
610            .yellow()
611        ),
612        None => println!("  Architecture:    {}", std::env::consts::ARCH),
613    }
614    println!(
615        "  Compiler:        Rust {}+ (edition 2024)",
616        constants::MSRV
617    );
618    println!("  License:         Apache-2.0");
619    println!();
620    let reg_path = config::Registry::registry_path()
621        .map(output::styled_path)
622        .unwrap_or_else(|_| "unknown".to_string());
623    println!("  Config Path:     {reg_path}");
624
625    if let Ok(exe) = std::env::current_exe()
626        && let Some(exe_dir) = exe.parent()
627    {
628        let exe_dir_str = output::clean_path(exe_dir);
629        // The same tolerant comparison the PATH writer uses — a trailing backslash or a
630        // case difference must not turn the audit line red on a healthy install.
631        let path_var = std::env::var("PATH").unwrap_or_default();
632        let exe_dir_entry = exe_dir.to_string_lossy();
633        let is_in_path = path_var
634            .split(if cfg!(windows) { ';' } else { ':' })
635            .any(|p| pathenv::entries_equal(p, &exe_dir_entry));
636
637        println!("  Binary Dir:      {}", exe_dir_str.cyan());
638        if is_in_path {
639            println!(
640                "  PATH Audit:      {}",
641                "✓ Executable directory is active in system PATH.".green()
642            );
643        } else {
644            println!(
645                "  PATH Audit:      {}",
646                "⚠ Executable directory is NOT in system PATH!".yellow()
647            );
648            println!(
649                "                   Add `{}` to Environment Variables.",
650                exe_dir_str.cyan()
651            );
652        }
653    }
654}
655
656/// Case-insensitive subcommand normalizer and status alias router.
657fn normalize_args() -> Vec<String> {
658    let args: Vec<String> = std::env::args().collect();
659    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
660        print_version_info();
661        std::process::exit(exit_code::OK);
662    }
663    if args.len() <= 1
664        || args
665            .iter()
666            .any(|a| a == "-h" || a == "--help" || a == "help")
667    {
668        output::print_banner();
669    }
670    if args.len() <= 1 {
671        return args;
672    }
673
674    let mut normalized = vec![args[0].clone()];
675    for (i, arg) in args.iter().enumerate().skip(1) {
676        if i == 1 && !arg.starts_with('-') {
677            normalized.push(arg.to_lowercase());
678        } else {
679            normalized.push(arg.clone());
680        }
681    }
682
683    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
684    //
685    // These live under `config` because that is where the rest of the persistent
686    // settings live, but nobody types `devp config hook install` when they mean
687    // "install the hook" — and the tool's own output has always said `devp hook
688    // install`. Accepting both costs one insert and removes a papercut.
689    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
690        normalized.insert(1, "config".to_string());
691    }
692
693    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
694    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
695    //
696    // Exactly one optional PATH, and never a flag: `devp status --json daemon` must
697    // reach clap as typed and fail there, not be rewritten with `--json` as a path.
698    if normalized[1] == "status"
699        && (normalized.len() == 3 || (normalized.len() == 4 && !normalized[2].starts_with('-')))
700    {
701        let last = normalized
702            .last()
703            .map(|s| s.to_lowercase())
704            .unwrap_or_default();
705        if last == "daemon" || last == "hook" {
706            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
707            if normalized.len() > 3 {
708                rewrited.push(normalized[2].clone());
709            }
710            rewrited.push("status".to_string());
711            return rewrited;
712        }
713    }
714
715    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
716    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
717    //
718    // Only when the second argument can actually be a path — a flag there means the
719    // user is talking to `config` itself and the rewrite would misfile it.
720    if normalized.len() >= 4 && normalized[1] == "config" && !normalized[2].starts_with('-') {
721        let third = normalized[3].to_lowercase();
722        if third == "daemon" || third == "hook" {
723            let mut rewrited = vec![
724                normalized[0].clone(),
725                "config".to_string(),
726                third,
727                normalized[2].clone(),
728            ];
729            for extra in &normalized[4..] {
730                rewrited.push(extra.clone());
731            }
732            return rewrited;
733        }
734    }
735
736    normalized
737}
738
739/// Whether the automatic setup pass may run for this invocation.
740///
741/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
742/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
743/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
744/// for the next command a human types. `uninstall` is excluded for the obvious reason,
745/// and `setup` because it is the pass, run deliberately.
746fn auto_setup_allowed(args: &[String]) -> bool {
747    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
748    // `--json` means a program is parsing stdout; the setup report and the first-run
749    // wizard would land inside the document. That invocation waits too.
750    !matches!(subcommand, "uninstall" | "setup")
751        && !args
752            .iter()
753            .any(|a| a == "--quiet" || a == "--daemon" || a == "--json")
754}
755
756/// Run the CLI application.
757pub fn run_cli() {
758    restore_sigpipe();
759
760    let args = normalize_args();
761    if auto_setup_allowed(&args) {
762        setup::auto_setup_if_due();
763    }
764    let cli = Cli::parse_from(args);
765
766    // Both spellings mean the same thing; the old one just says so first.
767    let ignore_idle = cli.ignore_idle || cli.force;
768    if cli.force {
769        print_force_help();
770    }
771
772    // Decided before the match, because that is where `cli.command` is consumed.
773    let credit_the_author = !cli.command.suppresses_attribution();
774
775    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
776    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
777    // registers a directory literally named `~`.
778    let result = match cli.command {
779        Commands::Init { paths } => {
780            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
781            commands::init::run(&paths, cli.dry_run)
782        }
783        Commands::Link { path, quiet } => {
784            commands::link::run_link(&config::expand_tilde(&path), quiet)
785        }
786        Commands::Unlink { path, missing } => {
787            if missing {
788                commands::link::run_unlink_missing()
789            } else {
790                commands::link::run_unlink(&config::expand_tilde(&path))
791            }
792        }
793        Commands::Undo => commands::undo::run(),
794        Commands::Run {
795            target_path,
796            daemon,
797            only,
798            skip,
799            min_size,
800            except,
801            json,
802            explain,
803        } => {
804            let target_path = target_path.map(|p| config::expand_tilde(&p));
805            commands::run::run(commands::run::RunArgs {
806                target_path: target_path.as_deref(),
807                dry_run: cli.dry_run,
808                force: ignore_idle,
809                yes: cli.yes,
810                daemon,
811                only: only.as_deref(),
812                skip: skip.as_deref(),
813                min_size_mb: min_size,
814                except: except.as_deref(),
815                json,
816                explain,
817            })
818        }
819        Commands::Status { top, drift, json } => {
820            commands::status::run(top.map(|n| n as usize), drift, json)
821        }
822        Commands::Stats { json } => commands::stats::run(json),
823        Commands::Completions { shell } => commands::completions::run(shell),
824        Commands::Man { command, dir, roff } => {
825            commands::man::run(command.as_deref(), dir.as_deref(), roff)
826        }
827        Commands::Trust {
828            json,
829            fix_ownership,
830            yes,
831        } => {
832            if fix_ownership {
833                commands::trust::fix_ownership(yes)
834            } else {
835                commands::trust::run(json)
836            }
837        }
838        Commands::Caches { json, action } => match action {
839            Some(CachesAction::Clear {
840                target,
841                over_cap,
842                unused,
843            }) => {
844                commands::caches::run_clear(&target, over_cap, unused, cli.yes, cli.dry_run, json)
845            }
846            None => commands::caches::run(json),
847        },
848        Commands::Config { action } => match action {
849            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
850            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
851            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
852            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
853            Some(ConfigAction::Project { path, update }) => {
854                commands::config::run_path_config(&config::expand_tilde(&path), update)
855            }
856            Some(ConfigAction::Daemon { target, sub_action }) => {
857                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
858                // target before the match cannot turn one into a path.
859                let target = target.map(|t| config::expand_tilde(&t));
860                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
861                    (Some(t), Some(a)) => (Some(t), a),
862                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
863                    (Some(t), None) => (Some(t), "status"),
864                    (None, Some(a)) => (None, a),
865                    (None, None) => (None, "status"),
866                };
867                commands::config::run_daemon_toggle(path, action)
868            }
869            Some(ConfigAction::Hook {
870                target,
871                sub_action,
872                chain,
873            }) => {
874                let target = target.map(|t| config::expand_tilde(&t));
875                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
876                    (Some(t), Some(a)) => (Some(t), a),
877                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
878                    (Some(t), None) => (Some(t), "status"),
879                    (None, Some(a)) => (None, a),
880                    // `--chain` on its own is an install instruction, not a status query.
881                    (None, None) if chain => (None, "install"),
882                    (None, None) => (None, "status"),
883                };
884                commands::config::run_hook_toggle(path, action, chain)
885            }
886            Some(ConfigAction::Icon) => commands::icon::run_install(),
887            Some(ConfigAction::Wizard { no_tui }) => commands::config::run_wizard(no_tui),
888        },
889        Commands::Restore { path, last_run } => {
890            if last_run {
891                commands::restore::run_last_run()
892            } else {
893                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
894            }
895        }
896        Commands::Update { offline, install } => commands::update::run(offline, install),
897        Commands::Skill { agent } => commands::skill::run(agent),
898        Commands::Setup { status } => commands::setup::run(status),
899        Commands::Doctor { path, fix } => {
900            let path = path.map(|p| config::expand_tilde(&p));
901            commands::doctor::run(path.as_deref(), fix)
902        }
903        Commands::Install { channel, dry_run } => commands::install::run(channel, dry_run, cli.yes),
904        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
905    };
906
907    if let Err(e) = result {
908        if is_broken_pipe(&e) {
909            std::process::exit(exit_code::OK);
910        }
911        output::print_error(&format!("{e:#}"));
912        if e.downcast_ref::<UsageError>().is_some() {
913            std::process::exit(exit_code::USAGE);
914        }
915        std::process::exit(exit_code::FAILURE);
916    }
917
918    // Only on the way out of a successful run: nobody reading an error message needs a
919    // credit under it.
920    if credit_the_author {
921        output::print_attribution();
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::auto_setup_allowed;
928
929    fn args(rest: &[&str]) -> Vec<String> {
930        std::iter::once("devp")
931            .chain(rest.iter().copied())
932            .map(String::from)
933            .collect()
934    }
935
936    #[test]
937    fn ordinary_interactive_commands_may_auto_setup() {
938        assert!(auto_setup_allowed(&args(&["status"])));
939        assert!(auto_setup_allowed(&args(&["run", "--dry-run"])));
940        assert!(auto_setup_allowed(&args(&[])));
941    }
942
943    #[test]
944    fn unattended_and_machine_read_invocations_may_not() {
945        // The Git hook, the scheduler, and any `--json` consumer: a setup pass nobody
946        // can see is one nobody can refuse, and setup output inside a JSON document is
947        // a parse error.
948        assert!(!auto_setup_allowed(&args(&["link", ".", "--quiet"])));
949        assert!(!auto_setup_allowed(&args(&["run", "--daemon"])));
950        assert!(!auto_setup_allowed(&args(&["status", "--json"])));
951        assert!(!auto_setup_allowed(&args(&["uninstall"])));
952        assert!(!auto_setup_allowed(&args(&["setup"])));
953    }
954}