dev-prune 1.23.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
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
// 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 declared;
pub mod discovery;
pub mod engine;
pub mod help;
pub mod history;
pub mod i18n;
pub mod json;
pub mod output;
pub mod pathenv;
pub mod receipt;
pub mod scanner;
pub mod setup;
pub mod spawn;
pub mod tools;
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())]
// The default help lists twenty commands as one flat block. The template swaps
// clap's subcommand list for the grouped, coloured one `help.rs` builds — the same
// grouping as the manual's contents page — and the styles colour every page.
#[command(styles = help::HELP_STYLES)]
#[command(help_template = help::ROOT_HELP_TEMPLATE.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 = help::ROOT_AFTER_HELP.as_str())]
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>,

        /// Work out where the repositories are instead of being told, and register
        /// everything found. Ignores PATHS.
        #[arg(long)]
        auto: bool,
    },

    /// 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.
    //
    // Off the front page since 1.17.0: it covers ground `devp restore` also covers,
    // but it shipped in 1.0.0 and the CLI surface is a contract, so it keeps
    // working — its own `--help`, `devp man undo`, the reference — until 2.x, which
    // is when it actually goes. `help::HIDDEN_FROM_HELP` keeps the grouped help in
    // step with this flag.
    #[command(hide = true)]
    #[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,
    },

    /// List every prune pass, and open one up to see what it deleted and what asked it to.
    #[command(long_about = help::HISTORY_LONG, after_long_help = help::HISTORY_EXAMPLES)]
    History {
        /// Show one pass in full. 1 is the most recent, matching the numbers in the list.
        #[arg(long, value_name = "N", conflicts_with = "all")]
        pass: Option<usize>,

        /// How many passes to list. Defaults to the 20 most recent.
        #[arg(long, value_name = "N", conflicts_with_all = ["all", "pass"])]
        limit: Option<usize>,

        /// List every recorded pass rather than the most recent few.
        #[arg(long)]
        all: bool,

        /// Emit the whole log as one JSON document instead of the text report.
        #[arg(long)]
        json: bool,

        /// Write the JSON document to a file. Bare, it goes to your documents folder.
        #[arg(long, value_name = "PATH", num_args = 0..=1)]
        export: Option<Option<std::path::PathBuf>>,

        /// Show high score records (Gold, Silver, Bronze for single repository cleanups and total passes).
        #[arg(long, conflicts_with_all = ["pass", "export"])]
        scores: 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.
        ///
        /// Declared here rather than inherited: this subcommand's `yes` carries the
        /// `requires` guard the global one cannot, and having a local `yes` stops clap
        /// propagating the global `-y` in — so without a short spelling of its own,
        /// `devp trust --fix-ownership -y` was a usage error.
        #[arg(long, short = 'y', 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,

        /// Report only the caches that sit on one drive or filesystem: `--volume V:`,
        /// `--volume /mnt/data`, or any path on it. `--drive` is the same flag.
        ///
        /// Not global: it narrows the report, and there is nothing for it to narrow in
        /// `clear`, `docker` or `containers`, so pairing it with one is a usage error
        /// rather than a flag that looks accepted and does nothing.
        #[arg(long, visible_alias = "drive", value_name = "VOLUME")]
        volume: Option<String>,

        #[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 without asking. Plain `devp update`
        /// offers the same install with a `[y/N]` prompt when a newer release exists
        /// and stdin is a terminal; `-y` answers that prompt. Needs the network, so it
        /// cannot be combined with `--offline`.
        #[arg(long, conflicts_with = "offline")]
        install: bool,

        /// Print the upgrade command for every channel dev-prune ships through, instead
        /// of only the one that installed this copy. Touches nothing and needs no
        /// network — useful when the machine in front of you is not the one that has
        /// the stale copy.
        #[arg(long, conflicts_with_all = ["offline", "install"])]
        channels: 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. Six of them —
        /// `agents-md`, `aider`, `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. Nothing below fits? Use
        /// `--rules-file` instead of waiting on a new value to be added.
        #[arg(
            long,
            value_enum,
            value_name = "EDITOR",
            conflicts_with_all = ["rules_file", "prompt", "detected"]
        )]
        agent: Option<commands::skill::AgentEditor>,

        /// Write the condensed rules into an exact path in the current repository, as
        /// a marked block, for a coding agent `--agent` does not name — new, renamed,
        /// or reading a file of its own under a different name.
        #[arg(long, value_name = "PATH", conflicts_with_all = ["agent", "prompt", "detected"])]
        rules_file: Option<std::path::PathBuf>,

        /// Print one onboarding prompt on its own, with no header or fence, so it can
        /// be piped straight into a clipboard tool or a file.
        #[arg(long, value_enum, conflicts_with_all = ["agent", "rules_file", "detected"])]
        prompt: Option<commands::skill::PromptKind>,

        /// Send the prompt named by `--prompt` to the clipboard instead of only
        /// printing it. Falls back to a pipe hint on stderr if no clipboard tool is
        /// found on PATH; the prompt itself still reaches stdout either way.
        #[arg(long, requires = "prompt")]
        copy: bool,

        /// Write rules for every editor detected on this machine or in this
        /// repository — the ones plain `devp skill` lists — in one pass.
        #[arg(long, conflicts_with_all = ["agent", "rules_file", "prompt"])]
        detected: bool,
    },

    /// 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`.
    //
    // Off the front page since 1.17.0: the name reads like "install the tool", and
    // what it does is *move* an installation between package managers. The honest fix
    // is a rename, which the 1.0.0 CLI contract defers to 2.x — until then it works
    // exactly as before, just not in `devp --help`.
    #[command(hide = true)]
    #[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::History { 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, …), a
        /// comma-separated list of them (`npm,uv,pip`), or `all`.
        #[arg(value_name = "MANAGER")]
        target: String,

        /// With `all`: empty every cache except these (comma-separated manager
        /// names). A one-time blacklist, the counterpart of naming a list.
        #[arg(long, value_name = "MANAGERS")]
        except: Option<String>,

        /// Only empty caches that are over the size cap set for them in
        /// `cache_max_gb`. Without a cap set for anything, this clears nothing.
        #[arg(long)]
        over_cap: bool,

        /// Only empty caches that no registered repository uses. Refuses to run when
        /// there are no registered repositories to check against.
        #[arg(long)]
        unused: bool,

        /// With a container engine: after the narrow steps, list its unused volumes by
        /// name and pick which of them go. Refuses `--yes`, `--json` and a piped stdin.
        #[arg(long)]
        include_volumes: bool,
    },

    /// What Docker is holding: images, containers, volumes and build cache (read-only).
    #[command(long_about = help::CACHES_DOCKER_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
    Docker,

    /// What Podman is holding: images, containers, volumes and build cache (read-only).
    #[command(long_about = help::CACHES_DOCKER_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
    Podman,

    /// The same report for every container engine found, or for the one you name.
    #[command(long_about = help::CACHES_CONTAINERS_LONG, after_long_help = help::CACHES_CONTAINERS_EXAMPLES)]
    Containers {
        /// docker, podman, nerdctl, finch or container. Omit for every one installed.
        #[arg(value_name = "ENGINE")]
        engine: Option<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, auto_update,
        /// disabled_adapters and the rest. `devp config show` prints every key with
        /// its current value.
        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,
    },
    /// Turn on everything the first run recommends, in one command.
    #[command(long_about = help::CONFIG_RECOMMENDED_LONG, after_long_help = help::CONFIG_RECOMMENDED_EXAMPLES)]
    Recommended {
        /// Include the recommendations that come with something to know first.
        #[arg(long)]
        with_cautious: 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,
        /// Act on the committed project.devprune.json instead of the personal file.
        #[arg(long)]
        team: 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,
    },
}

/// 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 scores [ARGS...]` -> `devp history --scores [ARGS...]`
    if normalized[1] == "scores" {
        normalized[1] = "history".to_string();
        normalized.insert(2, "--scores".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();

    let args = normalize_args();

    // The scheduler's preferred registration points at devpw.exe, the GUI-subsystem
    // twin, which never gets a console at all. But the fallback registration, written
    // when the twin is missing beside the binary, names this console-subsystem exe,
    // and Windows has already opened a window for it by the time this line runs. All
    // FreeConsole can do is close that window again, shortening the flash rather than
    // preventing it; it costs nothing, because Task Scheduler discards console output
    // anyway. Detaching from stdout is safe for the same reason.
    #[cfg(windows)]
    if args.iter().any(|a| a == "--daemon") {
        unsafe {
            windows_sys::Win32::System::Console::FreeConsole();
        }
    }

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

    // After parsing, so `--version` and `--help` never pay for it, and before anything
    // is printed, because every heading past this line is drawn in whatever it settles
    // on. `Registry::load` is a pure read, and a registry that will not load is not a
    // reason to refuse to print in English — the command below reports that failure
    // properly.
    i18n::init(
        config::Registry::load()
            .ok()
            .map(|registry| registry.settings.language)
            .as_deref(),
    );

    // 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, auto } => {
            let paths: Vec<String> = paths.iter().map(|p| config::expand_tilde(p)).collect();
            commands::init::run(&paths, cli.dry_run, auto)
        }
        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::History {
            pass,
            limit,
            all,
            json,
            export,
            scores,
        } => commands::history::run(&commands::history::HistoryArgs {
            pass,
            limit,
            all,
            json,
            export,
            scores,
        }),
        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 {
                // The global `-y` and the subcommand's own `--yes` are the same promise;
                // honouring only one of them made `devp -y trust --fix-ownership` stop
                // and ask anyway.
                commands::trust::fix_ownership(yes || cli.yes)
            } else {
                commands::trust::run(json)
            }
        }
        Commands::Caches {
            json,
            volume,
            action,
        } => match action {
            // Refused rather than ignored. `--volume` reads as "act on this drive
            // only", and a `clear` that silently emptied every drive after being
            // handed one would be the worst possible way to learn the flag does not
            // reach here.
            Some(_) if volume.is_some() => Err(anyhow::Error::new(UsageError(
                "`--volume` narrows the report and has nothing to narrow in a subcommand. \
                 Run `devp caches --volume <VOLUME>` on its own to see what is on one \
                 drive; `devp caches clear <manager>` then empties that manager \
                 wherever it is."
                    .to_string(),
            ))),
            Some(CachesAction::Clear {
                target,
                except,
                over_cap,
                unused,
                include_volumes,
            }) => commands::caches::run_clear(
                &target,
                except.as_deref(),
                over_cap,
                unused,
                include_volumes,
                cli.yes,
                cli.dry_run,
                json,
            ),
            Some(CachesAction::Docker) => commands::containers::run(Some("docker"), json),
            Some(CachesAction::Podman) => commands::containers::run(Some("podman"), json),
            Some(CachesAction::Containers { engine }) => {
                commands::containers::run(engine.as_deref(), json)
            }
            None => commands::caches::run(json, volume.as_deref()),
        },
        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::Recommended { with_cautious }) => {
                commands::config::run_recommended(with_cautious)
            }
            Some(ConfigAction::Project { path, update, team }) => {
                commands::config::run_path_config(&config::expand_tilde(&path), update, team)
            }
            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::config::Opened::ByRequest)
                    .map(|_| ())
            }
        },
        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,
            channels,
        } => commands::update::run(offline, install, channels, cli.yes),
        Commands::Skill {
            agent,
            rules_file,
            prompt,
            copy,
            detected,
        } => commands::skill::run(agent, rules_file, prompt, copy, detected),
        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"])));
    }
}