Skip to main content

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}