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