Personal dotfiles for a terminal-centric dev environment on macOS.
From an out-of-the-box Apple Silicon Mac to a working environment. Run the steps in order: later steps depend on earlier ones.
Important
This repo is public. Keys, tokens, and machine-specific identity config never go in it. Everything marked (untracked) below is created by hand or copied from the old machine over a trusted channel (AirDrop, encrypted USB). SSH private keys are the exception: generate new ones.
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
eval "$(/opt/homebrew/bin/brew shellenv)" # current zsh session only; fish config handles it later.gitconfig rewrites https://github.com/ to SSH, so every GitHub clone after the symlink
step (LazyVim plugins, tmux plugins, your repos) needs a working key first.
Generate fresh keys on the new machine — don't copy old private keys over. One key per machine means a lost or retired Mac is revoked by deleting one public key, with nothing shared to rotate elsewhere.
Rules for keeping keys secure and modern:
- ed25519 only. Short, fast, the modern default. No new RSA keys;
ssh-keygen -t ed25519. - Always set a passphrase, stored in the macOS Keychain so it's typed once.
- One key per account/purpose (personal GitHub, work GitHub, work Bitbucket, home lab), so each can be revoked independently.
- Comment = who + which machine (
-C "<email> <machine>") so the key is identifiable in GitHub's key list andauthorized_keys. - Private keys never leave the machine. Not in this repo, not in cloud drives. If you want a recovery copy, a password manager's SSH-key vault is the only acceptable place.
ssh-keygen -t ed25519 -C "<email> <machine>" -f ~/.ssh/<name> # repeat per account/purpose
ssh-add --apple-use-keychain ~/.ssh/<name>
pbcopy < ~/.ssh/<name>.pub # GitHub: github.com/settings/keys
ssh -T git@github.com # accept host key, expect "Hi <user>!"~/.ssh/config contains no secrets but stays out of this public repo: copy it from the old
machine, then point each IdentityFile at the new key names. Per host:
Host <alias>
HostName <host>
IdentityFile ~/.ssh/<name>
IdentitiesOnly yes # offer only this key, not every key in the agent
AddKeysToAgent yes
UseKeychain yes
Self-hosted machines (NAS, Pi, …): add the new public key to their authorized_keys from the
old machine, which still has access. Keep it running until every host accepts the new key,
then remove its old keys from GitHub/Bitbucket and authorized_keys.
Also restore from the old machine, if used: ~/.gitconfig-* identity overrides pulled in by
includeIf in .gitconfig.
git clone https://github.com/glnds/dotfiles.git ~/dotfiles
cd ~/dotfiles
brew install mise # one-time, seeds the task runner
mise trust # this repo's .mise.toml defines the tasks
mise run bootstrapbootstrap runs sequentially:
install:brew bundle(Brewfile)link: symlinks.gitconfig,.tmux,.tmux.conf,.configinto$HOME. Fails instead of nesting when a real file/dir is in the way, so move any pre-existing~/.configaside firstmise install: every mise-managed toolalacritty-update: Alacritty from the upstream dmg (the Homebrew cask is disabled)tpm: clones the tmux plugin manager and installs the tmux pluginshk install: git hooks for this repo
Idempotent, so it's safe to re-run. Available tasks: mise tasks.
Tip
If the shell ever reports mise: Unknown command, mise itself is gone —
re-seed it with brew install mise. update can't recover this: it runs
brew upgrade (installed formulae only), not brew bundle.
sudo bash -c 'echo /opt/homebrew/bin/fish >> /etc/shells'
chsh -s /opt/homebrew/bin/fishOpen Alacritty: fish starts and tmux auto-attaches to session main. Refresh completions once with
fish_update_completions.
~/.config/fish/secrets.fish: local env secrets, gitignored, sourced if presentgh auth loginfor each GitHub account, then per-directoryGH_TOKEN(see GitHub Multi-Account)~/.claude/settings.json: Claude Code settings + tmux bell hooks (see tmux Notifications)~/.codex/config.toml: local Codex settings; restore the tracked status-bar preferences separately
Open nvim: LazyVim auto-installs plugins on first launch (needs the SSH key from step 2).
LuLu, BlockBlock, and Malwarebytes each need their system extension / Full Disk Access approved under System Settings → Privacy & Security on first launch.
Sign in to iCloud and let iCloud Drive finish syncing the vault (the sb alias points at it),
then open it in Obsidian: vault settings and community plugins live inside the vault, so they
come along. Enable the CLI under Settings → General → Command line interface so ob works.
Two layers: brew bootstraps the system, mise handles everything else.
┌─ brew ────────────────────────────────────────────────────────────┐
│ Bootstrap only: git, mise, fish, neovim, tmux + GUI casks │
└───────────────────────────┬───────────────────────────────────────┘
│ brew bundle (one time)
▼
┌─ mise ────────────────────────────────────────────────────────────┐
│ Everything else, two scopes: │
│ │
│ GLOBAL PER-REPO │
│ ~/.config/mise/conf.d/*.toml <repo>/.mise.toml │
│ ────── ────── │
│ Always available Active only when cwd is │
│ starship, atuin, rg, bat, inside that repo │
│ fd, eza, jq, uv, rumdl, ... dotfiles → hk, pkl, │
│ trufflehog │
│ attracr → python, node, uv, │
│ ruff, hk, sam, … │
└───────────────────────────────────────────────────────────────────┘
Brewfile covers what must exist before mise runs, plus GUI casks and
formulae with no good mise plugin:
- Bootstrap:
git,mise - Shell/editor:
fish,neovim,luarocks,tmux - Utilities:
trash,tree,btop(no aqua-registry darwin/arm64 build) - Casks: nerd fonts, Finch, MarkEdit, Handy, Obsidian, LuLu, BlockBlock, KnockKnock, Malwarebytes
Note
Editing the Brewfile does nothing on its own. Only brew bundle reconciles
it — run mise run install (or mise run bootstrap) to apply new entries.
update runs brew upgrade, which only upgrades already-installed formulae
and never installs what you just added.
Pruning the other direction — after moving a tool from brew to mise — run
brew bundle cleanup --force. It uninstalls formulae/casks not in the
Brewfile (plus their orphaned deps), so the brew copy stops shadowing the
mise shim. brew leaves should then equal the Brewfile.
Declared in .config/mise/conf.d/*.toml, all pinned to latest. Available
in every shell, no matter the cwd:
20-shell.toml— starship, atuin, zoxide, fzf30-cli.toml— claude, gh, jq, ripgrep, bat, fd, eza, glow, dust, yazi, gitui, delta, rumdl, uv, cship, ccusage
Each repo ships its own .mise.toml declaring tools specific to that
project. This repo's pins hk, pkl, and trufflehog — useless outside
the dotfiles repo (git hook runner, its config language, secret scanner
called from the pre-commit hook). Other repos pin what they need: a
Python project pins python + uv + ruff; an AWS project pins
aws-sam-cli + cfn-lint + cfn-guard. When you cd into the repo,
mise puts those tools on PATH; when you leave, they're gone.
$ cd ~ # no .mise.toml in scope
$ hk --version
mise ERROR No version is set for shim: hk
$ cd ~/dotfiles # .mise.toml declares hk
$ hk --version
hk 1.46.0Why this matters:
- No global pollution. A project pinned to Python 3.11 doesn't fight
another pinned to 3.13. Each has its own
.mise.toml; both Just Work. - Reproducible across machines.
.mise.tomlis in git, so a fresh clone gets the exact same versions aftermise install. - Fast onboarding. Clone,
mise install, done — no chasing down which tools the project expects.
- One declarative tool for languages and CLIs — add a tool by editing one toml line, commit, done
- Fast installs from precompiled binaries via the aqua registry
- Auto-install on first use; no
brew installround trips - Versions live in git, so machines stay in sync
Single command, runs brew + mise + uv in one go:
update # fish wrapper
mise run update # equivalentDefined in .config/mise/conf.d/99-tasks.toml, the task chains:
brew update && brew upgrade && brew cleanupmise upgrade— re-resolveslatestpins and installs newer versionsmise prune— removes the now-unused old versionsuv tool upgrade --all— upgrades Python tools installed viauv tool
- fish — shell with autosuggestions and syntax highlighting
- starship — fast, customizable cross-shell prompt
- atuin — SQLite-backed shell history with fuzzy search
- tmux — terminal multiplexer
- Alacritty — GPU-accelerated terminal
emulator (
Cmd+Shift+Mto toggle maximize)
- bat —
catwith syntax highlighting - eza — modern
lsreplacement - fd — fast
findalternative - dust — visual
dureplacement - ripgrep — fast
grepalternative - fzf — fuzzy finder
- zoxide — smarter
cd - trash — safe
rmto trash can
- git — latest Homebrew-managed version
- delta — syntax-highlighted git diffs with side-by-side view
- gitui — lightweight terminal
Git UI (aliased as
tig) - gh — GitHub CLI
- hk — per-repo git hook runner
(config in
hk.pkl, install withhk install). On Git 2.54+ hooks are config-based, not scripts in.git/hooks/— so an empty.git/hooks/is expected. Verify withgit config --get-regexp '^hook\.', notls .git/hooks/.
- Finch — open-source container tool (Docker alternative)
AWS tooling (
aws-cli,aws-sam-cli,cfn-lint,cfn-guard) lives in each AWS project's.mise.toml, not globally.
- jq — JSON processor
- btop — system monitor with CPU, memory, disk, network, and GPU stats
- yazi — fast terminal file manager
- glow — terminal Markdown renderer
- tree — directory listing
- rumdl — fast Markdown linter (CLI + LSP for nvim)
- uv — fast Python package manager
- Handy — offline speech-to-text dictation app (Whisper), types transcribed text into any app
- Obsidian — Markdown knowledge base; vault syncs via iCloud,
CLI linked by the cask as
obsidian(ob,sbaliases)
- LuLu — open-source firewall, blocks unknown outgoing connections
- BlockBlock — monitors persistence locations (launch daemons, login items)
- KnockKnock — scans for persistently installed software
- Malwarebytes — on-demand malware scanner
TruffleHog (secret scanner) lives in each hk-enabled repo's
.mise.toml, called from the pre-commit hook — see this repo'shk.pklfor an example.
Only portable status-bar preferences are tracked in
.config/codex/statusline.toml. The full ~/.codex/config.toml
stays local: it includes app-managed paths, project trust, and hook approval state.
To restore, copy status_line into the existing [tui] table in ~/.codex/config.toml, replacing
that key if present. If [tui] is absent, append the whole fragment. Keep other settings intact;
do not overwrite or symlink the full config. Restart the CLI to load the restored preferences.
This is a manual restore fragment: mise run link does not install it into ~/.codex, and Codex
does not read it automatically. See Codex configuration.
| Keys | Directory |
|---|---|
gs |
~/source |
gk |
~/Desktop |
gd |
~/Downloads |
go |
~/Documents |
tmux monitor-activity is too noisy for Claude Code (fires on every
output line). Instead, bell-based notifications trigger only when
Claude needs input.
How it works: Claude Code hooks send a terminal bell (\a) on
Stop and permission_prompt events → tmux monitor-bell
highlights the window name in the status bar (bold red).
The hooks are configured in ~/.claude/settings.json (not tracked
in this repo).
The gh CLI supports multiple accounts natively (v2.40+). Combined with
mise [env], account switching is automatic per directory.
gh auth login # private account
gh auth login # work account (stacks)Verify with gh auth status.
Put a mise.toml at the root of each account's directory tree. It lives outside this repo:
it resolves a token at runtime and never stores one.
~/source/mise.toml (private, default for everything under ~/source):
[env]
GH_TOKEN = "{{ exec(command='/bin/sh -c \"$HOME/.local/share/mise/installs/gh/latest/*/bin/gh auth token --user <private-user>\"') }}"~/source/<work>/mise.toml (overrides it for work repos):
[env]
GH_TOKEN = "{{ exec(command='/bin/sh -c \"$HOME/.local/share/mise/installs/gh/latest/*/bin/gh auth token --user <work-user>\"') }}"Then mise trust inside each folder. GH_TOKEN overrides gh auth switch, so gh uses the right
account based on working directory. Re-auth an account and the token updates on the next shell
entry.
Warning
Call the real gh binary by path, as above — never plain gh. gh is mise-managed, so gh is
a mise shim, and invoking a shim from inside a mise exec() template deadlocks.