yui/cli.rs
1use anyhow::Result;
2use camino::Utf8PathBuf;
3use clap::builder::styling::{AnsiColor, Effects, Styles};
4use clap::{CommandFactory, Parser, Subcommand};
5use clap_complete::Shell;
6
7use crate::cmd;
8use crate::config::IconsMode;
9use crate::updater;
10
11/// Explicit colour palette for `--help` output. clap honours `NO_COLOR`
12/// and falls back to monochrome when stdout isn't a TTY, so this is
13/// safe to leave on unconditionally — the styled bytes are only ever
14/// emitted when a real terminal is reading them. The palette mirrors
15/// the bind-points / icon colours used in `yui list` / `yui status` so
16/// help, list, and status all feel like the same tool.
17const HELP_STYLES: Styles = Styles::styled()
18 // "Commands:" / "Options:" / etc. section headers.
19 .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
20 // The "Usage:" heading label itself (NOT the binary name — that
21 // falls under `literal` below).
22 .usage(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
23 // Binary name in the usage line + every subcommand / option
24 // literal (`init`, `--source`, …).
25 .literal(AnsiColor::Magenta.on_default().effects(Effects::BOLD))
26 // <PLACEHOLDER> values inside option signatures.
27 .placeholder(AnsiColor::Cyan.on_default())
28 .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
29 .valid(AnsiColor::Green.on_default())
30 .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
31
32#[derive(Parser, Debug)]
33#[command(version, about, long_about = None, styles = HELP_STYLES)]
34pub struct Cli {
35 /// Path to dotfiles source repository ($DOTFILES)
36 #[arg(short, long, env = "YUI_SOURCE", global = true)]
37 pub source: Option<Utf8PathBuf>,
38
39 /// Increase log verbosity (-v, -vv, -vvv)
40 #[arg(short, long, action = clap::ArgAction::Count, global = true)]
41 pub verbose: u8,
42
43 #[command(subcommand)]
44 pub command: Command,
45}
46
47#[derive(Subcommand, Debug)]
48pub enum Command {
49 /// Initialize source repo skeleton
50 Init {
51 /// Install git pre-commit/pre-push hooks for render-drift check
52 #[arg(long)]
53 git_hooks: bool,
54 },
55
56 /// Render templates + link targets + auto-absorb (default workflow)
57 Apply {
58 #[arg(long)]
59 dry_run: bool,
60 },
61
62 /// Render templates only
63 Render {
64 /// Fail with non-zero exit if rendered output diverges (CI hook)
65 #[arg(long)]
66 check: bool,
67 #[arg(long)]
68 dry_run: bool,
69 },
70
71 /// Link / relink targets only
72 Link {
73 #[arg(long)]
74 dry_run: bool,
75 },
76
77 /// Unlink targets
78 Unlink { paths: Vec<Utf8PathBuf> },
79
80 /// Show drift status (link-broken / replaced / template-drift)
81 Status {
82 /// Override [ui] icons mode for this invocation
83 #[arg(long, value_name = "MODE")]
84 icons: Option<IconsMode>,
85 /// Disable color output (also respected via NO_COLOR env)
86 #[arg(long)]
87 no_color: bool,
88 },
89
90 /// List all src→dst link mappings (mount entries + .yuilink overrides)
91 List {
92 /// Include entries whose `when` evaluates false on the current host
93 #[arg(long)]
94 all: bool,
95 /// Override [ui] icons mode for this invocation
96 #[arg(long, value_name = "MODE")]
97 icons: Option<IconsMode>,
98 /// Disable color output (also respected via NO_COLOR env)
99 #[arg(long)]
100 no_color: bool,
101 },
102
103 /// Manually absorb a target into source (when auto-absorb skipped).
104 ///
105 /// Prints a unified diff (source vs target) on stderr. Without
106 /// `--yes`, prompts on a TTY before writing; off a TTY refuses
107 /// to act unless `--yes` is given. `--dry-run` only shows the
108 /// diff and exits.
109 Absorb {
110 target: Utf8PathBuf,
111 #[arg(long)]
112 dry_run: bool,
113 /// Skip the y/N prompt (still prints the diff).
114 #[arg(long)]
115 yes: bool,
116 },
117
118 /// Diagnose environment (symlink capability, source detection, etc)
119 Doctor {
120 /// Override [ui] icons mode for this invocation
121 #[arg(long, value_name = "MODE")]
122 icons: Option<IconsMode>,
123 /// Disable color output (also respected via NO_COLOR env)
124 #[arg(long)]
125 no_color: bool,
126 },
127
128 /// Garbage-collect old backups under `$DOTFILES/.yui/backup/`.
129 ///
130 /// With no `--older-than`, prints every parsed backup with its
131 /// age + size and exits without deleting (a survey).
132 /// With `--older-than DUR`, deletes entries whose timestamp
133 /// suffix is older than DUR. Backups whose name doesn't match
134 /// yui's `<stem>_<YYYYMMDD_HHMMSSfff>[.<ext>]` shape are left
135 /// alone — anything you dropped into `.yui/backup/` by hand
136 /// stays there.
137 GcBackup {
138 /// Age threshold; e.g. `30d`, `2w`, `12h`, `6mo`, `1y`.
139 /// Omit to run a non-destructive survey instead.
140 #[arg(long, value_name = "DUR")]
141 older_than: Option<String>,
142 /// Preview the deletion (no files removed). Only meaningful
143 /// when `--older-than` is also given.
144 #[arg(long)]
145 dry_run: bool,
146 /// Override [ui] icons mode for this invocation
147 #[arg(long, value_name = "MODE")]
148 icons: Option<IconsMode>,
149 /// Disable color output (also respected via NO_COLOR env)
150 #[arg(long)]
151 no_color: bool,
152 },
153
154 /// Manage `[[hook]]` scripts
155 Hooks {
156 #[command(subcommand)]
157 action: HookAction,
158 },
159
160 /// Pull source repo and re-apply (`git pull --ff-only` + `apply`).
161 ///
162 /// Refuses to run with a dirty source tree — pulling on top of
163 /// uncommitted changes mixes upstream work with the user's
164 /// in-progress edits in ways that are easy to get wrong. Commit
165 /// (or stash) first.
166 Update {
167 /// Render templates / link targets in dry-run after the pull.
168 #[arg(long)]
169 dry_run: bool,
170 },
171
172 /// List source files NOT claimed by any `[[mount.entry]]` — yui's
173 /// "what's just sitting in the repo unused?" report. Skips
174 /// `.yui/`, `.git/`, anything matched by `.yuiignore`, and the
175 /// repo's own meta files (`config*.toml`, `.yuilink`, `.gitignore`,
176 /// `.yuiignore`, `*.tera` template sources).
177 Unmanaged {
178 /// Override [ui] icons mode for this invocation
179 #[arg(long, value_name = "MODE")]
180 icons: Option<IconsMode>,
181 /// Disable color output (also respected via NO_COLOR env)
182 #[arg(long)]
183 no_color: bool,
184 },
185
186 /// Print a unified diff for every entry that's drifted from
187 /// source — like `status` but with full content. Render-drift
188 /// rows show the rendered file vs what the template would
189 /// produce now; link-drift rows show source vs target.
190 Diff {
191 /// Override [ui] icons mode for this invocation
192 #[arg(long, value_name = "MODE")]
193 icons: Option<IconsMode>,
194 /// Disable color output (also respected via NO_COLOR env)
195 #[arg(long)]
196 no_color: bool,
197 },
198
199 /// Manage secret files (`*.age`, encrypted with age).
200 Secret {
201 #[command(subcommand)]
202 action: SecretAction,
203 },
204
205 /// Update the `yui` binary itself to the latest GitHub release.
206 ///
207 /// Detects how `yui` was installed (cargo install / `cargo build`
208 /// dev binary / direct binary download) and picks the right
209 /// upgrade path. Powered by the shared `yukimemi/kaishin`
210 /// library so the behaviour matches `rvpm self-update` /
211 /// `renri self-update`.
212 SelfUpdate {
213 /// Skip the confirmation prompt.
214 #[arg(long, short = 'y')]
215 yes: bool,
216 /// Print whether an update is available and exit without
217 /// installing.
218 #[arg(long)]
219 check: bool,
220 /// Bail out instead of prompting when stdin isn't a tty.
221 /// Pair with `--yes` to drive `self-update` from scripts.
222 #[arg(long)]
223 non_interactive: bool,
224 },
225
226 /// Generate shell completion script for `<shell>` to stdout.
227 ///
228 /// Pipe into the right place for your shell, e.g.
229 /// `yui completion bash > ~/.local/share/bash-completion/completions/yui`,
230 /// `yui completion zsh > "${fpath[1]}/_yui"`,
231 /// `yui completion pwsh | Out-String | Invoke-Expression`.
232 Completion {
233 /// Target shell (bash / zsh / fish / powershell / elvish).
234 shell: Shell,
235 },
236}
237
238#[derive(Subcommand, Debug)]
239pub enum SecretAction {
240 /// Generate an X25519 keypair, write the secret to
241 /// `[secrets] identity` (`~/.config/yui/age.txt` by default),
242 /// and append the public key to `[secrets] recipients` in
243 /// `$DOTFILES/config.local.toml`. Idempotent — refuses to
244 /// overwrite an existing identity file.
245 Init {
246 /// Append a comment block above the new recipient entry
247 /// (defaults to "<host> <user>" — yui.host / yui.user).
248 #[arg(long, value_name = "TEXT")]
249 comment: Option<String>,
250 },
251 /// Read `<path>` (absolute or relative to `$DOTFILES`) as
252 /// plaintext, encrypt it to every recipient in
253 /// `[secrets] recipients`, and write the ciphertext as
254 /// `<path>.age` next to it. Refuses to clobber an existing
255 /// `.age` without `--force`.
256 ///
257 /// The plaintext sibling is added to the managed `.gitignore`
258 /// section immediately so it can't be staged accidentally
259 /// before the next `apply`. Pass `--rm-plaintext` to also
260 /// delete the plaintext after a successful encryption (works
261 /// only when it lives under `$DOTFILES`).
262 Encrypt {
263 path: Utf8PathBuf,
264 /// Replace an existing `<path>.age`.
265 #[arg(long)]
266 force: bool,
267 /// Delete the plaintext after a successful encryption
268 /// (only works when the plaintext lives under `$DOTFILES`).
269 #[arg(long)]
270 rm_plaintext: bool,
271 },
272
273 /// Push the X25519 secret at `[secrets].identity` into the
274 /// configured `[secrets.vault]` (Bitwarden or 1Password).
275 /// Run this once on a machine that has the X25519; then on
276 /// a new machine `yui secret unlock` recovers it through the
277 /// vault provider's own auth (master password, biometric,
278 /// passkey, SSO — whatever the vault itself accepts).
279 Store {
280 /// Overwrite the existing vault item without prompting.
281 #[arg(long)]
282 force: bool,
283 },
284
285 /// Fetch the X25519 secret from the configured `[secrets.vault]`
286 /// and write it to `[secrets].identity`. The vault CLI (`bw`
287 /// or `op`) handles its own auth, so any factor that CLI
288 /// supports (passkey unlock in the BW web vault, Touch ID via
289 /// 1Password, …) gates this command.
290 Unlock,
291}
292
293#[derive(Subcommand, Debug)]
294pub enum HookAction {
295 /// List configured hooks with their last-run state
296 List {
297 /// Override [ui] icons mode for this invocation
298 #[arg(long, value_name = "MODE")]
299 icons: Option<IconsMode>,
300 /// Disable color output (also respected via NO_COLOR env)
301 #[arg(long)]
302 no_color: bool,
303 },
304 /// Run a hook (or every hook). The `when` filter is always honored;
305 /// `--force` bypasses the `when_run` state check (so a `once` hook
306 /// can be re-run, an `onchange` hook re-runs even with matching
307 /// hash).
308 Run {
309 /// Hook name (omit to run every hook per its `when_run` rule)
310 name: Option<String>,
311 /// Bypass the `when_run` state check
312 #[arg(long)]
313 force: bool,
314 },
315}
316
317impl Cli {
318 pub fn run(self) -> Result<()> {
319 let source = self.source;
320 match self.command {
321 Command::Init { git_hooks } => cmd::init(source, git_hooks),
322 Command::Apply { dry_run } => cmd::apply(source, dry_run),
323 Command::Render { check, dry_run } => cmd::render(source, check, dry_run),
324 Command::Link { dry_run } => cmd::link(source, dry_run),
325 Command::Unlink { paths } => cmd::unlink(source, paths),
326 Command::Status { icons, no_color } => cmd::status(source, icons, no_color),
327 Command::List {
328 all,
329 icons,
330 no_color,
331 } => cmd::list(source, all, icons, no_color),
332 Command::Absorb {
333 target,
334 dry_run,
335 yes,
336 } => cmd::absorb(source, target, dry_run, yes),
337 Command::Doctor { icons, no_color } => cmd::doctor(source, icons, no_color),
338 Command::GcBackup {
339 older_than,
340 dry_run,
341 icons,
342 no_color,
343 } => cmd::gc_backup(source, older_than, dry_run, icons, no_color),
344 Command::Hooks { action } => match action {
345 HookAction::List { icons, no_color } => cmd::hooks_list(source, icons, no_color),
346 HookAction::Run { name, force } => cmd::hooks_run(source, name, force),
347 },
348 Command::Update { dry_run } => cmd::update(source, dry_run),
349 Command::Unmanaged { icons, no_color } => cmd::unmanaged(source, icons, no_color),
350 Command::Diff { icons, no_color } => cmd::diff(source, icons, no_color),
351 Command::Secret { action } => match action {
352 SecretAction::Init { comment } => cmd::secret_init(source, comment),
353 SecretAction::Encrypt {
354 path,
355 force,
356 rm_plaintext,
357 } => cmd::secret_encrypt(source, path, force, rm_plaintext),
358 SecretAction::Store { force } => cmd::secret_store(source, force),
359 SecretAction::Unlock => cmd::secret_unlock(source),
360 },
361 Command::SelfUpdate {
362 yes,
363 check,
364 non_interactive,
365 } => updater::run_self_update(yes, check, non_interactive),
366 Command::Completion { shell } => {
367 let mut cmd = Cli::command();
368 clap_complete::generate(shell, &mut cmd, "yui", &mut std::io::stdout());
369 Ok(())
370 }
371 }
372 }
373}