結 — edit your live configs, the source repo updates itself.
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.
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.
cargo install yui-cliPre-built binaries for Linux x86_64, Windows x86_64, and macOS (Intel + Apple Silicon) are attached to every GitHub Release.
# 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 checkSmallest useful $DOTFILES/config.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:
[[mount.entry]]
src = "~/.dotfiles-private/home"
dst = "~"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.
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
[[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.
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
[[link]]
dst = "~/.config"# $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:
# $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.
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.tomlNested .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.
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
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 commitconfig.toml after init:
[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.
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.)
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.
[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).
yui secret init # generates ~/.config/yui/age.txt
yui secret store # pushes the file into the vault Secure Notegit 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 # doneThe 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.
[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.
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.
[[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:
[[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:
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 checkapply runs pre hooks before render/link and post hooks after
all linking. A failing hook stops apply immediately — fix the
script, then re-run.
When source AND target both diverge from each other, yui can't
auto-merge. It defers to your [absorb] on_anomaly setting:
[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, 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.
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.
Used in production for the author's own ~/dotfiles. Known gaps:
- no built-in encryption (use
pass/1password-clifrom a Tera template instead)
MIT