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