███████╗ ██████╗ ██████╗ ████████╗███████╗
██╔════╝ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝
█████╗ ██║ ██║██║ ██║ ██║ ███████╗
██╔══╝ ██║ ██║██║ ██║ ██║ ╚════██║
███████╗██╗██████╔╝╚██████╔╝ ██║ ███████║
╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚══════╝
Note
EdHeltzel's Dotfiles
Hey there 👋, I'm EdHeltzel and you've found my dotfiles setup for working with fish shell on macOS, managed with GNU Stow. You'll also find the scripts I use to provision a new machine. Neovim (via NEO.ED), WezTerm, Herdr, and Oh-My-Pi make up my ADE (AI/Agent Development Environment). The Zed config is kept for occasional use and is not part of the regular workflow.
Warning
This is my personal setup and changes often. Don't blindly fork and run install.sh without reading it first. The script uses subcommands - see ./install.sh help.
But get inspired, take what you want, and leave the rest to make it your own.
| Screenshots | Screenshots |
|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Different Versions
- v1 uses oh-my-zsh (very old)
- v2 uses fish shell + custom scripts (old)
- v3 uses fish shell + GNU Stow
- v3.2 adds the Zsh mirror config
- v3.3 Vite+ replaces Biome in Neovim, LeaderKey replaces Karabiner
- v3.4 Claude Code OMP statusline, gh-board, lazyworktree
- v3.5 superfile,
aupharness updater, Starship as primary prompt,tuicrpager (current)
Full history lives in CHANGELOG.md.
- Prerequisites
- For future Mr EdHeltzel
- The Nitty Gritty
- Stow Packages
- Scripts
- macOS Mods
- Troubleshooting
- TODOs
- Agent docs
Install with a single line...
I have not tested this on a fresh install, so this could break your setup. Read install.sh and the justfile before running it.
In theory, this clones the repository to ~/.dotfiles, then bootstraps the machine. Again, in theory.
bash -c "$(curl -fsSL https://raw.githubusercontent.com/edheltzel/dotfiles/master/install.sh)" -- bootstrapThe remote-curl invocation detects that it is running outside a cloned repo, clones itself to ~/.dotfiles, then re-executes with the bootstrap subcommand. If you prefer, clone first and run locally:
git clone --recurse-submodules https://github.com/edheltzel/dotfiles.git ~/.dotfiles
cd ~/.dotfiles && ./install.sh bootstrapResources & Inspiration
Below are the resources I used to get to this point in my setup.
My Equipment - Keyboards & Trackballs
I collect, build, and use different ergonomic keyboards and trackballs. Generally I'm running some kind of ergonomic split keyboard with the trackball in between.
- Dygma Defy - daily driver ❤︎
- Keychron Q11
- Lily58 Pro
- Ergodox 76 Hot Dox v2
- Corne v4.1
- Cheapino
- Keychron Q10
- Daskeyboard 4 Pro
Trackballs:
Layout backups live in local/.local/share/keyboards-mouse/.
Caveats for non-Apple Silicon (Intel)
If you are on any version of macOS that uses APFS, you'll need to disable SIP. First check whether SIP is enabled:
csrutil statusThe output should read:
System Integrity Protection status: enabled.If SIP is enabled, follow these steps to disable it. Assuming you know what you're doing:
- Turn off your Mac (Apple > Shut Down).
- Hold down Command-R and press the Power button. Keep holding Command-R until the Apple logo appears.
- Choose Utilities > Terminal.
- Wait for macOS to boot into the Utilities window.
- Enter
csrutil disable. - Enter
reboot. csrutil statusshould now readSystem Integrity Protection status: disabled.
Since we have a bad habit of forgetting things - see Troubleshooting:
- Install Xcode Command Line Tools:
sudo softwareupdate -i -a && xcode-select --install. This installsgitandmakeif not already present. - Generate SSH keys and add them to GitHub:
- Generate a new ssh key
ssh-keygen -t ed25519 -C "you@host" -f ~/.ssh/id_ed25519- auth keyssh-keygen -t ed25519 -N "" -C "git signing" -f ~/.ssh/id_signing- signing key, no passphrase- Add
id_ed25519.pubto GitHub as Authentication,id_signing.pubas Signing ssh-add --apple-use-keychain ~/.ssh/id_ed25519
- Clone the repo with submodules:
git clone --recurse-submodules https://github.com/edheltzel/dotfiles.git ~/.dotfiles- Or if already cloned:
cd ~/.dotfiles && git submodule update --init --recursive
- Use the
justfilefor the rest of the setup:cd ~/.dotfiles && just install(calls./install.sh bootstrap)- Or invoke the script directly:
./install.sh bootstrap - For stow-only (no software install):
./install.sh linkorjust link - The install script initializes git submodules for you
- After setup completes, run
uppto execute topgrade and update everything:uppis an abbreviation fortopgrade --yes- Update Packages (this is what I say to myself)config/.config/topgrade.tomlincludes[post_commands]for a Brew cleanup passaupupdates the AI agent harnesses listed infish/.config/fish/agent-harnesses.txt
- Optional DX and nice to haves:
- Disable Gatekeeper when installing apps:
sudo spctl --master-disable(seemacos/03-security.sh) - Node auto-switching is lazy-loaded;
fnm env --use-on-cdruns on the firstnode/npmcall
- Disable Gatekeeper when installing apps:
- Wallpapers live in
~/.wallpapers/, which has its own repo. Raycast uses that directory to set wallpapers.
Originally I used a series of custom scripts to create symlinks, and it worked, but I've since switched to GNU Stow. Way easier to manage.
With Stow I added a justfile that I treat like NPM scripts. You need to be in the root of ~/.dotfiles to run any just recipe. just --list is the source of truth; at the time of writing:
default Show available recipes (default)
install Bootstrap a new machine (full provision) [alias: bootstrap]
link Symlink all dotfiles with Stow (idempotent) [alias: run]
list List available stow packages
stow Add individual package with Stow (e.g., just stow fish) [alias: add]
unstow Remove individual package with Stow (e.g., just unstow fish) [alias: remove]
update Restow all dotfiles packages [alias: up]
delete Remove all dotfile symlinks
hooks Install tracked .githooks into .git/hooksBootstrapping
just install calls ./install.sh bootstrap. The unified installer has two primary subcommands:
./install.sh bootstrap- provision a new machine: Xcode CLT, Homebrew + Brewfile, language package managers (FNM, rbenv, rustup, Bun), Stow, duti, macOS prefs, git config, optional fish as default shell../install.sh link- symlink dotfiles only (idempotent). Use this when the machine is already set up.
Flags for bootstrap: -y/--yes (skip prompts), --no-packages (skip Homebrew bundle and language package managers), --no-macos (skip system preferences). Run ./install.sh help for full usage.
Stowing/Unstowing (add/remove)
Two options for managing packages with GNU Stow:
- Use Stow directly from the repo root:
stow fish,stow --restow fish, orstow -D fish(unstow) - Use the justfile:
just stow fishorjust unstow fish
just update restows everything. It also clears stale brew.fish/fish-ssh-agent.fish symlinks and zsh completion dumps first.
Git hooks
just hooks installs wrappers in .git/hooks that call the tracked .githooks/pre-commit (blocks direct commits to master, runs shellcheck on staged shell) and .githooks/commit-msg (Conventional Commits). It deliberately does not set core.hooksPath, because GitButler writes its own wrappers there.
The package list is stow_packages in the justfile: dots git fish zsh config neovim local.
-
dots (
dots/) - misc dotfiles that live directly in$HOME:.npmrc,.tmux.conf,.biome.json,.tigrc,.gitnow,.profile, etc. Also owns.stow-global-ignore, the shared ignore list used by every package without its own. -
git (
git/) -.gitconfig(aliases, delta pager, SSH signing),.gitignore_global, andgit.sh, which symlinks the machine-specific~/.gitconfig.localkeyed onComputerNameand registers the signing key on GitHub. List aliases withgit config --get-regexp '^alias\.'. -
fish (
fish/) - primary shell, XDG-compliant so$HOMEstays clean (XDG vars are set inconf.d/paths.fish).- Prompt:
FISH_PROMPTinconfig.fishselects the prompt engine. Starship is the default; the config isconfig/.config/starship.tomland shows the GitButler stack via a vendoredstarship-gitbutlermodule. - Secrets: API keys live in
conf.d/secrets.fish(gitignored). Create it from the template:cp fish/.config/fish/conf.d/secrets.fish.example fish/.config/fish/conf.d/secrets.fish. Used by theclaude-modelsfunction among others. - Lazy-loading: heavy tools initialize on first use.
config.fishdefines wrapper functions fornode/npm/npx(FNM) andruby/gem/bundle/rake/irb(rbenv) that init the tool once, erase themselves, and delegate.conf.d/zoxide.fishwrapsz/ziwith a persistent wrapper (zoxide is initialized with--no-cmdso it does not overwrite the wrapper) that runs__list_dirafter each jump.npxdelegates tobunx. - Shared directory listing:
functions/__list_dir.fishholds the single set ofezaflags used after every directory change.cd,z, andziall call it. - Abbreviations, not aliases:
conf.d/abbr.fish. Runabbrto list them. Multi-step commands live infunctions/. - Plugins: managed by Fisher, listed in
fish_plugins. - SSH agent:
conf.d/fish-ssh-agent.fishshares one agent across every shell (see Troubleshooting). - Agent harnesses:
functions/aup.fishupdates the AI CLIs listed inagent-harnesses.txt.
- Prompt:
-
zsh (
zsh/) - near-identical mirror of the Fish config for Zsh compatibility. XDG-compliant (ZDOTDIR=~/.config/zsh).- Plugin manager: Antidote, plugins in
.zsh_plugins.txt:zsh-autosuggestions,fast-syntax-highlighting,zsh-abbr,zsh-history-substring-search,zsh-autopair,zsh-completions. - Modular:
.zshrc.d/numbered files load in order (01-pathsthrough12-lazy-zoxide), mirroring Fish'sconf.d/. - Lazy-loading: FNM, rbenv, and zoxide load on first use.
- Secrets:
secrets.zsh(gitignored), copy fromsecrets.zsh.example. - Prompt:
ZSH_PROMPT(set insecrets.zsh), Starship by default. - Functions: 30+ autoloaded functions in
functions/, the same set as Fish (theme,reload,flashEthernet, ...). Custom completions incompletions/.
- Plugin manager: Antidote, plugins in
-
config (
config/) -~/.configfor 20+ applications, so they don't clutter the repo root. Currently: bat, borders, btop, fastfetch, gh, gh-changelog, gh-dash, ghostty, herdr, jj, karabiner, kitty, lazygit, leaderkey, markdownlint-cli2, raycast, starship, superfile, theme-switcher, topgrade, wezterm, zed.- Terminals: WezTerm (primary, modular Lua, see its README), Kitty, Ghostty.
- Theme switcher: run
themefor an fzf picker with preview,theme <name>to switch directly,theme --list/--current. Twelve themes (Eldritch, Tokyo Night, Rosé Pine x3, Vesper, Catppuccin x4, Dracula, Gruvbox) applied across Ghostty, Kitty, WezTerm, Neovim, bat, btop, lazygit, oh-my-posh, Claude Code, Yazi, herdr, and gh-dash. Details in theme-switcher/README.md. - Keyboard:
leaderkey(current) andkarabiner(legacy TypeScript config, see its README). - Editors:
zed(Vim mode), kept for occasional use.
-
neovim (
neovim/) - git submodule for NEO.ED, my LazyVim-based config and primary editor. Stow symlinks~/.config/nvimtoneovim/.config/nvim/. See its README for the full story; highlights: Vite+ formatting and linting (Oxfmt/Oxlint), Eldritch colorscheme, AI integration (Claude Code, OpenCode, Pi), multi-language support. -
local (
local/) -~/.local:bin/scripts (chshell,update-wezterm-nightly), cspell dictionaries, keyboard/mouse layout backups, and GitHub CLI extensions (gh-board,gh-changelog,gh-dash,gh-enhance,gh-markdown-preview,gh-stack). Repo screenshots in__repoImages/are excluded from stow.
These are run by install.sh bootstrap but can be run on their own. They source ../scripts/functions.sh by relative path, so run each from its own directory: cd ~/.dotfiles/duti && ./duti.sh.
-
macOS (
macos/) -macos.shruns01-preferences.sh,02-apps.sh, and03-security.sh. Do not blindly run this; it is a WIP and every macOS update changes something. -
packages (
packages/) -packages.shinstalls (and uninstalls) the Brewfile plus each package manager's manifest.- Usage:
./packages.sh [action] [target], actionsinstall(default) /uninstall, targetsbrew,node,bun,pnpm,ruby,rust,all. - Manifests:
Brewfile,node_packages.txt,bun_packages.txt,ruby_packages.txt,rust_packages.txt. Bun owns global JavaScript CLIs; there is currently nopnpm_packages.txt, so skip thepnpmtarget.
./packages.sh # install everything (default) ./packages.sh bun # install just Bun globals ./packages.sh uninstall bun # remove every pkg in bun_packages.txt ./packages.sh uninstall all # remove everything (reverse order; brew last) ./packages.sh --help # full reference
- Usage:
-
duti (
duti/) -duti.shsets default applications for file types. One file per app bundle id (dev.zed.Zed,com.apple.Preview, ...) listing the extensions it owns. -
git (
git/) -git.shsymlinks the per-machine~/.gitconfig.localand registers the SSH signing key on GitHub (needsghwith theadmin:ssh_signing_keyscope). -
scripts (
scripts/) -functions.shprovides the sharedinfo/success/warning/errorhelpers.nvim.shis a legacy LazyVim starter helper thatinstall.shno longer calls. -
private (
private/) - empty placeholder;private/ssh/is gitignored.
Window management: native Stage Manager + Raycast + AltTab. JankyBorders config lives in config/.config/borders/.
Menu bar: Ice only changes the appearance of the native menu bar.
Keyboard: most of my keyboard hacking happens in firmware (QMK via VIA, Bazecor on the Dygma Defy) plus Raycast for non-chorded shortcuts and LeaderKey for chorded ones.
My Hyper key is right_cmd + right_shift + right_option + right_control (right-side modifiers only). On the Defy that is a Bazecor layer. Examples:
- non-chorded:
hyper + tlaunches WezTerm (Raycast) - chorded:
hyper + r + dopens the dotfiles in my editor (LeaderKey)
Karabiner Elements is still in the Brewfile for the odd complex modification, but LeaderKey replaced it for daily use. The legacy TypeScript config is documented in config/.config/karabiner/README.md.
Fish: Fisher Plugin Manager
If Fisher does something weird or introduces a breaking change, reinstall it:
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source && fisher install jorgebucaran/fisherNode Development: FNM
Node version switching uses fnm, which honors both .nvmrc and .node-version files.
If not already installed from the Brewfile:
brew install fnmFNM is lazy-loaded: the first node/npm/npx call runs fnm env --use-on-cd | source. If completions are missing, fnm completions --shell fish. After changing fish config, just update (or just stow fish) and reload.
Global Node packages are in packages/node_packages.txt; JavaScript CLIs belong in packages/bun_packages.txt because Bun owns globals here.
Git: Commit and Tag Signing
SSH Signing
I use SSH commit signing over GPG. Resources that helped:
.gitconfig includes ~/.gitconfig.local last so machine values win:
[meta]
isLocalConfig = true
[user]
signingkey = PATH_TO_YOUR_KEY
[gpg "ssh"]
allowedSignersFile = PATH_TO_YOUR_ALLOWED_SIGNERS_FILEgit/git.sh provisions that file by symlinking git/gitconfig-<machine>.local based on ComputerName, and registers the public key on GitHub as a signing key if it is missing.
[!IMPORTANT] Point
signingkeyat a dedicated, passphrase-less key (e.g.~/.ssh/id_signing.pub), not your auth key.ssh-keygen -Y signreads the private key directly and has no macOS keychain hooks, so a passphrased signing key means typing it on every commit. Keep your passphrasedid_ed25519for auth, generate a separateid_signingfor signing, and add both to GitHub in their respective slots. Add the signing public key to~/.ssh/allowed_signerssogit log --show-signatureverifies locally.
Rust and Cargo
From time to time cargo fails to update through topgrade, usually because something changed in the Rust toolchain that breaks cargo install cargo-update.
The fix: uninstall and reinstall rust and rustup-init with brew, then reinstall cargo-update.
brew uninstall rustup-init;
and brew reinstall rust;
and cargo install cargo-update --force;
and topgrade --only cargoSSH Agent
fish/.config/fish/conf.d/fish-ssh-agent.fish handles ssh-agent. It is a small custom script, not the upstream plugin. On every interactive shell it:
- Sources
~/.ssh/agent/env.fishto inherit any agent a previous shell already started. - Pings the agent with
ssh-add -l. If unreachable, spawns a fresh one withssh-agent -c(csh syntax, since fish can't parse the Bourne output) and persists the env back toenv.fish. - Loads
id_ed25519if its fingerprint isn't already in the agent, prompting for the keychain-cached passphrase if needed.
Result: every fish shell (herdr panes, tmux panes, fresh WezTerm/Ghostty windows) shares one ssh-agent. It survives terminal restarts because the agent is a detached process and env.fish points new shells at it.
If something goes sideways (agent dies, stale sockets pile up):
pkill ssh-agent
rm ~/.ssh/agent/*
# open a fresh shell - the script spawns a clean agent[!NOTE] Commit signing doesn't use ssh-agent at all. If commits prompt for a passphrase every time, that's a signing-key problem, not an agent problem.
Git Submodules
This repo has one submodule, neovim (NEO.ED) at neovim/.config/nvim.
Initialize/update:
cd ~/.dotfiles
git submodule update --init --recursiveBump to latest upstream:
cd ~/.dotfiles/neovim/.config/nvim
git pull origin master
cd ~/.dotfiles
git add neovim
git commit -m "chore(neovim): bump neoed submodule"If the submodule is empty:
git submodule deinit -f neovim/.config/nvim
git submodule update --init --recursiveThe default key repeat rates set in macos/01-preferences.sh came from this site.
WindowServer RAM Leak
As of 2024-07 there is a known macOS issue where WindowServer consumes CPU and/or memory, in my experience when more than one external monitor is attached. The workaround is to kill WindowServer, which logs you out. On log-in WindowServer restarts and RAM usage returns to normal.
Usage: run killws in a terminal, then log back in.
Media Control Keys
Every so often the media keys stop working because Chrome, WhatsApp, or similar hijacked them. Re-enable them with:
launchctl load -w /System/Library/LaunchAgents/com.apple.rcd.plistEthernet backhaul
Run the flashEthernet function to flush the Ethernet backhaul:
flashEthernet; and echo 'Ethernet backhaul flushed'
speedtestOpen work is tracked in GitHub issues.
AGENTS.md (and the CLAUDE.md symlink) are the working contracts for AI coding agents in this repo. The root file covers repo-wide invariants and each stow package or script directory has its own. They are tracked here but excluded from stow, so they never land in ~.









