dev-prune 1.7.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

pub mod adapters;
pub mod channel;
pub mod commands;
pub mod config;
pub mod constants;
pub mod daemon;
pub mod engine;
pub mod help;
pub mod json;
pub mod output;
pub mod pathenv;
pub mod scanner;
pub mod setup;
pub mod spawn;
pub mod tui;
pub mod workspace;

use clap::{Parser, Subcommand};

/// Process exit codes, so scripts and CI can branch on the outcome.
///
/// These are part of the tool's contract and are documented in `docs/CLI_REFERENCE.md`;
/// changing one is a breaking change.
pub mod exit_code {
    /// The command did what it was asked to do. A prune that deleted nothing because
    /// nothing was idle is still a success.
    pub const OK: i32 = 0;
    /// The command failed. The reason is on stderr.
    pub const FAILURE: i32 = 1;
    /// The arguments were not usable. Emitted by clap, listed here so the set is complete.
    pub const USAGE: i32 = 2;
}

/// The machine's own architecture, reported only when it differs from this build's.
///
/// `std::env::consts::ARCH` is baked in at compile time, so a 32-bit build on a 64-bit
/// machine reports `x86` and looks, to anyone reading it, like a claim about the
/// hardware. Windows sets [`constants::ENV_NATIVE_ARCH`] under WOW64 and under ARM64
/// emulation; it is the only thing an emulated process can ask. The names are mapped to
/// Rust's spellings so the two halves of "x86, but this machine is x86_64" match.
///
/// `None` means the build and the machine agree, or the question cannot be answered —
/// both of which are reported as nothing at all rather than as a guess.
pub fn native_arch_if_emulated() -> Option<String> {
    let native = std::env::var(constants::ENV_NATIVE_ARCH).ok()?;
    let native = native.trim();
    if native.is_empty() {
        return None;
    }
    let mapped = match native.to_ascii_uppercase().as_str() {
        "AMD64" => "x86_64".to_string(),
        "ARM64" => "aarch64".to_string(),
        "X86" => "x86".to_string(),
        other => other.to_ascii_lowercase(),
    };
    (mapped != std::env::consts::ARCH).then_some(mapped)
}

/// Marker for errors that are usage mistakes rather than runtime failures.
///
/// clap exits `USAGE` for conflicts it can see at parse time; combinations only the
/// command logic can judge — `run --json` with neither `--dry-run` nor `--yes` — used
/// to exit `FAILURE`, which told a script "the prune broke" when the truth was "the
/// command line was incomplete". Raising this instead routes them to `USAGE`.
#[derive(Debug)]
pub struct UsageError(pub String);

impl std::fmt::Display for UsageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for UsageError {}

/// Restore the default disposition for `SIGPIPE`.
///
/// Rust ignores `SIGPIPE` at startup, which turns `devp status | head` into a panic —
/// "failed printing to stdout" plus a backtrace — where every other Unix tool simply
/// stops. Putting the default back makes dev-prune behave like `ls` in a pipeline.
#[cfg(unix)]
fn restore_sigpipe() {
    // SAFETY: `signal` with SIG_DFL is async-signal-safe and this runs before any
    // thread is spawned.
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
    }
}

#[cfg(not(unix))]
fn restore_sigpipe() {}

/// Explain the rename, then answer the question the user was really asking.
///
/// Nobody types `--force` for fun. They type it because something was not pruned and
/// they want the tool to stop arguing — so a bare "the flag moved" note would leave
/// them exactly as stuck as before. The list below is every reason a directory gets
/// skipped, with the fix, because six of the seven are not what `--force` was for.
///
/// Goes to stderr with the rest of the diagnostics, so `--json` stays parseable.
fn print_force_help() {
    output::print_notice(
        "`--force` is now `--ignore-idle`, which is what it has always actually done. \
         The old spelling still works.",
    );
    eprintln!(
        "
  Reaching for --force usually means something did not get pruned. It is one of these:

    Not idle yet          A commit or a source edit inside idle_days (15 by default).
                          This is the one --ignore-idle is for.
    Lockfile unusable     The package manager could not confirm it. Run the command
                          dev-prune printed, then try again. No flag skips this check.
    Opted out             `ignore.devprune.json` in the root, or `\"ignore\": true`
                          in `.devprune.json`.
    Under the size floor  Smaller than min_size_mb. `--min-size 0` includes it.
    Not registered        `devp link .` first; `devp status` shows what is tracked.
    Nested or symlinked   A submodule is pruned as itself, never as part of its
                          parent, and a linked directory is refused. By design.
    Too deep              Beyond scan_depth (6 levels). `devp config set scan_depth N`.

  `devp run --dry-run` names the actual reason, per repository.

  Still stuck? Ask your AI assistant — `devp skill` hands it the full troubleshooting
  tree, including this list. It has read it. It wrote it.
"
    );
}

/// Whether a failure is just the reader at the other end of a pipe hanging up.
///
/// `devp status | head -5` is a normal thing to type, and the closed pipe it produces is
/// not an error worth printing — printing it would itself fail.
fn is_broken_pipe(err: &anyhow::Error) -> bool {
    err.chain().any(|cause| {
        cause
            .downcast_ref::<std::io::Error>()
            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe)
    })
}

/// Universal, lockfile-safe workspace pruner and background dependency cleaner.
///
/// Note: `dev-prune` and `devp` are interchangeable binary aliases.
#[derive(Parser, Debug)]
#[command(name = constants::APP_NAME)]
#[command(version = constants::VERSION)]
#[command(author = constants::AUTHOR)]
#[command(long_version = constants::LONG_VERSION.as_str())]
#[command(
    about = "Universal, lockfile-safe workspace pruner and background dependency cleaner\nNote: `dev-prune` and `devp` are interchangeable binary aliases."
)]
#[command(
    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"
)]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Simulate pruning without deleting any files.
    #[arg(long, global = true)]
    dry_run: bool,

    /// Prune repositories you are still working in, ignoring the idle-day threshold.
    ///
    /// This is the *only* check it lifts. Lockfile verification, `ignore.devprune.json`,
    /// `"ignore": true`, symlink refusal and nested-repository refusal all still apply.
    #[arg(long, global = true)]
    ignore_idle: bool,

    /// Deprecated spelling of `--ignore-idle`.
    ///
    /// Renamed because "force" reads like "override the safety checks", which it never
    /// did — it only ever skipped the idle-day wait. Still accepted; prints a note.
    #[arg(long, global = true)]
    force: bool,

    /// Bypass interactive confirmation prompts.
    #[arg(long, short = 'y', global = true)]
    yes: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Workspace onboarding & discovery: crawl paths for Git repositories and register them.
    #[command(alias = "scan", alias = "onboard")]
    #[command(long_about = help::INIT_LONG, after_long_help = help::INIT_EXAMPLES)]
    Init {
        /// Paths to scan for Git repositories (defaults to current directory).
        #[arg(default_value = ".")]
        paths: Vec<String>,
    },

    /// Register a single Git repository for pruning (defaults to current directory `.`).
    #[command(long_about = help::LINK_LONG, after_long_help = help::LINK_EXAMPLES)]
    Link {
        /// Path to the Git repository to register.
        #[arg(default_value = ".")]
        path: String,

        /// Suppress output and skip repos that set `disable_hooks`. Used by the Git hook.
        #[arg(long)]
        quiet: bool,
    },

    /// Remove a repository from the dev-prune registry (does not delete workspace files).
    #[command(long_about = help::UNLINK_LONG, after_long_help = help::UNLINK_EXAMPLES)]
    Unlink {
        /// Path to the Git repository to unregister.
        #[arg(default_value = ".")]
        path: String,

        /// Unregister every path that no longer exists, instead of one named repository.
        #[arg(long, conflicts_with = "path")]
        missing: bool,
    },

    /// Revert the most recent init or link action.
    #[command(long_about = help::UNDO_LONG, after_long_help = help::UNDO_EXAMPLES)]
    Undo,

    /// Run a prune pass across all registered repositories or a target directory (`devp run .`).
    #[command(long_about = help::RUN_LONG, after_long_help = help::RUN_EXAMPLES)]
    Run {
        /// Optional target workspace path. If omitted, runs across all registered repositories.
        target_path: Option<String>,

        /// Mark this as the scheduled background pass. Repositories that set
        /// `disable_daemon` in `.devprune.json` are skipped. Set by the installed scheduler.
        #[arg(long)]
        daemon: bool,

        /// Act only on these package managers (comma-separated),
        /// e.g. `--only npm,pnpm`. Unknown names are an error.
        #[arg(long, value_name = "ADAPTERS", conflicts_with = "skip")]
        only: Option<String>,

        /// Leave these package managers alone (comma-separated), e.g. `--skip cargo`.
        #[arg(long, value_name = "ADAPTERS")]
        skip: Option<String>,

        /// Ignore bloat directories smaller than this many MiB. Overrides `min_size_mb`.
        #[arg(long, value_name = "MIB")]
        min_size: Option<u64>,

        /// Prune everything except these repositories (comma-separated paths or names).
        ///
        /// The safe way to express "clean up but keep the API project": that project is
        /// never verified, never deleted and never reinstalled, instead of being pruned
        /// and then restored over the network.
        #[arg(long, value_name = "REPOS")]
        except: Option<String>,

        /// Emit one JSON document instead of the human report. Implies non-interactive.
        #[arg(long)]
        json: bool,

        /// Explain every decision instead of pruning: each repository and directory,
        /// with the reason it would or would not be touched — including the states a
        /// normal pass keeps quiet about (still active, opted out, under the size
        /// floor). Read-only; nothing is verified or deleted.
        #[arg(long, conflicts_with = "json")]
        explain: bool,
    },

    /// View system dashboard: registered repos, background daemon, Git hooks & space metrics.
    #[command(long_about = help::STATUS_LONG, after_long_help = help::STATUS_EXAMPLES)]
    Status {
        /// Show only the N repositories with the most reclaimable space.
        ///
        /// The dashboard lists every registered repository, which on a machine with a
        /// hundred of them buries the handful actually worth pruning. Applies to the TUI,
        /// the plain table and `--json` alike.
        ///
        /// Zero is rejected up front: "show the top 0" can only be a typo, and an empty
        /// dashboard that looks like an empty registry is worse than a usage error.
        #[arg(long, value_name = "N", value_parser = clap::value_parser!(u64).range(1..))]
        top: Option<u64>,

        /// Report lockfile drift instead of the dashboard: environments holding packages
        /// their lockfile never recorded — the installs a prune would refuse to delete
        /// because nothing could bring them back.
        ///
        /// A pure read: no package manager runs, nothing is written. Checked where a
        /// file-level comparison exists — npm, uv and venv projects.
        #[arg(long, conflicts_with = "top")]
        drift: bool,

        /// Emit the dashboard as one JSON document instead of the TUI or text table.
        #[arg(long)]
        json: bool,
    },

    /// Show lifetime space reclaimed, recent prune passes, and the biggest repositories.
    #[command(long_about = help::STATS_LONG, after_long_help = help::STATS_EXAMPLES)]
    Stats {
        /// Emit the figures as one JSON document instead of the text report.
        #[arg(long)]
        json: bool,
    },

    /// Report what dev-prune is allowed to do on this machine, and what it has been given permission to do.
    #[command(long_about = help::TRUST_LONG, after_long_help = help::TRUST_EXAMPLES)]
    Trust {
        /// Emit the report as one JSON document instead of the table.
        #[arg(long)]
        json: bool,

        /// Let Git read registered repositories it currently refuses on ownership.
        #[arg(long, conflicts_with = "json")]
        fix_ownership: bool,

        /// Answer yes to the confirmation `--fix-ownership` asks.
        #[arg(long, requires = "fix_ownership")]
        yes: bool,
    },

    /// Print a shell completion script for bash, zsh, fish, PowerShell or elvish.
    #[command(long_about = help::COMPLETIONS_LONG, after_long_help = help::COMPLETIONS_EXAMPLES)]
    Completions {
        /// Shell to generate for.
        shell: clap_complete::Shell,
    },

    /// Print or write man pages, generated from the same definitions `--help` prints.
    #[command(long_about = help::MAN_LONG, after_long_help = help::MAN_EXAMPLES)]
    Man {
        /// The command whose page to read, e.g. `devp man run`. Omit for the
        /// contents page.
        command: Option<String>,

        /// Write `devp.1` plus one `devp-<command>.1` per subcommand into this
        /// directory, instead of rendering the main page to stdout.
        #[arg(long, value_name = "DIR")]
        dir: Option<String>,

        /// Print the roff source even when stdout is a terminal, instead of the
        /// readable manual.
        #[arg(long)]
        roff: bool,
    },

    /// Report the size of every package manager cache on this machine (read-only unless you ask for `clear`).
    #[command(long_about = help::CACHES_LONG, after_long_help = help::CACHES_EXAMPLES)]
    Caches {
        /// Emit the report as one JSON document instead of the table.
        ///
        /// Global within `caches` so it can be written after the subcommand too —
        /// `devp caches clear npm --json` is what everyone types.
        #[arg(long, global = true)]
        json: bool,

        #[command(subcommand)]
        action: Option<CachesAction>,
    },

    /// Manage global settings, background daemon, Git hooks, custom icons, or per-project .devprune.json.
    #[command(long_about = help::CONFIG_LONG, after_long_help = help::CONFIG_EXAMPLES)]
    Config {
        #[command(subcommand)]
        action: Option<ConfigAction>,
    },

    /// Restore dependencies in a project using its lockfile (npm ci, pnpm install, uv sync).
    #[command(long_about = help::RESTORE_LONG, after_long_help = help::RESTORE_EXAMPLES)]
    Restore {
        /// Path to the project to restore (defaults to current directory).
        path: Option<String>,

        /// Put back exactly what the most recent prune pass deleted, in every repository
        /// it touched. The undo for a `run`.
        #[arg(long, conflicts_with = "path")]
        last_run: bool,
    },

    /// Print the installed version, check for a newer release, and show how to upgrade.
    #[command(long_about = help::UPDATE_LONG, after_long_help = help::UPDATE_EXAMPLES)]
    Update {
        /// Skip the release check for this run. The check is the only thing in dev-prune
        /// that opens a network connection; `devp config set update_check false` turns
        /// it off for good.
        #[arg(long)]
        offline: bool,

        /// Download and install the newer release, through whichever package manager
        /// installed this copy (cargo, npm, uv, pipx, or the installer script). Needs
        /// the network, so it cannot be combined with `--offline`.
        #[arg(long, conflicts_with = "offline")]
        install: bool,
    },

    /// Export SKILL.md and display ready-to-copy AI Agent onboarding & skill import prompts.
    #[command(long_about = help::SKILL_LONG, after_long_help = help::SKILL_EXAMPLES)]
    Skill {
        /// Write rules for one editor's agent into the current repository instead.
        /// Each value below names the exact file it writes. Five of them —
        /// `agents-md`, `copilot`, `gemini`, `junie`, `zed` — share a file with
        /// other tools, so dev-prune owns a marked block inside it and leaves every
        /// byte outside the markers as found. Claude Code needs no per-repo file:
        /// plain `devp skill` installs its skill globally.
        #[arg(long, value_enum, value_name = "EDITOR")]
        agent: Option<commands::skill::AgentEditor>,
    },

    /// Install whatever dev-prune integration is missing: alias, SKILL.md, Git hooks, scheduler.
    #[command(long_about = help::SETUP_LONG, after_long_help = help::SETUP_EXAMPLES)]
    Setup {
        /// Report what is installed without changing anything.
        #[arg(long)]
        status: bool,
    },

    /// Diagnose the installation, or one repository if given a path (`devp doctor .`).
    #[command(long_about = help::DOCTOR_LONG, after_long_help = help::DOCTOR_EXAMPLES)]
    Doctor {
        /// Repository to diagnose. Omit to check the installation itself.
        path: Option<String>,

        /// Repair what the installation check finds broken: refresh a stale or missing
        /// `devp` twin, re-export SKILL.md, re-register a scheduler or Git hooks whose
        /// binary moved, and drop registry entries whose repository is gone.
        ///
        /// Repairs only what was installed and has since broken — it never installs an
        /// integration that was never set up (that is `devp setup`), and it cannot fix a
        /// corrupt registry file, which needs a human decision.
        #[arg(long, conflicts_with = "path")]
        fix: bool,
    },

    /// Move this install to another package manager: `devp install --channel uv`.
    #[command(long_about = help::INSTALL_LONG, after_long_help = help::INSTALL_EXAMPLES)]
    Install {
        /// The package manager to move this installation to. Omit to print which one
        /// owns the running copy, and the names this flag accepts.
        #[arg(long, value_enum, value_name = "NAME")]
        channel: Option<commands::install::TargetChannel>,

        /// Print the commands that would run, and run none of them.
        #[arg(long)]
        dry_run: bool,
    },

    /// Remove dev-prune: scheduler, hooks, PATH entry, agent skill, and every copy of the binary.
    #[command(long_about = help::UNINSTALL_LONG, after_long_help = help::UNINSTALL_EXAMPLES)]
    Uninstall {
        /// Perform a deep uninstall (wipe configuration folder and .devprune.json files).
        #[arg(long)]
        deep: bool,
    },
}

impl Commands {
    /// Whether this command's stdout is something another program reads.
    ///
    /// Two cases. `--json` promises stdout carries one document and nothing else, and
    /// `completions` prints a script that gets sourced — a stray line in either is a
    /// parse error rather than a nicety. `link --quiet` is the Git hook path, which runs
    /// inside somebody's commit.
    ///
    /// Everything else defers to [`output::print_attribution`], which prints only when
    /// stdout is a terminal. Neither function checks that the line is intact, and nothing
    /// downstream depends on it having been printed.
    fn suppresses_attribution(&self) -> bool {
        match self {
            Commands::Completions { .. } | Commands::Man { .. } => true,
            Commands::Run { json, .. }
            | Commands::Status { json, .. }
            | Commands::Stats { json }
            | Commands::Trust { json, .. }
            | Commands::Caches { json, .. } => *json,
            Commands::Link { quiet, .. } => *quiet,
            _ => false,
        }
    }
}

#[derive(Subcommand, Debug)]
pub enum CachesAction {
    /// Empty one manager's cache, or every one of them, after showing what goes and asking.
    #[command(long_about = help::CACHES_CLEAR_LONG, after_long_help = help::CACHES_CLEAR_EXAMPLES)]
    Clear {
        /// Which cache to empty: a manager name (npm, go, cargo, gradle, …) or `all`.
        #[arg(value_name = "MANAGER")]
        target: String,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigAction {
    /// Display a global configuration value.
    #[command(long_about = help::CONFIG_GET_LONG, after_long_help = help::CONFIG_GET_EXAMPLES)]
    Get {
        /// Any key `devp config show` lists — idle_days, min_size_mb, scan_depth,
        /// require_confirmation, allow_manifest_rewrite, command_timeout_secs,
        /// auto_setup, auto_daemon, check_interval_days, auto_hooks, auto_hooks_chain,
        /// update_check, update_check_interval_days, update_check_timeout_secs.
        key: String,
    },
    /// Set a global configuration value.
    #[command(long_about = help::CONFIG_SET_LONG, after_long_help = help::CONFIG_SET_EXAMPLES)]
    Set {
        /// Configuration key.
        key: String,
        /// New value.
        value: String,
    },
    /// Show all global configuration values or sync per-repo configurations.
    #[command(long_about = help::CONFIG_SHOW_LONG, after_long_help = help::CONFIG_SHOW_EXAMPLES)]
    Show {
        /// Force update/sync pass across all registered repos.
        #[arg(long, short)]
        update: bool,
    },
    /// Inspect or initialize per-repository config (.devprune.json) for a workspace path.
    #[command(long_about = help::CONFIG_PROJECT_LONG, after_long_help = help::CONFIG_PROJECT_EXAMPLES)]
    Project {
        /// Path to the repository (defaults to current directory).
        #[arg(default_value = ".")]
        path: String,
        /// Force update/sync pass on this project config.
        #[arg(long, short)]
        update: bool,
    },
    /// Configure OS background daemon scheduler globally or for a workspace path.
    #[command(long_about = help::CONFIG_DAEMON_LONG, after_long_help = help::CONFIG_DAEMON_EXAMPLES)]
    Daemon {
        /// Optional workspace path or sub-action (enable, disable, status).
        target: Option<String>,
        /// Sub-action if path was provided (enable, disable, status).
        sub_action: Option<String>,
    },
    /// Configure non-blocking global Git background auto-registration hooks globally or for a workspace path.
    #[command(long_about = help::CONFIG_HOOK_LONG, after_long_help = help::CONFIG_HOOK_EXAMPLES)]
    Hook {
        /// Optional workspace path or sub-action (enable, disable, status).
        target: Option<String>,
        /// Sub-action if path was provided (enable, disable, status).
        sub_action: Option<String>,
        /// Install in front of the hooks directory already configured, forwarding to it,
        /// instead of refusing to take a slot another tool is using.
        #[arg(long)]
        chain: bool,
    },
    /// Register a file-manager icon for .devprune.json, and print an editor snippet.
    #[command(long_about = help::CONFIG_ICON_LONG, after_long_help = help::CONFIG_ICON_EXAMPLES)]
    Icon,
    /// Walk through every global setting, confirming or changing each one.
    #[command(long_about = help::CONFIG_WIZARD_LONG, after_long_help = help::CONFIG_WIZARD_EXAMPLES)]
    Wizard {
        /// Ask one question per line instead of opening the full-screen configurator.
        #[arg(long)]
        no_tui: bool,
    },
}

/// Create the `devp` executable alias next to `dev-prune`, and keep it current.
///
/// Runs on every invocation because it is two `stat` calls in the settled case, and
/// because the alias is how most people invoke this tool — it must never be the stale
/// half of an upgrade.
///
/// `DEV_PRUNE_NO_AUTO_SETUP` suppresses it, and so does looking like CI or a container,
/// because writing a second executable next to the first is a self-installation like any
/// other — and those environments cannot set the variable before the first run. `devp
/// setup` still creates the alias in either case: it governs the unattended pass, not
/// the explicit request.
pub fn ensure_devp_alias() {
    if setup::no_auto_setup_requested() || setup::unattended_environment().is_some() {
        return;
    }
    let _ = setup::ensure_alias();
}

/// Print rich version & system environment details for -v / -V / --version.
///
/// This, not clap, is what `devp --version` actually runs — [`normalize_args`] catches the
/// flag first. The author and repository are printed here because a copy of this binary
/// found on a machine with no package manager record should still be able to say where it
/// came from, and `--version` is the first thing anyone runs on an unknown executable.
pub fn print_version_info() {
    use colored::Colorize;
    output::print_banner();
    println!(
        "dev-prune (devp) {}",
        format!("v{}", constants::VERSION).green().bold()
    );
    println!(
        "  Binary Aliases:  {} | {}",
        "dev-prune".cyan(),
        "devp".cyan()
    );
    // These lines are facts, not status, so most of them stay in the terminal's own
    // colour. The author line was turquoise and the OS and architecture yellow, which
    // marked nothing and put five hues on one short screen; yellow now only ever means a
    // warning, and cyan is reserved for the two things worth clicking.
    println!("  Author:          {}", constants::AUTHOR);
    println!(
        "  Repository:      {}",
        constants::REPO_URL.cyan().underline()
    );
    println!(
        "  Homepage:        {}",
        constants::HOMEPAGE_URL.cyan().underline()
    );
    println!("  Target OS:       {}", std::env::consts::OS);
    match native_arch_if_emulated() {
        // Without this the line reads as a statement about the machine, and a 32-bit
        // build on a 64-bit laptop looks like the laptop is 32-bit.
        Some(native) => println!(
            "  Architecture:    {} {}",
            std::env::consts::ARCH,
            format!(
                "(this build — the machine is {native}; `devp update` installs the native one)"
            )
            .yellow()
        ),
        None => println!("  Architecture:    {}", std::env::consts::ARCH),
    }
    println!(
        "  Compiler:        Rust {}+ (edition 2024)",
        constants::MSRV
    );
    println!("  License:         Apache-2.0");
    println!();
    let reg_path = config::Registry::registry_path()
        .map(output::styled_path)
        .unwrap_or_else(|_| "unknown".to_string());
    println!("  Config Path:     {reg_path}");

    if let Ok(exe) = std::env::current_exe()
        && let Some(exe_dir) = exe.parent()
    {
        let exe_dir_str = output::clean_path(exe_dir);
        // The same tolerant comparison the PATH writer uses — a trailing backslash or a
        // case difference must not turn the audit line red on a healthy install.
        let path_var = std::env::var("PATH").unwrap_or_default();
        let exe_dir_entry = exe_dir.to_string_lossy();
        let is_in_path = path_var
            .split(if cfg!(windows) { ';' } else { ':' })
            .any(|p| pathenv::entries_equal(p, &exe_dir_entry));

        println!("  Binary Dir:      {}", exe_dir_str.cyan());
        if is_in_path {
            println!(
                "  PATH Audit:      {}",
                "✓ Executable directory is active in system PATH.".green()
            );
        } else {
            println!(
                "  PATH Audit:      {}",
                "⚠ Executable directory is NOT in system PATH!".yellow()
            );
            println!(
                "                   Add `{}` to Environment Variables.",
                exe_dir_str.cyan()
            );
        }
    }
}

/// Case-insensitive subcommand normalizer and status alias router.
fn normalize_args() -> Vec<String> {
    let args: Vec<String> = std::env::args().collect();
    if args.len() == 2 && (args[1] == "-v" || args[1] == "-V" || args[1] == "--version") {
        print_version_info();
        std::process::exit(exit_code::OK);
    }
    if args.len() <= 1
        || args
            .iter()
            .any(|a| a == "-h" || a == "--help" || a == "help")
    {
        output::print_banner();
    }
    if args.len() <= 1 {
        return args;
    }

    let mut normalized = vec![args[0].clone()];
    for (i, arg) in args.iter().enumerate().skip(1) {
        if i == 1 && !arg.starts_with('-') {
            normalized.push(arg.to_lowercase());
        } else {
            normalized.push(arg.clone());
        }
    }

    // Map `devp daemon|hook|icon [ARGS...]` -> `devp config daemon|hook|icon [ARGS...]`
    //
    // These live under `config` because that is where the rest of the persistent
    // settings live, but nobody types `devp config hook install` when they mean
    // "install the hook" — and the tool's own output has always said `devp hook
    // install`. Accepting both costs one insert and removes a papercut.
    if matches!(normalized[1].as_str(), "daemon" | "hook" | "icon") {
        normalized.insert(1, "config".to_string());
    }

    // Map `devp status [PATH] daemon` -> `devp config daemon [PATH] status`
    // Map `devp status [PATH] hook`   -> `devp config hook [PATH] status`
    //
    // Exactly one optional PATH, and never a flag: `devp status --json daemon` must
    // reach clap as typed and fail there, not be rewritten with `--json` as a path.
    if normalized[1] == "status"
        && (normalized.len() == 3 || (normalized.len() == 4 && !normalized[2].starts_with('-')))
    {
        let last = normalized
            .last()
            .map(|s| s.to_lowercase())
            .unwrap_or_default();
        if last == "daemon" || last == "hook" {
            let mut rewrited = vec![normalized[0].clone(), "config".to_string(), last];
            if normalized.len() > 3 {
                rewrited.push(normalized[2].clone());
            }
            rewrited.push("status".to_string());
            return rewrited;
        }
    }

    // Map `devp config [PATH] daemon [ACTION]` -> `devp config daemon [PATH] [ACTION]`
    // Map `devp config [PATH] hook [ACTION]`   -> `devp config hook [PATH] [ACTION]`
    //
    // Only when the second argument can actually be a path — a flag there means the
    // user is talking to `config` itself and the rewrite would misfile it.
    if normalized.len() >= 4 && normalized[1] == "config" && !normalized[2].starts_with('-') {
        let third = normalized[3].to_lowercase();
        if third == "daemon" || third == "hook" {
            let mut rewrited = vec![
                normalized[0].clone(),
                "config".to_string(),
                third,
                normalized[2].clone(),
            ];
            for extra in &normalized[4..] {
                rewrited.push(extra.clone());
            }
            return rewrited;
        }
    }

    normalized
}

/// Whether the automatic setup pass may run for this invocation.
///
/// Two callers are excluded on purpose. The Git hook runs `link --quiet` with no
/// terminal attached and inside someone's commit; the scheduler runs `run --daemon` the
/// same way. An integration pass nobody can see is one nobody can refuse, so both wait
/// for the next command a human types. `uninstall` is excluded for the obvious reason,
/// and `setup` because it is the pass, run deliberately.
fn auto_setup_allowed(args: &[String]) -> bool {
    let subcommand = args.get(1).map(String::as_str).unwrap_or("");
    // `--json` means a program is parsing stdout; the setup report and the first-run
    // wizard would land inside the document. That invocation waits too.
    !matches!(subcommand, "uninstall" | "setup")
        && !args
            .iter()
            .any(|a| a == "--quiet" || a == "--daemon" || a == "--json")
}

/// Run the CLI application.
pub fn run_cli() {
    restore_sigpipe();
    ensure_devp_alias();

    let args = normalize_args();
    if auto_setup_allowed(&args) {
        setup::auto_setup_if_due();
    }
    let cli = Cli::parse_from(args);

    // Both spellings mean the same thing; the old one just says so first.
    let ignore_idle = cli.ignore_idle || cli.force;
    if cli.force {
        print_force_help();
    }

    // Decided before the match, because that is where `cli.command` is consumed.
    let credit_the_author = !cli.command.suppresses_attribution();

    // Every path the user typed passes through `expand_tilde` on the way in. PowerShell
    // and cmd hand us `~/Code` verbatim, so without this the documented one-liner
    // registers a directory literally named `~`.
    let result = match cli.command {
        Commands::Init { paths } => {
            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
            commands::init::run(&paths, cli.dry_run)
        }
        Commands::Link { path, quiet } => {
            commands::link::run_link(&config::expand_tilde(&path), quiet)
        }
        Commands::Unlink { path, missing } => {
            if missing {
                commands::link::run_unlink_missing()
            } else {
                commands::link::run_unlink(&config::expand_tilde(&path))
            }
        }
        Commands::Undo => commands::undo::run(),
        Commands::Run {
            target_path,
            daemon,
            only,
            skip,
            min_size,
            except,
            json,
            explain,
        } => {
            let target_path = target_path.map(|p| config::expand_tilde(&p));
            commands::run::run(commands::run::RunArgs {
                target_path: target_path.as_deref(),
                dry_run: cli.dry_run,
                force: ignore_idle,
                yes: cli.yes,
                daemon,
                only: only.as_deref(),
                skip: skip.as_deref(),
                min_size_mb: min_size,
                except: except.as_deref(),
                json,
                explain,
            })
        }
        Commands::Status { top, drift, json } => {
            commands::status::run(top.map(|n| n as usize), drift, json)
        }
        Commands::Stats { json } => commands::stats::run(json),
        Commands::Completions { shell } => commands::completions::run(shell),
        Commands::Man { command, dir, roff } => {
            commands::man::run(command.as_deref(), dir.as_deref(), roff)
        }
        Commands::Trust {
            json,
            fix_ownership,
            yes,
        } => {
            if fix_ownership {
                commands::trust::fix_ownership(yes)
            } else {
                commands::trust::run(json)
            }
        }
        Commands::Caches { json, action } => match action {
            Some(CachesAction::Clear { target }) => {
                commands::caches::run_clear(&target, cli.yes, cli.dry_run, json)
            }
            None => commands::caches::run(json),
        },
        Commands::Config { action } => match action {
            Some(ConfigAction::Get { key }) => commands::config::run_get(&key),
            Some(ConfigAction::Set { key, value }) => commands::config::run_set(&key, &value),
            Some(ConfigAction::Show { update: true }) => commands::config::run_global_update(),
            Some(ConfigAction::Show { update: false }) | None => commands::config::run_show(),
            Some(ConfigAction::Project { path, update }) => {
                commands::config::run_path_config(&config::expand_tilde(&path), update)
            }
            Some(ConfigAction::Daemon { target, sub_action }) => {
                // A toggle word (`on`, `off`) never starts with `~`, so expanding the
                // target before the match cannot turn one into a path.
                let target = target.map(|t| config::expand_tilde(&t));
                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
                    (Some(t), Some(a)) => (Some(t), a),
                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
                    (Some(t), None) => (Some(t), "status"),
                    (None, Some(a)) => (None, a),
                    (None, None) => (None, "status"),
                };
                commands::config::run_daemon_toggle(path, action)
            }
            Some(ConfigAction::Hook {
                target,
                sub_action,
                chain,
            }) => {
                let target = target.map(|t| config::expand_tilde(&t));
                let (path, action) = match (target.as_deref(), sub_action.as_deref()) {
                    (Some(t), Some(a)) => (Some(t), a),
                    (Some(t), None) if commands::config::is_toggle_word(t) => (None, t),
                    (Some(t), None) => (Some(t), "status"),
                    (None, Some(a)) => (None, a),
                    // `--chain` on its own is an install instruction, not a status query.
                    (None, None) if chain => (None, "install"),
                    (None, None) => (None, "status"),
                };
                commands::config::run_hook_toggle(path, action, chain)
            }
            Some(ConfigAction::Icon) => commands::icon::run_install(),
            Some(ConfigAction::Wizard { no_tui }) => commands::config::run_wizard(no_tui),
        },
        Commands::Restore { path, last_run } => {
            if last_run {
                commands::restore::run_last_run()
            } else {
                commands::restore::run(&config::expand_tilde(path.as_deref().unwrap_or(".")))
            }
        }
        Commands::Update { offline, install } => commands::update::run(offline, install),
        Commands::Skill { agent } => commands::skill::run(agent),
        Commands::Setup { status } => commands::setup::run(status),
        Commands::Doctor { path, fix } => {
            let path = path.map(|p| config::expand_tilde(&p));
            commands::doctor::run(path.as_deref(), fix)
        }
        Commands::Install { channel, dry_run } => commands::install::run(channel, dry_run, cli.yes),
        Commands::Uninstall { deep } => commands::uninstall::run(deep, cli.yes),
    };

    if let Err(e) = result {
        if is_broken_pipe(&e) {
            std::process::exit(exit_code::OK);
        }
        output::print_error(&format!("{e:#}"));
        if e.downcast_ref::<UsageError>().is_some() {
            std::process::exit(exit_code::USAGE);
        }
        std::process::exit(exit_code::FAILURE);
    }

    // Only on the way out of a successful run: nobody reading an error message needs a
    // credit under it.
    if credit_the_author {
        output::print_attribution();
    }
}

#[cfg(test)]
mod tests {
    use super::auto_setup_allowed;

    fn args(rest: &[&str]) -> Vec<String> {
        std::iter::once("devp")
            .chain(rest.iter().copied())
            .map(String::from)
            .collect()
    }

    #[test]
    fn ordinary_interactive_commands_may_auto_setup() {
        assert!(auto_setup_allowed(&args(&["status"])));
        assert!(auto_setup_allowed(&args(&["run", "--dry-run"])));
        assert!(auto_setup_allowed(&args(&[])));
    }

    #[test]
    fn unattended_and_machine_read_invocations_may_not() {
        // The Git hook, the scheduler, and any `--json` consumer: a setup pass nobody
        // can see is one nobody can refuse, and setup output inside a JSON document is
        // a parse error.
        assert!(!auto_setup_allowed(&args(&["link", ".", "--quiet"])));
        assert!(!auto_setup_allowed(&args(&["run", "--daemon"])));
        assert!(!auto_setup_allowed(&args(&["status", "--json"])));
        assert!(!auto_setup_allowed(&args(&["uninstall"])));
        assert!(!auto_setup_allowed(&args(&["setup"])));
    }
}