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:
- The edit-source-then-apply tax — every config tweak became a two-step ceremony.
- Source ↔ target drift — apps overwrite the target directly,
and the user finds out at the next
chezmoi diff. - Untracked new files — apps that create new files inside a
managed directory aren't visible to chezmoi unless you remember
to
chezmoi addthem.
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
Pre-built binaries for Linux x86_64, Windows x86_64, and macOS (Intel + Apple Silicon) are attached to every GitHub Release.
Quick start
# Scaffold a source repo at the current directory and install git hooks.
# Edit $DOTFILES/config.toml to declare your mounts, then:
Smallest useful $DOTFILES/config.toml:
[[]]
= "home"
= "~" # ~ expands to $HOME / $USERPROFILE per OS
[[]]
= "appdata"
= "{{ env(name='APPDATA') }}"
= "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:
[[]]
= "~/.dotfiles-private/home"
= "~"
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.
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:
# $DOTFILES/home/.config/nvim/.yuilink
[[]]
= "~/.config/nvim"
[[]]
= "{{ env(name='LOCALAPPDATA') }}/nvim"
= "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:
# $DOTFILES/home/.config/.yuilink — junction the whole .config dir
[[]]
= "~/.config"
# $DOTFILES/home/.config/nvim/.yuilink — extra Windows-only dst
[[]]
= "{{ env(name='LOCALAPPDATA') }}/nvim"
= "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:
# $DOTFILES/home/.config/powershell/.yuilink
[[]]
= "Microsoft.PowerShell_profile.ps1"
= "{{ env(name='USERPROFILE') }}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
= "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:
# $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
# appends the public key to $DOTFILES/config.local.toml
# produces home/.ssh/id_ed25519.age, ready to commit
config.toml after init:
[]
= "~/.config/yui/age.txt" # picked up automatically
= [
"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:
- On the new machine:
yui secret init→ generates a per-machine key, appends its public key toconfig.local.toml[secrets].recipients. Move that public-key line toconfig.toml(the committed one) so other machines see it too. - On a machine that already has the secret: re-encrypt every
.ageso its recipient list includes the new public key. (For now: runyui secret encrypt --force <path>per file. Ayui secret reencrypthelper is planned.)
Hardware MFA / passkey (wrap-and-unlock model)
apply only ever uses the plain X25519 secret at
[secrets].identity — no device prompts on the hot path. Hardware
keys (Pixel passkey, YubiKey, Bitwarden, Touch ID, Windows Hello,
TPM, 1Password, …) come in via age plugins, but yui scopes them
to a one-time-per-machine unlock step rather than every apply.
[]
= "~/.config/yui/age.txt" # X25519 plain, gitignored
= ["age1abc…"] # X25519 publics for *.age files
# Wrap setup — committed, lets a fresh machine recover the X25519:
= ".yui/age.txt.age" # ciphertext of identity
= [ # who can unwrap it
"age1fido2-hmac1…pixel",
"age1fido2-hmac1…bitwarden",
]
= ".yui/passkeys.txt" # device descriptors (one per device)
.yui/passkeys.txt is a multi-identity file — one comment-and-line
pair per device:
# Pixel
AGE-PLUGIN-FIDO2-HMAC-1…pixel…
# Bitwarden
AGE-PLUGIN-FIDO2-HMAC-1…bitwarden…
One-time setup on the first machine
# Generate plugin identities once per device — yui doesn't bundle plugins:
&&
One-time on each new machine
Picker
With multiple passkey_identities, unlock shows a dialoguer
Select prompt so you choose Pixel vs Bitwarden vs whichever device
you have at hand. Off-TTY (CI / scripts), age tries them in file
order; pass --passkey <label> to pick non-interactively (the
label is matched as a substring of the # comment above each
identity entry).
Trade-off vs full plugin-on-apply
The wrap-and-unlock model means changing recipients of *.age
files (e.g. revoking a machine's X25519 access) still requires
re-encrypting those files with the X25519 recipients alone. The
passkey only protects the X25519 secret in transit, not individual
secrets. This is deliberate — it keeps daily apply silent.
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.
[[]]
= "brew-bundle"
= ".yui/bin/brew-bundle.sh"
# Defaults: command="bash", args=["{{ script_path }}"], when_run="onchange", phase="post"
= "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:
[[]]
= "denops-build"
= ".yui/bin/build.ts"
= "deno"
= ["run", "-A", "{{ script_path }}"]
= "onchange"
= "post"
= "yui.os != 'windows'"
Manual control:
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:
[]
= true # auto-absorb on any AutoAbsorb classification
= true # treat dirty source as anomaly
= "ask" # "ask" | "skip" | "force"
ask— on a TTY, render the diff and prompt y/N; off-TTY, skipskip— log a warning and leave both sides untouchedforce— 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-clifrom a Tera template instead)
License
MIT