yui-cli 0.7.19

Target-as-truth dotfiles manager: edit your live configs, source repo updates automatically via hardlink/junction/symlink.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
<p align="center">
  <img src="assets/logo.svg" width="560" alt="yui — target-as-truth dotfiles manager" />
</p>

<p align="center">
  <b>結 — edit your live configs, the source repo updates itself.</b>
</p>

<p align="center">
  <a href="https://crates.io/crates/yui-cli"><img src="https://img.shields.io/crates/v/yui-cli.svg" alt="crates.io"/></a>
  <a href="https://github.com/yukimemi/yui/actions/workflows/ci.yml"><img src="https://github.com/yukimemi/yui/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"/></a>
  <a href="https://deepwiki.com/yukimemi/yui"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"/></a>
  <a href="https://codewiki.google/github.com/yukimemi/yui"><img src="https://img.shields.io/badge/View-Code_Wiki-4285F4?logo=google" alt="View Code Wiki"/></a>
</p>

`yui` flips the chezmoi flow: instead of editing your source repo and
running `apply` to push changes out to `~`, you edit `~` directly and
the source follows automatically. The two sides share a backing inode
(hardlink / junction / symlink), so an app's write to the target *is*
a write to source.

It exists to fix three chezmoi pain points the author hit running
chezmoi for years:

1. **The edit-source-then-apply tax** — every config tweak became a
   two-step ceremony.
2. **Source ↔ target drift** — apps overwrite the target directly,
   and the user finds out at the next `chezmoi diff`.
3. **Untracked new files** — apps that create new files inside a
   managed directory aren't visible to chezmoi unless you remember
   to `chezmoi add` them.

## How it works

Your dotfiles repo is a normal directory tree. `yui apply` walks it
and links each file/directory into its target location:

| platform | files | directories |
|----------|-------|-------------|
| Linux / macOS | symlink | symlink |
| Windows (default) | **hardlink** | **junction** |
| Windows (opt-in) | symlink | symlink (Developer Mode / admin) |

The Windows defaults are deliberate: hardlinks and junctions both
work without elevated permissions and survive most editors' "atomic
save" rename trick. When that trick *does* break the hardlink, the
**absorb classifier** notices on the next `apply` / `status`:

```
target's file-id == source's file-id?            → InSync
content identical, different file-id?            → RelinkOnly
target newer + content differs?                  → AutoAbsorb (target wins)
source newer + content differs?                  → NeedsConfirm (anomaly)
target missing?                                  → Restore
```

`AutoAbsorb` backs source up under `$DOTFILES/.yui/backup/` and
copies target's content into source before relinking — your local
edit is preserved, even when an editor saved over the link.

For directories the same target-wins merge applies: target's files
land in source (overwriting on conflict), source-only scaffolding
(like `.yuilink` markers) survives, and the dir is then re-exposed
via a platform-appropriate link back to source — junction on
Windows, symlink on Unix/macOS, or whatever the configured `dir_mode`
resolves to. Non-regular entries inside the target — junctions,
symlinks, device files — are skipped with a warning since following
them safely is ill-defined.

Per-file content collisions inside the merge run through the same
absorb classifier the file-level path uses: identical content is a
no-op, target-newer copies through (AutoAbsorb), and source-newer +
diff defers to `[absorb] on_anomaly` (skip / force / ask). The
marker is consent for the *whole-tree* merge, but a single file
where the source side is newer is still a real anomaly worth
surfacing.

## Install

```sh
cargo install yui-cli
```

Pre-built binaries for Linux x86_64, Windows x86_64, and macOS
(Intel + Apple Silicon) are attached to every
[GitHub Release](https://github.com/yukimemi/yui/releases).

## Quick start

```sh
# Scaffold a source repo at the current directory and install git hooks.
yui init --git-hooks

# Edit $DOTFILES/config.toml to declare your mounts, then:
yui apply        # render templates + link targets + auto-absorb drift
yui list         # see every src→dst mapping at a glance
yui status       # check what drifted
yui doctor       # environment sanity check
```

Smallest useful `$DOTFILES/config.toml`:

```toml
[[mount.entry]]
src = "home"
dst = "~"          # ~ expands to $HOME / $USERPROFILE per OS

[[mount.entry]]
src  = "appdata"
dst  = "{{ env(name='APPDATA') }}"
when = "yui.os == 'windows'"
```

Add files under `home/` and they'll link into `~`. Add a `.yuilink`
file to a directory to junction the whole directory as one unit (so
files an app creates inside that dir land back in source
automatically).

`src` is the path *to* yui's source for that mount; it accepts
relative paths (resolved against `$DOTFILES`), absolute paths, `~`
/ `~/...`, and Tera tags. So a private clone outside `$DOTFILES`
can participate as its own mount:

```toml
[[mount.entry]]
src = "~/.dotfiles-private/home"
dst = "~"
```

## Templates (`*.tera`)

Files ending in `.tera` are rendered with [Tera] before linking; the
output is a sibling file with the `.tera` suffix dropped. `yui` adds
the rendered file to a managed `# >>> yui rendered (auto-managed) <<<`
section of `.gitignore` so it doesn't get committed.

```
home/.gitconfig.tera   →  home/.gitconfig   →  ~/.gitconfig
```

Templates have access to `yui.os` / `yui.host` / `yui.user` /
`yui.arch` / `yui.source` and your `[vars]` table. Per-host overrides
go in `config.local.toml` (machine-local, gitignored), which `yui`
loads after `config.*.toml` so its values win.

[Tera]: https://keats.github.io/tera/

## One source → many targets

If you want the same source directory linked to different places on
different OSes — common for editor configs (`~/.config/nvim` on Unix,
`%LOCALAPPDATA%\nvim` on Windows) — drop a `.yuilink` with content:

```toml
# $DOTFILES/home/.config/nvim/.yuilink
[[link]]
dst = "~/.config/nvim"

[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"
```

`yui list` shows each link and which `when` would activate it.

### Stacking markers and file-level entries

Markers compose. A parent `.yuilink` no longer stops the walker, so
you can junction a whole `~/.config` and *also* layer extra dsts onto
specific subdirs:

```toml
# $DOTFILES/home/.config/.yuilink — junction the whole .config dir
[[link]]
dst = "~/.config"
```

```toml
# $DOTFILES/home/.config/nvim/.yuilink — extra Windows-only dst
[[link]]
dst = "{{ env(name='LOCALAPPDATA') }}/nvim"
when = "yui.os == 'windows'"
```

Both links land — the parent takes care of the natural placement, the
child adds its OS-specific alternate.

A `[[link]]` may also carry a `src = "<filename>"` to scope the link to
a single sibling file rather than the directory itself. Useful for
paths that don't follow `~/.config/<app>/` conventions, like the
PowerShell profile on Windows:

```toml
# $DOTFILES/home/.config/powershell/.yuilink
[[link]]
src = "Microsoft.PowerShell_profile.ps1"
dst = "{{ env(name='USERPROFILE') }}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
when = "yui.os == 'windows'"
```

`src` must be a single filename (no path separators); the file lives
right next to the marker. The rest of the directory still falls
through to whatever placement an ancestor (or the parent mount)
provides.

## `.yuiignore` — exclude paths from being linked

A `$DOTFILES/.yuiignore` file (gitignore syntax) keeps matched paths
out of every yui flow — render skips them, list omits them, and apply
won't link them. Useful for editor lock-files, build artifacts, OS
junk like `.DS_Store`, and anything else that lives next to your real
config but shouldn't be propagated:

```gitignore
# $DOTFILES/.yuiignore
**/.DS_Store
**/lock.json
home/.config/nvim/lazy-lock.json     # exact path also works

# Exclude all of build/ except the one file we DO want linked
build/
!build/result.toml
```

Nested `.yuiignore` files inside subdirectories are honored too, with
the same rule-scoping semantics as `.gitignore`: deeper layers override
shallower ones, `!negation` re-includes paths, and rules apply only to
the subtree below the file. Put repo-wide rules at `$DOTFILES/.yuiignore`
and per-tree rules where they belong.

## Secrets (`*.age` — opt-in)

Files ending in `.age` are encrypted with [age]; on every `apply` the
ciphertext is decrypted to a sibling file without the suffix, exactly
like `*.tera` rendering. The plaintext sibling is added to the managed
`.gitignore` section so it never gets committed.

```
home/.ssh/id_ed25519.age   →  home/.ssh/id_ed25519   →  ~/.ssh/id_ed25519
       ↑ committed                  ↑ gitignored               ↑ linked
```

### Bootstrap

```sh
yui secret init        # generates ~/.config/yui/age.txt
                       # appends the public key to $DOTFILES/config.local.toml
yui secret encrypt home/.ssh/id_ed25519
                       # produces home/.ssh/id_ed25519.age, ready to commit
```

`config.toml` after `init`:

```toml
[secrets]
identity = "~/.config/yui/age.txt"          # picked up automatically
recipients = [
  "age1abc...",   # this machine's public key
  # add more entries to grant other machines access
]
```

The feature is **off** until `recipients` has at least one entry — old
repos without `[secrets]` keep behaving exactly as before.

### Multi-machine

age supports multiple recipients per file. To grant a new machine
access:

1. On the new machine: `yui secret init` → generates a per-machine key,
   appends its public key to `config.local.toml` `[secrets].recipients`.
   Move that public-key line to `config.toml` (the committed one) so
   other machines see it too.
2. On a machine that already has the secret: re-encrypt every `.age`
   so its recipient list includes the new public key. (For now: run
   `yui secret encrypt --force <path>` per file. A `yui secret reencrypt`
   helper is planned.)

### Carrying the X25519 across machines (vault model)

`apply` only ever uses the plain X25519 secret at
`[secrets].identity` — no device prompts on the hot path. To
ferry that secret to a new machine, yui wraps a vault provider
of your choice (**Bitwarden** or **1Password**) so the same
auth you already use for that vault — master password, biometric,
passkey unlock in the web vault, SSO — gates the unlock here.

```toml
[secrets]
identity   = "~/.config/yui/age.txt"   # X25519 plain, gitignored
recipients = ["age1abc…"]              # X25519 publics for *.age files

[secrets.vault]
provider = "bitwarden"                 # or "1password"
```

The vault item is stored under the fixed name
`yui-x25519-identity` — yui doesn't expose a per-repo override
yet (no one's hit the multi-yui-tree-on-one-vault collision in
practice, so it's hardcoded).

#### Setup (once on the first machine)

```sh
yui secret init                # generates ~/.config/yui/age.txt
yui secret store               # pushes the file into the vault Secure Note
```

#### On each new machine

```sh
git clone <dotfiles>
# 1. Authenticate the vault CLI ONCE on this machine:
bw login && bw unlock          # Bitwarden — or `op signin` for 1Password
# 2. Pull the X25519 from the vault:
yui secret unlock              # writes ~/.config/yui/age.txt
yui apply                      # done
```

The vault CLI itself is the auth boundary — yui shells out to
`bw` / `op` and inherits whatever factor that CLI accepts.
Bitwarden's web vault supports passkey unlock; once you've used
your Pixel passkey to log into the BW web vault and the CLI
session is alive, `yui secret unlock` will quietly fetch the
X25519 with no further prompts.

#### Plugin recipients (advanced, unsupported)

`[secrets].recipients` accepts plugin-flavoured public keys
(`age1yubikey1…`, `age1fido2-hmac1…`, …) alongside the X25519
ones. yui doesn't ship first-class commands for plugin
identities — `apply` decrypts only with the X25519 in
`[secrets].identity` — but encrypting `*.age` files to
plugin-backed *recipients* works as long as the matching
`age-plugin-*` binary is on `$PATH`, so a YubiKey holder can
decrypt the same file via `age` directly without yui in the
loop. No support promises, but the path is open.

[age]: https://age-encryption.org/

## Hooks — run scripts around `apply`

Drop a script under `$DOTFILES/.yui/bin/` and reference it with a
`[[hook]]` entry. The script stays a normal executable (you can run
it directly without yui); yui just decides *when* to invoke it.

```toml
[[hook]]
name   = "brew-bundle"
script = ".yui/bin/brew-bundle.sh"
# Defaults: command="bash", args=["{{ script_path }}"], when_run="onchange", phase="post"
when   = "yui.os == 'macos'"
```

Schema:

| field | required? | default | meaning |
|---|---|---|---|
| `name` ||| unique identifier (state-tracking key, `yui hooks run <name>`) |
| `script` ||| path to script, relative to `$DOTFILES` |
| `command` | | `"bash"` | Tera-templated interpreter |
| `args` | | `["{{ script_path }}"]` | Tera-templated each |
| `when_run` | | `"onchange"` | `once` \| `onchange` \| `every` |
| `phase` | | `"post"` | `pre` \| `post` (around `apply`) |
| `when` | | _always_ | optional Tera bool predicate |

`onchange` re-runs whenever the script's SHA-256 differs from the
last successful run. State is tracked in `$DOTFILES/.yui/state.json`
(gitignored — it's per-machine).

`command` and each `args` element are Tera-rendered with the standard
`yui.*` / `vars.*` / `env(...)` plus these extras for the script:
`script_path`, `script_dir`, `script_name`, `script_stem`,
`script_ext`. Example with a Deno script:

```toml
[[hook]]
name = "denops-build"
script = ".yui/bin/build.ts"
command = "deno"
args = ["run", "-A", "{{ script_path }}"]
when_run = "onchange"
phase = "post"
when = "yui.os != 'windows'"
```

Manual control:

```sh
yui hooks list                    # what's configured + last-run state
yui hooks run                     # run all hooks per their rules
yui hooks run brew-bundle         # run just this one (still honors `when`)
yui hooks run brew-bundle --force # bypass when_run state check
```

`apply` runs `pre` hooks before render/link and `post` hooks after
all linking. A failing hook stops `apply` immediately — fix the
script, then re-run.

## Anomalies and the `[absorb]` policy

When source AND target both diverge from each other, `yui` can't
auto-merge. It defers to your `[absorb] on_anomaly` setting:

```toml
[absorb]
auto              = true     # auto-absorb on any AutoAbsorb classification
require_clean_git = true     # treat dirty source as anomaly
on_anomaly        = "ask"    # "ask" | "skip" | "force"
```

- `ask` — on a TTY, render the diff and prompt y/N; off-TTY, skip
- `skip` — log a warning and leave both sides untouched
- `force` — treat the anomaly as auto-absorb anyway (target wins)

Need to absorb a single file regardless of policy? `yui absorb
<target-path>` does that — bypasses `auto`, `require_clean_git`, and
`on_anomaly` for an explicit user-initiated pull.

## Commands

| | |
|---|---|
| `yui init [--git-hooks]` | scaffold `config.toml` + `.gitignore` in cwd |
| `yui apply [--dry-run]` | render → link → auto-absorb |
| `yui render [--check] [--dry-run]` | template-only pass; `--check` fails on drift |
| `yui link [--dry-run]` | alias for apply (kept for muscle memory) |
| `yui list [--all] [--icons MODE] [--no-color]` | every src→dst mapping |
| `yui status [--icons MODE] [--no-color]` | drift overview, exits non-zero on any divergence |
| `yui diff [--icons MODE] [--no-color]` | unified diff of every drifted entry (link or render) |
| `yui absorb <target> [--dry-run] [--yes]` | pull one target into source — prints diff, confirms (`--yes` to skip) |
| `yui unlink <path>...` | tear down a specific link |
| `yui update [--dry-run]` | `git pull --ff-only` source repo, then re-apply |
| `yui unmanaged [--icons MODE] [--no-color]` | list source files no `[[mount.entry]]` claims |
| `yui doctor` | environment sanity check |
| `yui gc-backup [--older-than DUR] [--dry-run]` | survey or prune `.yui/backup/` snapshots by suffix age |
| `yui hooks list` | show configured `[[hook]]` entries + last-run state |
| `yui hooks run [<name>] [--force]` | run hooks on demand (bypassing `when_run` with `--force`) |
| `yui completion <shell>` | print shell completion (bash / zsh / fish / powershell / elvish) |

`--icons` accepts `unicode` (default), `nerd` (Nerd-Font glyphs),
`ascii` (CI-log-safe). The `[ui] icons = "..."` config key sets it
globally.

## Status

Used in production for the author's own ~/dotfiles. Known gaps:

- no built-in encryption (use `pass` / `1password-cli` from a Tera
  template instead)

## License

MIT