Skip to content

yukimemi/yui

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

106 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

yui — target-as-truth dotfiles manager

結 — edit your live configs, the source repo updates itself.

crates.io CI License: MIT Ask DeepWiki View Code Wiki

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

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 = "~"

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.

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

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:

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

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

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

On each new machine

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.

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

About

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

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages