Cross-platform dotfiles built on a Nix flake + standalone
Home Manager running on
Lix, with a thin imperative layer
(platform/) for the few things Home Manager can't do on a
non-NixOS host. Targets macOS (aarch64) and Debian/Ubuntu (x86_64 + aarch64).
The zsh + Starship (catppuccin_mocha) + fzf-tab experience is preserved.
Design is recorded in ADR-0007 (intent) and RFC-0001 (discussion trail); AGENTS.md is the contributor/agent guide.
Warning: These are my personal settings. Fork the repo and review the code before running it — don't blindly apply someone else's configuration. The bootstrap can install Nix, change your login shell, and install system software. See Trying it on a new machine for the (fully recoverable) safety model first.
git clone git@github.com:HernandoR/dotfiles.git
cd dotfiles
./bootstrap.sh --dry-run --verbose # preview every step, run nothing (recommended first)
./bootstrap.sh # then run for realbootstrap.sh needs curl and git. No privilege is required if Nix is
already installed; otherwise it needs root/sudo to install Lix (with no
init system — bare container/CI — it falls back to a single-user install).
On a terminal it asks before it touches anything. It prints the whole plan first — what will be installed, from which network/mirrors, which config files are written, and every symlink it will place — then asks for clearance once:
==> Plan — nothing has run yet
os ubuntu (x86_64)
host dotfiles-debian
privilege sudo — privileged steps run via sudo (may ask for your password)
network upstream defaults (pass --network CN for the China mirrors)
will install # prerequisites, Lix, the HM generation, mise runtimes …
will write / link # system nix.conf, every HM symlink, the login shell
will move your existing files aside (renamed, never deleted)
- any $HOME file Home Manager wants to own -> the same name with a .backup suffix
? Proceed with this plan? [Y/n]
(Each section really lists every item, one per line, with [privileged] on the
steps that use root/sudo.) The last section is separate on purpose: displacing
files you already have is the only part of a bootstrap that touches your data, so
it is listed file-by-file, last, right where you answer.
Answering anything but yes exits without changing a thing. There is exactly one
prompt — no step-by-step nagging. A run with no terminal (CI, container
build, cron, bash -c) never asks and behaves as it always has; --yes skips
the prompt on a terminal too (the plan is still printed). Design record:
ADR-0010.
Split around the Home Manager switch:
- Pre-HM (shell): detect privilege (root / sudo / none) → install
prerequisites → install Lix → configure Nix (+ optional CERNET mirror) →
build & activate Home Manager with
-b backup(this is also where the out-of-store$HOMElinks fromhome/env-links.nixare placed). - Post-HM (Python via
uv): set the login shell to the Nix zsh (chsh) → install the coding agents and project the capability manifest onto each (ADR-0011) → write the interactive remainder → install any opt-in Linux system components.
When it finishes, the shell that launched it keeps its old PATH, so a bare
zsh won't be found yet. Start the new environment with the absolute path it
prints, or just re-login (your login shell is already zsh):
exec ~/.nix-profile/bin/zsh -l| Flag | Effect |
|---|---|
--dry-run |
Print every command without executing it (no clearance prompt — nothing to clear). |
--verbose |
Echo each command as it runs. |
--yes / -y |
Skip the clearance prompt (the plan is still printed). Same as DF_ASSUME_YES=1. |
--network CN |
Enable China (CERNET) mirrors for Nix, pypi/uv, and rustup. |
--system <list> |
Install opt-in Linux system components (all = every one). |
--host NAME |
Force a named flake host instead of auto-detecting. |
--agents <list> |
Which coding agents to provision: claude,codex,omp / all (default) / none. |
--no-claude |
Deprecated alias for --agents none. |
| Env var | Effect |
|---|---|
DF_ASSUME_YES=1 |
Skip the interactive clearance (same as --yes); exported automatically once you have cleared the plan, so the nested steps never re-ask. |
DOTFILE_NETWORK_ENV=CN |
Same as --network CN (also read by the zsh env for pypi/rustup). |
DOTFILE_SYSTEM_COMPONENTS |
Fallback for --system (e.g. all); the flag wins. |
DOTFILE_AGENTS |
Fallback for --agents (e.g. claude or none); the flag wins. |
DOTFILE_FLAKE_CACHE |
Dir with seed-paths.txt to seed flake inputs from (CN/offline/CI). |
Safety model — nothing is destroyed:
- Preview first:
./bootstrap.sh --dry-run --verboseruns nothing. An ordinary interactive run also prints its full plan and waits for clearance before the first change. - Existing dotfiles are backed up, not deleted. Activation uses
-b backup(HOME_MANAGER_BACKUP_EXT=backup), so a pre-existing~/.zshrc/~/.gitconfig/ etc. is renamed to~/.zshrc.backupbefore the Home Manager symlink is placed. - The old setup stays intact. The previous (pre-Nix) config remains on the
archivebranch, and previous Home Manager generations are kept until you expire them.
Roll back (after the home-manager CLI is on PATH):
# 1) step back exactly one generation (no rebuild, no flake needed)
home-manager switch --rollback
# 2) or activate a specific earlier generation
home-manager generations # list them (newest first)
PROFILE=~/.local/state/nix/profiles/home-manager # or /nix/var/nix/profiles/per-user/$USER/home-manager
nix-env --profile "$PROFILE" --switch-generation <id>
"$PROFILE"/activate
# 3) restore a file that was backed up
mv ~/.zshrc.backup ~/.zshrc # repeat for any *.backup
# 4) restore your previous login shell
chsh -s "$(command -v bash)" # or your prior shellFully uninstall Home Manager:
home-manager uninstall # prompts; removes the HM symlinks + generationsuninstall removes the symlinks Home Manager created but does not restore your
*.backup files — move those back manually (mv ~/.zshrc.backup ~/.zshrc) and
chsh back to your old shell. Reclaim store space with nix-collect-garbage -d.
To remove Nix/Lix entirely, follow the Lix uninstall docs.
Prune old generations later:
home-manager expire-generations "-30 days" # keep the last 30 days (current is always kept)
home-manager remove-generations <id> [<id>…] # remove specific ones
nix-collect-garbage -d # then reclaim diskgit pull on its own changes nothing in $HOME: every dotfile is a symlink into
/nix/store, so the repo is only a build input. One switch applies whatever came
in — from upstream or from your own edit:
git pull # on an env branch (prod/mewtant): rebase onto the shared branch, never merge
just switch # == home-manager switch --flake .#<host> -b backup
exec zsh -l # pick up the new PATH / env / completions
mise use -g <tool>@<ver> # only if home/mise.nix gained a tool — see below, a
# switch does not push it to a machine you already set upThe just recipes. Justfile names the commands in this section, so the
host, --impure, and -b backup are not yours to remember. just on its own
lists them; the ones you will actually use are build, diff, switch,
reset-hard, check, update, news, packages, generations, rollback,
expire, gc, and plan. Everything below spells out what a recipe runs —
reach for the raw command when you want a variation, or before the first switch
has put just on PATH (it comes from mise).
Which host? Your hostname if flake.nix defines it, else the OS/arch default
(platform/lib.sh:211). Any other user — including root — uses the impure
generic fallback: home-manager switch --flake .#generic -b backup --impure.
just show-host prints what resolves for you; just host=<name> switch (or
DF_HOST=<name>) overrides it.
-b backup is what the bootstrap does (HOME_MANAGER_BACKUP_EXT=backup);
without it a switch aborts as soon as it finds a real file where a symlink should
go. To see the delta before activating:
nix build --no-link --print-out-paths .#homeConfigurations."<host>".activationPackage
nix store diff-closures /nix/var/nix/profiles/per-user/"$USER"/home-manager <the printed path>If the result is wrong, home-manager switch --rollback — see
Trying it on a new machine.
Starting over from the repo — when $HOME has drifted, or a switch keeps
aborting on .backup files left by an earlier cycle:
just reset-hard # move every managed path aside, then activateIt collects every $HOME path the generation would own into a single
~/dotfiles_backup/YYYY_MM_DD_HHMMSS/, keeping each file's $HOME-relative
name, and only then activates. Because nothing is left in the way, Home Manager
renames nothing and the .backup collision that aborts a plain switch
(ADR-0009) cannot happen.
Everything is moved, never deleted — recover any file by copying it back out
of the timestamped directory. It prints the full list and asks once before
touching anything (DF_ASSUME_YES=1 to skip, which a non-interactive run must
pass explicitly — silence is refusal, not consent). It does not sweep
pre-existing *.backup files.
An env-linked path (~/.claude, ~/.ssh, …) is a symlink, so what moves is the
link; the data in envLinks.stateRoot is untouched and the activation relinks
it. One guard: if ~/.ssh is still a real directory holding
authorized_keys while the persistent target has none, the recipe refuses
rather than severing inbound SSH to the machine.
Updating versions (as opposed to applying config):
nix flake update # all inputs; or `nix flake update nixpkgs`
home-manager switch --flake .#<host> -b backup
mise up # mise tools, within their declared rangesCommit the changed flake.lock together with the change that needed it.
Only needed when the change is in the imperative half: platform/ itself, a
login shell that never got set, or a new --system component. Re-runs are
idempotent — Lix is skipped when nix exists
(platform/lib.sh:312), nix.conf lines are deduplicated before appending
(platform/nix-cn.sh:59), an unchanged generation is reused rather than created
("No change so reusing latest profile generation"), and chsh, mise install
and brew all no-op when already done. Four things to know:
- Pass the same flags as the first run. Without
--network CNthe run deletes~/.config/dotfiles/network-env(platform/nix-cn.sh:94), silently dropping the pypi/uv + rustup mirrors from your shell. - A leftover
.backupaborts activation. If a file Home Manager newly wants to own already exists for real and<name>.backupis still there from last time, activation fails with "would be clobbered by backing up". Delete the stale.backup, or re-run withHOME_MANAGER_BACKUP_OVERWRITE=1. - The post-login script comes back.
setup.pyrewritespost-login-setup.shunconditionally, sodotfiles-postsetupis offered again even after you ran it;codegraph upgradealso runs every time.--agents noneskips both. - Removing a manifest entry does not uninstall it. Agent projection is
add-only: drop a marketplace, plugin, MCP server or agent extension from
platform/installers/agents.pyand the machines that already applied it keep it. Uninstall there by hand, once. - Disk is what accumulates, not installs. Each changed
flake.lockleaves a generation behind, and*.backupfiles are never removed — prune withexpire-generations+nix-collect-garbage(above).
Components in this repo are split into two broad categories:
- User components — declarative, managed by Home Manager in
home/packages.nix. - System components — imperative, installed by
platform/setup.pyvia theOptionalComponentregistry inplatform/installers/components.py.
The home/packages.nix list Home Manager installs on every switch — the core CLI
toolset (ripgrep, jq, fd, tree, wget, uv, …), some of it gated by OS
in the same file (xclip on Linux only). Never selected with --system: always
applied.
What Home Manager cannot own on a non-NixOS host, installed after the switch and
selected with --system <list> / DOTFILE_SYSTEM_COMPONENTS:
| Name | Description | OS |
|---|---|---|
software-properties |
add-apt-repository support (required on Linux — always installed) |
debian, ubuntu |
docker |
Docker Engine (rootful) | debian, ubuntu |
docker-rootless |
Docker (rootless) | debian, ubuntu |
cuda |
CUDA Toolkit 12.6 | debian, ubuntu |
nvidia |
NVIDIA driver + container toolkit | debian, ubuntu |
llvm |
LLVM 18 (+ update-alternatives) |
debian, ubuntu |
brew |
Homebrew — the package manager only (no formulae/casks) (default on macOS) | darwin |
The selector takes names, alias groups and all; docker + docker-rootless
together resolve to rootless. Unset means the default group — brew on macOS,
nothing optional on Linux — and software-properties still runs on Debian/Ubuntu
unless you pass --system none, which opts out of everything.
./bootstrap.sh --system docker,llvm # + the required Linux prerequisites
DOTFILE_SYSTEM_COMPONENTS=cuda,nvidia ./bootstrap.sh
./nix-system-interactive-install.sh # add components later (--dry-run to preview)
uv run platform/installers/components.py # list what existsmacOS: brew installs Homebrew itself only (CLI tools come from nixpkgs; on
CN via the BFSU mirror). GUI apps are a separate, manual, never-auto-run picker —
./brew-cask-interactive-install.sh, a uv script
(platform/brew_cask_install.py) that offers the
recommended casks as a checklist (Edge + Alacritty pre-checked; edit the list in
the file) and a mirror choice defaulting to DOTFILE_NETWORK_ENV.
Where a new tool is written down depends on which layer owns it:
| What you want | Write it in | Scope |
|---|---|---|
| A CLI tool that exists in nixpkgs | home/packages.nix |
every host, on every switch |
| A runtime, or a tool that only ships via npm/cargo/go/gh-release | home/mise.nix (the tools attrset) |
new hosts on bootstrap; existing ones need mise use -g |
| Something only one project needs | that project's mise.toml, or its own flake.nix devShell |
that directory tree |
| A daemon/driver/apt-level thing (docker, cuda, llvm, …) | platform/installers/components.py + --system |
see Component classification |
| A one-off experiment | nothing — nix shell nixpkgs#<pkg> |
the current shell only |
Nothing user-level is installed imperatively. Home Manager installs its
home-manager-path into the same profile ~/.nix-profile points at, so a
nix profile install / nix-env -i on the side competes with it for the same
file names, never reaches another machine, and does not show up in
home-manager packages. If you want the tool tomorrow, it goes into a file in
this repo.
nix search nixpkgs hyperfine # regex match over nixpkgs attributes + descriptions
nix search nixpkgs '^ripgrep$' # anchored: the exact attribute nameor search.nixos.org/packages — same data, with the attribute name and the binaries a package provides.
nix search nixpkgs resolves the registry nixpkgs (current unstable), while
this repo builds from the revision pinned in flake.lock. Confirm the attribute
exists there and see the version you'd actually get:
nix eval --raw .#homeConfigurations.dotfiles-debian.pkgs.ripgrep.version # -> 15.1.0Try it before committing to it — this puts it on PATH for one shell and
persists nothing:
nix shell nixpkgs#hyperfine # then: hyperfine --versionAdd the attribute to the list in home/packages.nix, in
the group it belongs to; wrap it in lib.optionals stdenv.isLinux /
isDarwin if it is OS-specific (home/packages.nix:47):
ripgrep
jq
+ hyperfine # benchmarkingUnfree packages need no extra step — mkHome instantiates nixpkgs with
config.allowUnfree = true (flake.nix:41). Then
sync it into your home.
Project dependencies never go into home/packages.nix. Ad hoc, in the project
directory:
nix shell nixpkgs#ffmpeg nixpkgs#imagemagick # this shell only, nothing persistedReproducible: give that project its own flake with a devShell and enter it
with nix develop (commit its flake.nix + flake.lock):
# <project>/flake.nix
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
outputs =
{ nixpkgs, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in
{
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [ pkgs.ffmpeg pkgs.imagemagick ];
};
};
}To have that shell load on cd instead, drop a one-line .envrc next to the
flake — direnv + nix-direnv are already part of this config
(home/direnv.nix):
echo 'use flake' > .envrc
direnv allow # required once per .envrc, and again after every edit
echo '.direnv/' >> .gitignorecd in and the devShell is active; cd out and it's gone. The first entry
builds the closure (slow); nix-direnv caches the result in .direnv/ and pins
it with a GC root, so later entries are instant and nix-collect-garbage leaves
it alone.
direnv and the global mise activate coexist, and the devShell wins: if the
devShell lists a tool mise also manages (node, just, …), the devShell's copy
is the one on PATH inside that project — even if a project mise.toml pins a
different version. Leave such a tool out of the devShell to keep mise's.
mise registry | grep -i terraform # tool name -> the backend(s) mise would use
mise ls-remote node # versions available for a toolShort names resolve through mise's registry (core/aqua/ubi); other backends are
named explicitly: npm:<pkg>, cargo:<crate>, go:<module>, pipx:<pkg>,
ubi:<owner>/<repo>.
mise's global config is split, because its two halves want opposite ownership (ADR-0009 tiers):
| File | Holds | Owner |
|---|---|---|
~/.config/mise/config.toml |
the tool list | mise. Seeded from home/mise.nix the first time the target does not exist, then yours to rewrite |
~/.config/mise/conf.d/zz-dotfiles.toml |
[settings] |
Home Manager. Read-only store link, re-applied on every switch |
So mise use -g, mise up --bump and mise unuse all work and persist — the
real config.toml lives under envLinks.stateRoot, so those versions also
survive container recreation.
The split is what makes both true at once: within the global config, a conf.d
file always overrides config.toml (mise will tell you so: X is defined in conf.d/… which overrides the global config). That is what settings want and
exactly what tools must not have, so only [settings] goes there.
A tool added to home/mise.nix does not reach a machine that already
bootstrapped — the seed applies on creation only, and a switch will not touch
your config.toml. Add it there with mise use -g <tool>@<version>; the repo
list stays the source of truth for the next fresh machine, which is why new
tools still belong in home/mise.nix:
just = "latest";
node = "lts";
+ terraform = "latest";
+ "npm:@openai/codex" = "latest";npm-backed tools are installed with pnpm (npm.package_manager = "pnpm",
home/mise.nix:88 — a setting, hence the conf.d half), and pnpm blocks
dependency lifecycle scripts by default. If a package genuinely needs its
postinstall, approve exactly that package the way @smithery/cli does
(home/mise.nix:29):
"npm:@smithery/cli" = {
version = "latest";
allow_builds = [ "@smithery/cli" ];
};Then, on this machine, add it and materialize — a declared-but-not-installed
tool is not on PATH until it is installed (home/mise.nix:60-64):
mise use -g terraform@latest # writes ~/.config/mise/config.toml and installs
mise ls # what is installed / active
just runtimes # == mise install: catch up anything still missingTo check the two halves are landing where they should:
mise config ls # both files, and which tools each contributes
mise settings # resolved settings (from the conf.d half)Host-local escape hatch: a second ~/.config/mise/conf.d/*.toml still
overrides everything, including the repo's settings — conf.d resolves
lexically first-wins, and the Home-Manager-owned file is named zz- precisely
so any name you pick beats it. Prefer plain mise use -g for host-local tools
now that config.toml is yours; keep conf.d for overriding a setting.
cd <project>
mise use node@22 python@3.12 # writes ./mise.toml (creating it) and installs
mise trust # needed for a mise.toml that came from git, not from you
mise current # active versions here
mise which node # which shim/binary resolvesCommit mise.toml in that project; project config is independent of Home
Manager. mise up upgrades within the declared range, and mise up --bump —
which rewrites the config file — now works on the global config too. Mirror a
bump you want to keep into home/mise.nix, or the next fresh machine seeds the
old version.
| Want to change | File |
|---|---|
zsh options/plugins, PATH, session variables |
home/shell.nix |
| zsh functions, aliases, fzf-tab tweaks | home/zsh/functions.zsh, home/zsh/fzf-tab.zsh (sourced verbatim) |
| the prompt | home/starship.toml (read by home/starship.nix) |
| git settings | home/git.nix; aliases in home/git-aliases.conf |
| tmux | home/tmux.conf (+ home/tmux.nix) |
| mise settings (live), mise tool seed | home/mise.nix |
| links to writable, out-of-store paths | home/env-links.nix (ADR-0009 Tier B — the set every environment wants) |
| the same, for one environment only | home/env-branch.nix (empty on shared branches; the only file an env branch edits, so its rebases never conflict) |
| a new machine | the hosts attrset in flake.nix:17 |
Two conventions worth keeping (see AGENTS.md): prefer an upstream
programs.* option over hand-rolled config, and embed verbatim files
(builtins.readFile / source ${./file}) instead of escaping large blobs into
nix strings. Don't reorder the zsh plugin list in home/shell.nix — completions
→ fzf-tab → autosuggestions → syntax-highlighting-last is correctness-critical.
Everything above is inert until Home Manager switches: see
Staying in sync for the switch, the preview, and the rollback.
Verify a package landed with home-manager packages | grep hyperfine. Or let the
bootstrap drive it — it detects the host and re-runs the post-HM steps too
(./bootstrap.sh --dry-run --verbose, then ./bootstrap.sh --yes).
Three agents — Claude Code, Codex CLI and omp (oh-my-pi) — are
provisioned from one in-repo manifest (platform/installers/agents.py) and applied
with each agent's own CLI. What the agents have (marketplaces, plugins, MCP
servers) is a reviewed table there; what each agent is (model, theme, approval
policy) stays in its own config, which the agents rewrite at runtime and nothing
here touches. Cross-agent instructions live once, in ~/.agents/AGENTS.md, which
Codex and omp read directly and Claude imports from its thin ~/.claude/CLAUDE.md
shell. omp replaces the pi agent (ADR-0011 update log, 2026-08-06) and is
installed as a Nix package from the llm-agents-nix flake input
(home/packages.nix); its config is deliberately not Home-Manager-managed —
~/.omp is a symlinked staging root and plugins go through omp's own interface.
Design record:
ADR-0011.
python3 platform/installers/agents.py # what the agents have, and who gets what
./bootstrap.sh --agents claude,codex # provision a subset (default: all three)Adding a capability is an edit to that manifest plus a commit — never a per-machine command, which is the drift the ADR exists to stop.
Only the two steps that genuinely need you — Smithery auth and the Lark CLI's own
installer — are deferred. setup.py writes them to
~/.local/share/dotfiles/post-login-setup.sh; the zsh prints a reminder while it's
pending. Run it once when you're ready to authorize:
dotfiles-postsetup # needs a TTY; self-removes on successIt offers to authenticate Smithery and add your namespace's MCP endpoint to Claude, then installs the Lark CLI — each step skippable, nothing fatal. Marketplaces, plugins, MCP servers and omp's native MCP config are not here: they are applied unattended during the bootstrap. Details: platform/README.md.
Everything mirror-related is gated on one switch. With --network CN (or
DOTFILE_NETWORK_ENV=CN) the bootstrap wires the CERNET substituter into the
system nix.conf and the zsh exports pypi/uv + rustup mirrors. Unset = upstream
defaults.
Justfile `just` recipes for the day-to-day Home Manager commands
bootstrap.sh Thin entry → platform/bootstrap.sh
flake.nix Inputs (nixpkgs + home-manager), hosts, homeConfigurations
home/ Home Manager modules — the declarative user environment
packages.nix All user-level CLI tools
shell.nix zsh (fzf-tab order), fzf, zoxide, sessionPath/Variables
starship.nix + starship.toml (catppuccin_mocha theme)
git.nix, tmux.nix, mise.nix, zsh/
platform/ Imperative layer (see platform/README.md)
bootstrap.sh Orchestrator; lib.sh; nix-cn.sh; setup.py; installers/
docs/plans/ ADRs (0007 governs)
docs/rfc/ RFCs (0001 = migration log)