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