Skip to main content

dev_prune/
lib.rs

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