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