My personal dotfiles, for macOS and Arch/Manjaro. Take anything you want, but at your own risk.
You should first review the code and remove things you don't want or need. Don't blindly use my settings unless you know what that entails.
git clone https://github.com/kdbaustert/dotfiles.git ~/dotfiles
cd ~/dotfiles
./install.sh # macOS (Apple silicon)
./install-linux.sh # Arch / ManjaroBoth are idempotent — safe to re-run, and re-running is how you update. Existing
real files are backed up (.backup-<timestamp>) before being replaced by
symlinks; existing symlinks are simply re-pointed. Both ask for your sudo
password up front, and each refuses to run on the other's platform.
install.sh is interactive. Run bare, it asks a yes/no for every section
(drawn with gum, fetched pinned into
~/.local/bin on first run), all up front, before anything runs — Enter means
yes. Sections always run in their usual order, and sudo is only requested when
one you kept needs it.
./install.sh # a yes/no for every section
./install.sh brew # just the Homebrew + Brewfile step, no questions
./install.sh symlinks zsh # relink, then redo the zsh setup and theme caches
./install.sh --skip nix,hosts # the questions, without these two
./install.sh -y # everything, no questions
./install.sh --help # list the section namesWithout a terminal (piped, over ssh without -t) it behaves as -y.
They are siblings rather than forks: everything that is the same on both — the
symlinks, zinit, the theme caches, LS_COLORS, the setup-script runner — lives in
setup/lib.sh and is called from each. What is left in the two scripts is only
what Homebrew, PAM and CoreText do differently from pacman, systemd and
fontconfig.
install.sh expects Apple silicon (the pay-respects install in the zsh
step is pinned to arm64 and skips itself elsewhere).
install-linux.sh expects pacman, and its
pay-respects step is disabled until you fill in the checksum — the Linux
release asset could not be hashed from the Mac this was ported on, so the step
warns and skips rather than installing something unverified. See the comment
above that block.
Two things on Linux are worth knowing before the first run:
- The package lists in
arch/were transcribed, not verified. The installer checks every name against the sync database and reports the ones pacman does not recognise instead of aborting, so the first run tells you what to fix. .config/ghostty/configis not deployed here at all. Ghostty is macOS-only in this setup, so it is the one.configentry the symlink step skips on Linux; a re-run also removes a link an earlier one left..config/rio/config.tomlis deployed and is still macOS-shaped — Rio needsnavigation.modeoffNativeTabandrenderer.backendoffMetalby hand.
- Installs the Xcode command line tools and Homebrew, then everything in
homebrew/Brewfile - Installs Nix (multi-user, daemon mode) beside Homebrew — for per-project
shells, not for tools the Brewfile already has; the shell hook is sourced
from
.zprofile, not/etc/zshrc - Symlinks the root dotfiles and every
.config/*entry into$HOME - Installs
pay-respects(pinned + checksummed) into~/.local/bin - Clones the trapd00r
LS_COLORSdatabase as an alternative to the default vivid/Voltage one — seethemes/voltage.md - Bootstraps zinit and pre-installs/compiles the zsh plugins
- Builds the Voltage theme caches for
batand fast-syntax-highlighting - Splices an ad, tracker and porn blocklist into
/etc/hosts(setup/hosts.sh) and installs a root LaunchDaemon that refreshes it every Monday - Enables Touch ID for
sudovia/etc/pam.d/sudo_local - Points iTerm2 at
iterm/for its preferences
The scripts in setup/ are opt-in, because they install a lot of global
packages. Select them by name:
SETUP_SCRIPTS="pnpm composer" ./install.sh
SETUP_SCRIPTS=all ./install.sh # pnpm composer mas gh-extensions| Script | Installs |
|---|---|
pnpm.sh |
Global JS packages (pnpm, not npm — see the file) |
composer.sh |
Global Composer packages |
mas.sh |
Mac App Store apps (sign in to the App Store first) |
gh-extensions.sh |
GitHub CLI extensions |
The macOS system defaults in setup/macos.sh are not one of these — they are
the macos section of install.sh (./install.sh macos), last in the run.
Review that script before saying yes to it.
| Path | Contents |
|---|---|
.config/ |
Everything symlinked to ~/.config |
zsh/ |
Aliases, functions, zinit config, extra/ snippets |
homebrew/ |
Brewfile |
setup/ |
The optional scripts above |
themes/ |
Voltage — the shared palette; see themes/voltage.md |
iterm/ |
iTerm2 preferences folder |
obsidian/ |
"Amethyst Night" theme |
There are two Neovim configs, and they are not variants of each other.
.config/nvim is hand-rolled: lazy.nvim, 23 plugins, no vendored runtime, and
a ~50ms start (~80ms opening a file, once treesitter and the LSP attach).
.config/lvim is LunarVim, an IDE layer with its own runtime directory. The
split is the point — nvim is the editor, lvim is there when the
batteries-included version is wanted, and neither can break the other.
Roughly 35ms of that start is nvim-tree, which is open on every launch and so
cannot be lazy-loaded. That is the price of the sidebar being always-on, and it
is the single biggest item in the startup profile — nvim --startuptime if it
ever needs revisiting.
They share exactly one thing: .config/voltage.nvim, the Voltage colorscheme
as a standalone plugin-shaped directory that both put on their runtimepath. It
lives outside both because two transcriptions of the same palette is the drift
themes/voltage.md exists to prevent.
.config/nvim splits into lua/configs (settings needing no plugin) and
lua/plugins (one file per concern, each returning a lazy spec). Language
servers come from Mason; formatters and linters deliberately do not — they are
the ones the Brewfile and setup/pnpm.sh already install, so the shell and the
editor run the same binaries. lazy-lock.json is tracked, so :Lazy restore
reproduces the plugin set exactly.
LunarVim's runtime is not installed by install.sh, only its config.
.config/lvim is symlinked like every other .config entry, but the editor
itself lives in ~/.local/share/lunarvim and comes from upstream's installer,
which is not vendored here:
LV_BRANCH=master bash <(curl -s https://raw.githubusercontent.com/LunarVim/LunarVim/master/utils/installer/install.sh)Run that once on a new box, then lvim.
It is lvim, not nvim. LunarVim isolates itself: its shim sets
NVIM_APPNAME=lvim and starts Neovim against its own init.lua, so nothing in
.config/lvim is on the path of a bare nvim, and nothing in .config/nvim
is on the path of lvim. They are two separate editors that happen to share a
binary — see the next note. $EDITOR stays nvim.
The config LunarVim writes on first run is replaced by the symlink, and the
plugin set is pinned by the tracked .config/lvim/lazy-lock.json — :Lazy restore reproduces it exactly. The formatters and linters it wires up are the
ones already installed by the Brewfile and setup/pnpm.sh, not by Mason.
Upstream targets Neovim 0.10 while the Brewfile tracks current neovim, so
startup logs one harmless vim.tbl_flatten is deprecated warning from
LunarVim's own code.
obsidian/ is not deployed by install.sh. Obsidian themes live at
<vault>/.obsidian/themes/ and the vault path is per-machine; copy them in by
hand.
Part of .claude/ is deployed. CLAUDE.md — Claude Code's global
instructions, applied to every project — plus hooks/notify.sh,
statusline.sh and every directory under skills/ are symlinked into
~/.claude/. The rest of ~/.claude is state Claude writes itself
(settings.json, settings.local.json, projects/, todos/), so the
directory is created and linked into rather than linked over.
.claude/themes/ is kept here for reference only; nothing links it.
Two settings blocks are manual. Both deployed scripts are named by
~/.claude/settings.json, which is untracked, so on a new machine add this
under hooks:
"Notification": [{ "hooks": [{ "type": "command",
"command": "$HOME/.claude/hooks/notify.sh" }] }]and this at the top level:
"statusLine": { "type": "command",
"command": "$HOME/.claude/statusline.sh", "padding": 0 }hooks/notify.sh fires a terminal-notifier banner when Claude is waiting on
you. Only the Notification event is hooked — permission prompts and idle
questions. Hooking Stop as well would banner every single turn.
statusline.sh keeps the plan's usage on screen as four bars, one per window,
each coloured green → yellow → orange → red as it fills:
Current session ███████░░░░░░░ 52% 2h 14m left
Current week ███████████░░░ 81% 4d 9h left
Fable week █████░░░░░░░░░ 38% 4d 9h left
Context window ████░░░░░░░░░░ 31% 312K of 1M · Opus 5
Current session and Current week are Claude Code's own names for the two
rate-limit windows, so the status line and /usage never disagree about which
bar is which. /usage and /context answer the same questions on demand; the
point of the status line is that you see the 5-hour number before it becomes
the reason you went looking — hence a countdown rather than a reset time.
Windows Claude Code does not report (API-key auth has no rate limits) drop
their row rather than showing an empty bar. The Fable weekly window is one
Claude Code tracks and draws in /usage yet never hands the script — 2.1.266
still drops it — so the script fetches it itself: a background refresher pulls
the same usage endpoint /usage calls, at most every five minutes, into
~/.cache/claude-usage.json, and the render reads that file alongside stdin.
The cache also fills in the session and week rows when the session has not seen
them yet. The script's header has the detail.
That refresher only runs while something is rendering, so a scheduled job keeps
the cache warm when nothing is — a LaunchAgent on macOS
(launchd/dev.kennyb.claude-usage-refresh.plist), a user timer on Arch
(systemd/claude-usage-refresh.timer), both calling statusline.sh --refresh
every five minutes. The installers deploy and enable them. Without it the cache
ages past its one-hour cutoff overnight and the first render of the morning has
nothing to draw those rows from.
There is no AGENTS.md at either scope any more. .claude/ used to carry
one alongside CLAUDE.md — the vendor-neutral name other agent tools read
directly, holding the "which binary to reach for" rules (rg, fd, jq, the
g-prefixed GNU tools). Claude Code has no AGENTS.md discovery path, though —
verified against 2.1.231, neither ~/.claude/AGENTS.md nor <project>/AGENTS.md
is picked up, only CLAUDE.md is — so an @AGENTS.md import line was the only
thing loading it, and deleting that line lost the rules quietly rather than
erroring. Those rules are now inlined in .claude/CLAUDE.md. install.sh
removes the retired ~/.claude/AGENTS.md symlink on its next run.
The root of this repo carried the same pair and was merged first, for the same
reasons: the rules for editing this repo are inlined directly in the top-level
CLAUDE.md, since Claude Code is the only agent that edits it and one file can't
drift from its copy.
iris autostarts, and IRIS_AUTOSTART=0 opts one shell out. It wraps the
shell rather than plugging into zle, so it arbitrates keys against atuin,
fzf-tab and zsh-autosuggestions before they see them — the iris block in
.zshrc records each collision and where it is settled. It ran on demand only
for a month because it swallowed the empty-prompt Up arrow; iris 0.7.0's
core.navigate-closed = "shell" gives that key back to atuin, which is what
made always-on reasonable again. IRIS_AUTOSTART=0 zsh gets you a plain shell
without editing anything.
Don't run iris setup or iris uninstall. Both rewrite your shell RC file
in place, and ~/.zshrc is a symlink into this repo — they would edit the
tracked .zshrc rather than a copy. To remove iris, delete that block,
brew uninstall versenilvis/iris/iris, and drop .config/iris.
No System Integrity Protection changes are needed. Earlier versions of this README opened with instructions to disable SIP; that was only ever required by yabai's scripting addition. yabai and skhd have been removed, so nothing here touches SIP any more — leave it on.
Thanks to the dotfiles community.