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