My machine setup, declared in one file. This repo used to be a Chezmoi source tree; everything now runs through mise bootstrap.
Built for macOS. Linux is a second-class but real target: packages carry os
filters and pick up apt equivalents, and CI runs the whole thing on both.
See Linux for what still does not work there.
| Path | What it is |
|---|---|
mise.toml |
The whole declaration: packages, directories, removals, repos, the LaunchAgent, and the dotfile mapping |
dotfiles/ |
The actual config files, laid out to mirror where they land in $HOME |
hk.pkl |
Lint and format steps, and which git hooks run them |
scripts/ |
The bootstrap hooks, as shell rather than TOML so they get linted |
fnox.toml |
Where a declared secret would come from — references only, no values, none declared today |
dotfiles/ is not magic. There is no name mangling and no dot_ prefix —
every file is listed explicitly in the [dotfiles] table of mise.toml, and
the directory is just somewhere tidy to keep the sources. It is a directory
inside this repo, not the repo itself.
Hello future me (and onlookers). On a new computer (congrats on the new hardware):
-
Install Homebrew, which the
brew:andbrew-cask:packages need. -
Install mise — from mise.run, not from Homebrew:
curl https://mise.run | sh -
Run it:
mise bootstrap --from git@github.com:btkostner/dotfiles.gitThat clones this repo to $MISE_DATA_DIR/bootstrap-repo and applies it. To
work from a checkout you already have instead:
mise trust && mise trust dotfiles/config/mise/config.toml && mise bootstrapThe second trust is for the global mise config this repo links into
~/.config/mise/config.toml; without it mise warns on every run.
mise bootstrap plan # resource-by-resource diff of declared vs. actual
mise bootstrap status # same thing, aggregated
mise bootstrap --dry-run # print every command the full run would executeOne caveat on --dry-run: the [tasks.bootstrap] task runs for real on every
invocation, dry or not. It installs the global toolchain, applies the history
service, and installs repo-local hooks; add --skip task for a genuinely inert
preview.
mise bootstrap walks its phases in a fixed order, which is what makes the
declarations above safe to write in any order:
- Packages — Homebrew formulae and casks. First, so the binaries the templates bake in by absolute path exist before they are rendered.
- Files and directories — creates
~/.ssh(0700) and the work projects directory, and deletes the bash/zsh leftovers. - Repos — clones AstroNvim into
~/.config/nvim. - Dotfiles — clears nushell's stub configs and installs omp, then
symlinks (and renders) everything in
dotfiles/. - macOS defaults — Dock, keyboard and trackpad preferences, then
killall Dockso they take effect. - LaunchAgents — installs the XDG agent.
- User —
chshto nushell. - Tools — installs
[tools]from every loaded mise config. [tasks.bootstrap]— installs the global toolchain, applies the history service, and installshk.
Most dotfiles are symlinks. Editing ~/.config/starship.toml edits the
file in this repo, and git diff is the source of truth for what has drifted.
Nushell is symlink-each, so each file is linked individually and nushell
can keep writing history and plugin state into ~/.config/nushell without any
of it landing here.
Some files are rendered instead, because they either branch on the OS or
bake in an absolute path. Those are the .tmpl sources, listed one by one
under [dotfiles]:
| Rendered | Why |
|---|---|
ssh/config |
1Password's agent socket is somewhere else on Linux |
projects/hiivemarkets/gitconfig |
so is op-ssh-sign |
config/nushell/env.nu |
Homebrew paths, the Zed and Postgres.app aliases, and the Erlang build flags are all macOS-only |
config/nushell/scripts/{mise,starship,gh-npm}.nu |
each calls a binary by absolute path |
Rendered files are copies, not links. Editing the deployed file does not
edit this repo — edit the .tmpl and re-apply, or use mise dot edit.
Ghostty is installed on macOS from Homebrew's ghostty cask. The cask
repackages Ghostty's official signed and notarized .dmg; Linux intentionally
does not install it because the Ghostty project only distributes prebuilt
binaries for macOS.
None are declared right now. The Zed GitHub PAT was the only one, and the MCP
server that needed it is gone — so nothing under dotfiles/ asks 1Password
for a value while rendering, and a plain mise bootstrap needs no fnox
wrapper. 1Password is still a package here; it is the agent that ssh/config
and the work gitconfig point at, which is a path, not a secret.
The wiring stays for the next one. mise.toml declares what a
template needs; fnox.toml says where it comes from. Neither
holds a value, so both are safe to commit:
# mise.toml
[bootstrap.secrets]
some_token = { env = "SOME_TOKEN", allow_empty = true }
# fnox.toml
SOME_TOKEN = { provider = "onepassword", value = "op://...", default = "", if_missing = "warn" }Run bootstrap through fnox so the values are in the environment:
fnox exec -- mise bootstrapAn interactive nushell already has them — scripts/fnox.nu activates fnox
per-directory — so a plain mise bootstrap works there too.
allow_empty and fnox's default = "" are two halves of one bargain, and it
is worth being precise about what each covers, because the failure modes are
not the same:
| State | Result |
|---|---|
| fnox resolves the secret | real value rendered |
fnox runs, 1Password unreachable or op missing |
fnox warns, substitutes "", bootstrap completes |
| bootstrap run without fnox at all | hard error, nothing rendered |
The middle row is the point: a machine with no 1Password still gets a
complete, valid file — just without the value in it. mise refuses to leave a
file half-rendered, so without allow_empty that row would be a failed run.
The last row is a genuine gap on a brand-new machine, because fnox installs
during the tools phase, which is after dotfiles. For that one first run,
either set the variable empty (SOME_TOKEN= mise bootstrap) or use
mise bootstrap --prompt-secrets, which asks for the value. Every run after
that picks fnox up automatically.
mise bootstrap secrets status lists what is declared and whether it
resolves, without printing any of it.
Templates get exec() and env, but no os variable, so the branch comes
from uname:
{%- set uname = exec(command="uname -s") | trim -%}
{% if uname == "Darwin" %}...{% else %}...{% endif %}
Binaries resolve the same way, once, at apply time:
{%- set mise_bin = exec(command="command -v mise || echo mise") | trim -%}
The absolute path is baked into the rendered file, so the prompt never pays
for a PATH lookup — which is the reason these paths were hardcoded in the
first place. The || echo fallback covers the case where the binary is not
installed yet, and a later re-apply upgrades it to the real path. It also
means the Homebrew prefix no longer has to be guessed: this already corrected
mise.nu, which was pointing at /opt/homebrew/bin/mise on a machine where
mise actually lives in ~/.local/bin.
Tapped Homebrew casks are not declared here, and the reason is worth
recording. mise resolves cask metadata from formulae.brew.sh, which only knows
homebrew-core, so a tapped cask falls back to evaluating the tap's
Casks/*.rb — which needs Ruby 3, while macOS still ships 2.6:
mise ERROR evaluating the tap definition for entire requires Ruby 3 or newer
That is not a warning. It fails mise bootstrap plan outright, so a single
tapped cask takes the whole preview down. entire and docker-desktop are
therefore installed but unmanaged. Declaring one would mean putting a Ruby 3
on PATH purely so mise can read a .rb file.
mise is not in [bootstrap.packages]. It installs itself from
mise.run and stays outside every package manager, which is the whole
point: Homebrew's build has self-update compiled out, so a brew-installed mise
answers mise self-update with "self-update is disabled for this install".
Keeping it unmanaged is what lets auto_update = true in the global config do
anything. The check is throttled by auto_update_check_duration, default 7d.
The LaunchAgent gets renamed. mise namespaces every agent it writes, so
what was io.btkostner.setXDG is now dev.mise.setXDG.
Fonts come from Homebrew (brew-cask:font-fira-code-nerd-font) rather
than unzipping a GitHub release into ~/Library/Fonts.
AstroNvim tracks its default branch. The Chezmoi external pinned the
latest release tag and refreshed weekly; [bootstrap.repos] has no
equivalent, so it follows the branch. Pin it with ref = "v5.0.0" if that
matters.
On a machine that Chezmoi already set up, the real files in $HOME are in the
way of the symlinks. mise refuses to clobber them:
mise ERROR files: refusing to overwrite existing files (use --force)
Check what the run wants to do, then let it:
mise bootstrap --dry-run
mise bootstrap --force-dotfilesDocker Desktop and other tools may create XDG directories before this migration runs. When both legacy and XDG paths exist, the migration keeps the XDG path and leaves the legacy path untouched; file conflicts and the special Go module migration still fail rather than merge data.
Worth checking before anything else, because a brew-installed mise cannot update itself:
brew list miseIf it is there, ~/.local/bin/mise is most likely a symlink pointing into the
Cellar rather than a real binary. Replace both:
rm -f ~/.local/bin/mise
brew uninstall mise
curl https://mise.run | sh
mise --versionmise bootstrap packages prune will also flag the brew install now that it is
no longer declared.
Do this first, not last. mise.toml sets min_version = "2026.9.8", which is
the release that understands auto_update — so an older mise refuses to run
anything in this repo and says so. That is deliberate: on 2026.8.8 the setting
is an unknown field, and mise warns about it on every single prompt.
Also worth doing once:
brew uninstall chezmoilaunchctl bootout gui/$(id -u) ~/Library/LaunchAgents/io.btkostner.setXDG.plistand delete that plist — the mise-manageddev.mise.setXDGreplaces it.
Read off this machine rather than invented. Apart from the Dock tile list, every value here is one that actually differs from Apple's default — the rest is left alone deliberately, so this file stays a record of decisions rather than a wall of settings.
| Section | What it covers |
|---|---|
[bootstrap.macos.dock] |
autohide and no recents; scripts/configure-dock-apps.sh manages pinned apps |
[bootstrap.macos.keyboard] |
autocapitalise on, autocorrect off |
[bootstrap.macos.trackpad] |
tap-to-click off, three-finger drag off |
[bootstrap.macos.defaults] |
dark mode, which has no curated section |
The curated sections compile down to defaults write; the raw
[bootstrap.macos.defaults] table is the escape hatch for anything they do
not cover. mise bootstrap macos defaults apply --dry-run prints the exact
commands.
The Dock tile list is managed in the order recorded by
scripts/configure-dock-apps.sh, using the
dockutil package declared in mise.toml. An app is pinned only when its
bundle exists, so systems without Slack, Linear, Notion, or another optional
application still bootstrap cleanly.
[bootstrap.user] runs chsh, and mise appends the shell to /etc/shells
first, so no separate registration is needed.
What it does need is one absolute path that is right on every machine, and
nushell has none — Homebrew installs to /opt/homebrew/bin, apt to
/usr/bin, and login_shell neither templates nor filters by OS. So
scripts/register-shell.sh keeps
/usr/local/bin/nu pointed at whichever install is real, and the config names
that. Terminals launch nu directly regardless; this is what fixes ssh
sessions and anything that shells out to $SHELL.
omp (oh-my-pi) is the coding agent everything here drives AI work through, in the terminal and in the editor both. It is installed from upstream's script rather than from a package manager, because it updates itself — the same argument that keeps mise off Homebrew:
curl -fsSL https://omp.sh/install | sh -s -- --binaryscripts/install-omp.sh runs that from
[bootstrap.hooks.pre-dotfiles], ahead of the Zed settings that name the
binary. --binary rather than the default, which builds from source through
bun when bun happens to be on PATH: nothing here installs bun, and pinning the
mode keeps the binary in one known place.
The binary lands in ~/.local/bin, which is on PATH and writable, so
omp update can replace it without sudo. /usr/local/bin/omp is symlinked at
it — the same arrangement as /usr/local/bin/nu, and for the same reason. One
absolute path that is right on every machine, and one that an app launched
from the Dock can actually find, since launchd hands it a PATH with neither
~/.local/bin nor /opt/homebrew/bin in it. The symlink is the only part
that needs sudo.
The harness settings in ~/.omp/agent/config.yml are symlinked from
dotfiles/omp/agent/config.yml, its generic
Cloudflare MCP configuration from
dotfiles/omp/agent/mcp.json, and
~/.omp/agent/APPEND_SYSTEM.md (global appended system-prompt instructions,
used unless a project puts its own APPEND_SYSTEM.md in that repo's config,
which takes precedence instead of combining) from
dotfiles/omp/agent/APPEND_SYSTEM.md.
Only those three files are deployed: the sibling credential databases, model
state, history and sessions remain local.
Zed drives omp over ACP, so the models, the
subscriptions, the tools and the rules are all omp's. The agent block in
settings.json is not dead config, but it only covers Zed's own agent and the
inline assistant:
"agent_servers": {
"omp": {
"type": "custom",
"command": "/usr/local/bin/omp",
"args": ["acp"],
"env": {}
}
}Zed has no setting that picks a default agent — the panel reopens whichever
one was used last, and falls back to its own on a fresh project. So
dotfiles/config/zed/keymap.json binds the
agent panel's new-thread key to omp instead. cmd-alt-shift-n still opens the
menu with every other agent in it.
omp keeps its own credential store at ~/.omp/agent/agent.db, and does not
read Claude Code's or Codex's. Both logins are OAuth flows answered in a
browser, which is why they are a task rather than part of the bootstrap run:
mise run omp-login # anthropic, then openai-codex
omp usage # which accounts are signed in, and what is left of them/login anthropic and /login openai-codex inside a session do the same
thing. The first omp thread in Zed offers Use existing local credentials,
which is this store — there is no second login to do there.
A watcher service checkpoints tracked files into a local git history, so an accidental edit can be diffed or rolled back. It is a LaunchAgent on macOS and a systemd user service on Linux, neither needing root:
# dotfiles/config/mise/config.toml
[bootstrap.services.mise-history]
builtin = "history-watch"mise dot status # what is tracked, and whether the watcher runs
mise dot paths # the exact file list
mise dot save -d "message" # checkpoint now
mise dot history # list checkpoints
mise dot history diff 11 12 --patch --path ~/.config/gh/config.yml
mise dot rollback ~/.config/gh/config.yml --dry-runBoth the service and the tracked entries are declared in the global mise
config rather than this repo's mise.toml, because mise rejects the latter:
[dotfiles]."~/.trackme": tracking is enrolled from the global configuration
only (ignored: project config), ignoring entry
Since ~/.config/mise/config.toml is itself a dotfile this repo deploys, the
declaration still lives in version control — it just has to arrive by that
route. One consequence: on a first run the services phase happens before the
global config exists, so [tasks.bootstrap] re-runs mise bootstrap services apply at the end.
An entry is either deployed or tracked, never both — so nothing this repo symlinks or renders appears here. That costs nothing: those files already live in this repo's git history. What gets tracked is the config around them.
| Tracked | Why |
|---|---|
gh/config.yml |
small, hand-edited, nothing else backs it up |
git/ignore |
same |
Each names a file rather than the directory around it, which is what
388f4d1 tightened: a directory entry sweeps up whatever lands beside it
later, and the sibling is usually the credential store.
Deliberately excluded, and worth keeping excluded:
| Left out | Reason |
|---|---|
github-copilot |
auth.db is an OAuth token store |
1Password, op |
credentials and session state |
raycast, raycast-x |
~450 MB of binary state, not config |
gh/hosts.yml |
the GitHub auth token — out of reach now that only config.yml is named |
**/node_modules/** |
opencode's alone was 57 MB, 3,400 files |
.omp/agent/agent.db |
the OAuth store both subscriptions log in to |
.omp/agent/{models,history}.db, sessions |
megabytes of churn, rewritten every session |
History commits are plaintext, which is why the credential paths are excluded
rather than merely untidy, and why the origin is a private repo. mise dot paths prints the resolved list — worth a look after adding anything, since it
counts files per entry and makes an over-broad glob obvious. Adding more is
one line each, or mise dot track ~/.config/whatever.
mise bootstrap applies cleanly on Ubuntu — files, directories, repos and
dotfiles all land, and the LaunchAgent is skipped with a note. CI asserts
that on every push.
Ubuntu's own repositories cover curl, gnupg, zoxide and
fonts-firacode — the last standing in for the Nerd Font, which nobody
packages, and costing nothing because the starship config uses text symbols
rather than glyphs.
Everything else needs a repository added first, which is what
scripts/linux-repos.sh does from
[bootstrap.hooks.pre-packages]:
| Package | Repository |
|---|---|
nushell |
apt.fury.io/nushell |
gh |
cli.github.com — universe has 2.45, this has 2.100 |
1password-cli |
downloads.1password.com, plus the debsig policy dpkg demands |
The script installs its own curl and gnupg first, since those are declared
in [bootstrap.packages] and so do not exist yet on the first run, and it
needs sudo. Re-running adds nothing: a repository with both its keyring and
its list file present is skipped.
Starship is the one thing not installed from a package. It reached apt in
Debian 13 but Ubuntu does not carry it, and a missing apt package is a hard
error in [bootstrap.packages] — so the same script installs it from
starship.rs, preferring apt where apt has it.
The macOS-specific dotfiles are templates now, so a Linux box gets the
1Password socket at ~/.1password/agent.sock, op-ssh-sign out of
/opt/1Password, and an env.nu with no Homebrew, Zed.app or Postgres.app in
it. CI greps for exactly that on both runners.
One asymmetry worth knowing: the XDG base directories come from the setXDG
LaunchAgent on macOS, and nothing plays that role on Linux — so env.nu fills
in the spec defaults when they are unset. On macOS the LaunchAgent still wins.
Installing nushell has a side effect worth knowing about: its postinst seeds
stub config.nu and env.nu files in ~/.config/nushell, exactly where the
dotfiles phase wants to write, and the run stops dead on them.
scripts/clear-nushell-stubs.sh clears them
from [bootstrap.hooks.pre-dotfiles]. It matches on the # Installed by:
header nushell generates, so it will never delete a file this repo put there.
One ordering note: starship installs during the packages phase, which is
before dotfiles, so starship.nu gets the real /usr/local/bin/starship
baked in on the very first run.
mise bootstrap packages use brew:ripgrep # add a package and install it
mise bootstrap packages status # what is declared vs. installed
mise bootstrap packages prune # find packages no longer declared
mise bootstrap repos statusTo add a dotfile, drop it in dotfiles/ and add a line to [dotfiles].
hk runs the checks, and every step in
hk.pkl is one of its builtins.
hk check --all # everything, what CI runs on both OSes
hk fix --all # everything, and rewrite what can be rewritten
hk check # just what changed
hk check --plan # which steps would run against which filesHooks are installed by mise run bootstrap, or on their own with hk install.
On git 2.54+ that writes hook.hk-*.command entries into .git/config and
leaves .git/hooks/ alone.
| Hook | What it does |
|---|---|
pre-commit |
Runs the fixers, stages what they changed |
pre-push |
Check-only, plus lychee on the links in this file |
commit-msg |
check_conventional_commit |
HK=0 git commit skips them.
hk recommends hk install --global instead, which turns hooks on for every
repo on the machine and no-ops wherever there is no hk.pkl. That writes to
~/.gitconfig — a file this repo tracks — so the setting would end up in
dotfiles/gitconfig and follow you to the next machine. Repo-local is the
default here only because it keeps the blast radius to this repo.
mise manages languages, not machine software. The global config carries
only fnox, node and npm; the shell, the prompt and the CLI tools all
come from Homebrew or apt through [bootstrap.packages]. The [tools] in
this repo's own mise.toml are a separate thing — hk's linters, dependencies
of this repo rather than of the machine, and not packaged for apt anyway.
editorconfig-checker is pinned to 3.4.0 and invoked as ec. The 4.x aqua
package has no working darwin asset, and the binary has never been named after
the project.
pinact calls the GitHub API to map a SHA back to its version comment,
and unauthenticated that is 60 requests an hour. It only matches files under
.github/workflows/, so it stays quiet until one of them changes — but export
GITHUB_TOKEN (export GITHUB_TOKEN=$(gh auth token)) before hk check --all
if you hit the limit. CI passes one in already.
mise fmt and taplo both format TOML, so taplo-format excludes
mise's own files and lets mise fmt own them.
Three tool configs exist only to stop a builtin from being wrong:
.yamllint (workflows have no ---, and on: is a key not a
boolean), .rumdl.toml (code fences may run long), and
.yamlfmt (keep the blank lines). The repo's own
.editorconfig exempts markdown list indentation and the
tab-indented ssh config.