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