yui-cli 0.7.8

Target-as-truth dotfiles manager: edit your live configs, source repo updates automatically via hardlink/junction/symlink.
Documentation

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

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.

Quick start

# 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:

[[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).

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
[[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:

# $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.

.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.

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.

[[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 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:

[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 absorb <target> [--dry-run] manually pull a single target into source
yui unlink <path>... tear down a specific link
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)

--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