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