Personal dotfiles for macOS (Apple Silicon). Jump to Keybindings for every shortcut this config defines. See CHANGELOG.md for notable changes and CLAUDE.md for architecture and tooling rationale.
git clone git@github.com:itsmeduncan/dotfiles.git
cd dotfiles
./install.sh--dry-run— preview every mutating action as[DRY] would: ...without executing--check— audit mode: report drift (symlinks, Brewfile, mise, macOS defaults), change nothing--skip=a,b/--only=a,b— skip or isolate phases:preflight brew symlinks mise agents runtimes android macos ssh--snapshot— writebootstrap-state.snapshotrecording current versions of every brew/mise tool, submodule, and plugin
The installer is idempotent and skips already-satisfied work (Brewfile check, nvim lazy-lock hash, tmux plugin dirs).
install.sh is a thin orchestrator over phase modules in lib/:
- brew — install Homebrew if missing, then sync the
Brewfile: a slim set of things mise can't manage (tmux, postgresql@18, watchman, zsh plugins, mosh/nmap/wget, tldr, yazi, git-absorb, cocoapods) plus GUI casks (Ghostty, OrbStack, 1Password + CLI, Slack, Notion, Tailscale, LM Studio, gcloud-cli, MesloLGS Nerd Font) - symlinks — all dotfiles to
$HOME; config dirs (nvim,yazi,uv,bat,ghostty,opencode) to~/.config/;pnpmto~/Library/Preferences/;bin/utilities to~/.dotfiles/bin/ - mise — symlink
mise.tomlto~/.config/mise/config.toml, then install everything declared there via mise: all language runtimes (Node, Python, Ruby, Go, Java, Bun) plus most CLI tools (neovim, eza, bat, ripgrep, fd, zoxide, fzf, delta, direnv, gh, lazygit, pre-commit, uv, pnpm, just, jq, yq, hyperfine, dust, bottom, tokei, awscli, terraform, opencode, swiftlint, atuin, pi-coding-agent) - agents — shared agent skills from
agents/skills/to~/.claude/skills,~/.agents/skills, and~/.pi/agent/skills; Claude Code config to~/.claude/ - runtimes — oh-my-zsh, Rust via rustup, tpm + tmux plugins, Neovim plugins via lazy.nvim
- android — Android SDK via Homebrew commandlinetools (only when missing)
- macos — productivity defaults (key repeat, Finder, Dock; version-aware, diff-before-write), application firewall
- ssh — generate an ed25519 key if missing (passphrase-protected by default)
mise.tomlpins every runtime and CLI tool version.tool-versions(asdf format, runtimes only) is generated from it viabin/sync-tool-versions— CI fails if they drift./install.sh --snapshotwritesbootstrap-state.snapshot, a human-diffable record of the last-known-good system state
The tracked gitconfig wires up SSH signing via 1Password's op-ssh-sign but leaves it off — enable per machine in ~/.gitconfig.local (loaded via includeIf, safe if missing):
[user]
signingKey = ssh-ed25519 AAAA... your-key
[commit]
gpgsign = true
[tag]
gpgsign = true.github/workflows/ci.yml:
- Tier-1 (Ubuntu, every push/PR):
bash -n, shellcheck, shfmt, JSON/YAML validation, prettier, gitleaks secret scan,.tool-versionssync check - Tier-2 (macOS, any pull request or manual
workflow_dispatch):./install.sh --dry-run, then a real end-to-end install on a clean runner, then./install.sh --check
Pre-commit hooks (.pre-commit-config.yaml) run the same linters locally: pre-commit install once, then they fire on every commit.
Global cross-agent skills live in agents/skills/.
Claude consumes them through claude/skills -> ../agents/skills.
Codex discovers them through ~/.agents/skills. OpenCode is configured
with skills.paths in config/opencode/opencode.json. Pi gets the
same tree at ~/.pi/agent/skills for agents that know to inspect a
local skills directory.
Every key combo, alias, and shortcut this config defines, and which file defines it. Bindings below were verified against a live tmux server and the current config files.
The eight you'll reach for most:
| Keys | Does |
|---|---|
C-b p |
Start / pause the Pomodoro timer (countdown lives in the tmux status bar) |
C-b g |
lazygit, full-screen popup, at the current pane's path |
C-b C |
Split off a 30% Claude Code pane on the right |
C-h C-j C-k C-l |
Move between panes and nvim splits — no prefix, one muscle memory |
,f (nvim) |
Live grep the project |
C-p (nvim) |
Fuzzy-find files |
C-r (shell) |
atuin history search |
y (shell) |
yazi file manager; exits into whatever directory you browsed to |
Defined in tmux.conf. Prefix is the default C-b — it is not remapped.
Notation: C-b | means press C-b, release, then |. Bindings marked no prefix
are pressed directly. -r bindings repeat: hold the prefix once, then tap the key.
| Keys | Action |
|---|---|
C-b | |
Split vertically (new pane on the right), in the current path |
C-b - |
Split horizontally (new pane below), in the current path |
C-b c |
New window, in the current path |
C-b C |
New 30% right-hand pane — the Claude Code split |
C-b S |
Break the current pane out into its own window |
C-b @ |
Join pane 2 into window 1 as a horizontal split |
C-b r |
Reload ~/.tmux.conf |
C-h / C-j / C-k / C-l |
No prefix. Move pane left/down/up/right — passes through to Neovim when a vim-like process owns the pane (vim-tmux-navigator) |
M-← M-→ M-↑ M-↓ |
No prefix. Move pane by arrow |
M-1 … M-5 |
No prefix. Jump to window 1–5 |
C-b H/J/K/L |
Resize pane left / down / up / right by 5 cells (repeatable) |
| and - replace the default % and ", which are unbound. New splits and
windows inherit the current pane's working directory rather than starting at $HOME.
Each opens centered over the current pane and closes on exit.
| Keys | Popup | Size |
|---|---|---|
C-b g |
lazygit, at the current pane's path | 90% × 90% |
C-b f |
fzf session switcher — pick a session, switch to it | 40% × 40% |
C-b m |
glow markdown browser, scoped to the current pane's path |
80% × 80% |
C-b f and C-b m override tmux's defaults (find-window and mark-pane).
From tmux-pomodoro-plus. Desktop
notifications are off by design — the countdown is always visible in the status
bar instead, rendered by bin/pomodoro-status, which shows a
dim 🍅 -- placeholder when no timer is running.
| Keys | Action |
|---|---|
C-b p |
Toggle — start a Pomodoro, or pause/resume a running one |
C-b P |
Cancel the current Pomodoro or break |
C-b _ |
Skip to the next interval |
C-b e |
Restart the current Pomodoro |
C-b C-p |
Open the Pomodoro menu |
C-b M-p |
Set a custom timer duration |
Plugin defaults are unchanged: 25 min work, 5 min break, 4 intervals, then a 25 min long break.
C-b poverrides tmux's defaultprevious-window. UseC-b M-1…M-5orC-b n/C-b lto move between windows instead.
vi keys (setw -g mode-keys vi). Enter with C-b [, or just scroll — the mouse is on.
| Keys | Action |
|---|---|
v |
Begin selection |
y |
Copy selection to the macOS clipboard (pbcopy) and exit copy mode |
C-h/j/k/l |
Change pane without leaving copy mode |
q |
Exit copy mode |
Top-positioned, refreshed every 30s: session name → window tabs → Pomodoro countdown →
weather → battery → date/time. The session block turns amber while the prefix is
armed, so you can see whether C-b registered. Weather and battery come from
bin/weather and bin/battery; battery goes red when low.
The window name tracks repo:branch automatically via a zsh precmd hook.
Defined in config/nvim/init.lua. Leader is ,
(both mapleader and maplocalleader).
Press , and pause — which-key pops up
the available continuations, grouped as g git, h hunks, x diagnostics,
c code, t toggle.
| Keys | Action |
|---|---|
C-p |
Fuzzy-find files (fzf-lua) |
,f |
Live grep the project |
,b |
Switch buffers |
,e or - |
File explorer at the current file's directory (oil.nvim — edit the buffer like text to rename/create/delete) |
[b / ]b |
Previous / next buffer |
,d |
Delete the buffer, keeping the window layout (Snacks) |
,a |
Add the current file to Harpoon |
,1 … ,4 |
Jump to Harpoon file 1–4 |
| Keys | Action |
|---|---|
s |
Flash jump — type a couple of characters, then the label |
S |
Flash Treesitter — select expanding syntax nodes |
jk |
Escape from insert mode |
; |
: — command mode without the shift |
C-n |
Clear search highlight |
gc / gcc |
Toggle comment (operator / current line, Comment.nvim) |
ys / cs / ds |
Add / change / delete surround (nvim-surround) |
x c p (visual) |
Delete / change / paste without clobbering the yank register |
Bound on LspAttach, so they exist only in buffers with a language server.
| Keys | Action |
|---|---|
gd |
Go to definition |
gr |
References |
K |
Hover docs |
,ca |
Code action |
,rn |
Rename symbol |
[d / ]d |
Previous / next diagnostic |
,xx |
Workspace diagnostics (Trouble) |
,xd |
Document diagnostics (Trouble) |
Servers auto-install via Mason: pyright, ts_ls, kotlin_language_server, lua_ls, and sourcekit (Swift, via Xcode). Format-on-save runs through conform.nvim (ruff / prettier / stylua / swiftformat / ktlint); linting is async via nvim-lint.
| Keys | Action |
|---|---|
,gs |
Git status (fugitive) |
,gd |
Git diff split |
,gp |
Git push |
,gf |
Close the fugitive diff split and unfold |
]c / [c |
Next / previous hunk (gitsigns) |
,hs |
Stage hunk |
,hu |
Undo stage hunk |
,hp |
Preview hunk |
,tb |
Toggle inline blame for the current line |
| Keys | Action |
|---|---|
,mr |
Toggle inline markdown rendering (render-markdown.nvim) — markdown files only |
,mp |
Toggle live browser preview with synced scroll — markdown files only |
,ww |
Quick-switch between notes in ~/notes/ |
,wt |
Open today's daily note (~/notes/daily/YYYY-MM-DD.md) |
,wn |
New note |
,wf |
Grep the vault |
,wb |
Backlinks to the current note |
,wo |
Open the current note in the Obsidian app |
| Keys | Action |
|---|---|
,tt |
Toggle terminal (Snacks) |
,o |
Open OpenCode |
,r |
Edit init.lua |
,R |
Reload init.lua |
Defined in zshrc — zsh + oh-my-zsh + Spaceship prompt.
| Keys | Action |
|---|---|
C-r |
atuin — fuzzy, SQLite-backed history search with per-directory filtering |
C-t |
fzf file picker, previewed with bat |
M-c |
fzf directory jump (fd-powered), previewed with eza --tree |
→ / End |
Accept the zsh-autosuggestions completion |
| Command | Runs |
|---|---|
ls / ll / tree |
eza, eza -la --git, eza --tree |
cat |
bat --paging=never |
top |
btm (bottom) |
du |
dust |
g |
git |
vi / vim |
nvim |
lg |
lazygit |
dc |
docker compose |
p |
cd ~/Projects/src/github.com |
y |
yazi — and cds the shell into the directory you exit from |
mdr [file] |
Render markdown with glow — paged with a file argument, directory browser without one |
z <partial> |
zoxide — jump to a frecent directory |
md is deliberately not markdown — it stays oh-my-zsh's mkdir -p.
auto_cd is on, so a bare directory name cds into it, and cdpath covers
~, ~/Projects, and ~/Projects/src/github.com.
Defined in gitconfig. Use as git <alias> (or g <alias>).
| Alias | Expands to |
|---|---|
st |
status -sb |
co / ci / br / bl |
checkout / commit / branch / blame |
d / ds / dw / dp |
diff / diff --staged / --word-diff / no-pager diff |
l / lg |
log --date=human / one-line graph log, last 20 |
f |
fetch -p (prune) |
p |
push |
ffm |
merge --ff-only |
undo |
reset --soft HEAD~1 — uncommit, keep the changes staged |
amend |
commit --amend --no-edit |
wip |
add -u && commit -m 'wip' — tracked files only, so it can't sweep up secrets or build artifacts |
absorb |
git absorb --and-rebase — auto-fixup into the right commits |
cleanup |
Delete local branches already merged into main |
su / suf |
submodule update --init --recursive (--force) |
Because pull.rebase, push.autoSetupRemote, rebase.autosquash, and
fetch.prune are all on, git pull never makes a merge commit and first-push
never needs -u origin.
| Action | Behavior |
|---|---|
Open any .md file in Finder |
Opens in Neovim in a new Ghostty window, via ~/Applications/Open in Neovim.app (compiled from macos/open-in-neovim.applescript, wired up with duti) |
⌥ in the terminal |
Sends Alt/Meta — config/ghostty/config sets macos-option-as-alt = true, which is what makes tmux's M-1…M-5 and fzf's M-c work |
Ghostty and yazi otherwise run on their default keymaps — neither config overrides any bindings.
| File | Owns |
|---|---|
tmux.conf |
tmux prefix bindings, pane navigation, popups, copy mode, plugin config |
config/nvim/init.lua |
Every Neovim mapping, including per-plugin keys tables and LspAttach maps |
zshrc |
Aliases, shell functions, fzf env, oh-my-zsh plugin list |
gitconfig |
Git aliases and workflow defaults |
config/ghostty/config |
Terminal-level key behavior (macos-option-as-alt) |
config/yazi/keymap.toml |
Empty — yazi runs on defaults |
Adding a binding? Add it to the owning file and to the matching table here.