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