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