A tour of my dotfiles
I bounce between macOS, Windows, and Linux, WSL included. Every new machine used to cost me a week of rebuilding the same shell habits by hand.
I finally stuck the useful pieces in one private git repo: aliases, shell functions, editor defaults, install scripts, keyboard automation. Below is what sits in there and how I use it day to day. Tools and shortcuts first. Install and structure after that.
What are dotfiles?
Dotfiles are config files that usually live in your home directory and start with a dot: .zshrc, .gitconfig, init.lua. Your shell, editor, terminal, and CLI tools read them. macOS and Linux hide that leading-dot name by default, which is how the nickname stuck.
A dotfiles repo is those files in git, plus a small installer that symlinks them into place. On a new laptop I clone the repo, run the script, and open a fresh terminal. gg is still lazygit. The prompt still looks like mine.
Mine go a bit past pure home-directory configs. I keep shared shell files across OSes and matching keyboard scripts on each platform, because that is where the daily savings show up.
The shape of the repo
Rough layout:
shell/ Shared aliases & functions (zsh + PowerShell)
zsh/ .zshrc and a small custom theme
powershell/ Profile, init caching, interactive setup
prompt/ Oh My Posh theme used on Windows
hammerspoon/ macOS keyboard automation
codingmode/ AutoHotkey "coding mode" on Windows
nvim/ Minimal Neovim defaults
terminal/ Windows Terminal / iTerm bits
linux/ Small Linux helpers
install.sh macOS / Linux installer
install.ps1 Windows installer
Most of the daily value lives under shell/. Zsh and PowerShell load the same ideas from parallel files:
aliases.sh/aliases.ps1functions.sh/functions.ps1
The files are not line-for-line copies. PowerShell has different syntax and a few Windows-only paths. What I type at the prompt stays stable: s, c, pp, gg, w, b, v.
Secrets and one-off paths stay out of the shared files.
- zsh:
~/.zshrc.local - PowerShell: a local profile override next to the real profile
Modern CLI tools
I replaced a bunch of classic Unix tools with faster defaults over time. Aliases hide the swap.
alias ls="eza -l --group-directories-first"
alias sl="eza -l --group-directories-first"
alias l="eza -l --group-directories-first"
alias cat="bat"
sl is on purpose. I mistype ls often enough that aliasing the typo costs less than fighting the habit.
On Windows the same idea shows up as tiny functions. PowerShell aliases cannot carry arguments the way zsh aliases can.
function ls { eza -l --group-directories-first @args }
function sl { eza -l --group-directories-first @args }
function l { eza -l --group-directories-first @args }
Set-Alias -Name cat -Value bat -Force
Day to day loadout:
- eza: better
ls, directories first, clean columns, tree view when I ask for it - bat: better
cat, with syntax highlighting for quick peeks - fzf: fuzzy history and pickers, so Ctrl-R is usable again
- zoxide: smarter
cd, jump by a path fragment - fnm: Node version manager that can switch when I enter a project
- lazygit: terminal git UI, bound to
gg - neovim: default editor for quick edits and kube manifests
The install scripts check for these tools and print a short missing-tool list. Broken aliases are still annoying, so I want that checklist loud at install time.
Jumping around with zoxide
zoxide is the modern tool I notice most. Once it has seen my directories a few times, a short fragment is enough.
z dotfiles
z bca
z ibnuhx
I also keep a few hard-coded jumps for places I open constantly.
w() # main work project
ww() # Work root
fff() # frontend apps folder
They look dull. I type them dozens of times a day, so I keep them.
On a fresh terminal, zsh moves me into ~/Work when I opened the shell from home. Splits and tabs that already have a working directory stay put.
One-letter git
Most of my git usage is short functions, not full commands.
s() { git status; }
a() { git add --all; }
c() { git commit -m "$*"; }
p() { git pull; }
pp() { git push; }
sw() { git switch "$@"; }
ss() { git stash; }
ssp() { git stash pop; }
gg # lazygit
A normal flow looks like this:
s
a
c fix login redirect
pp
A few sharper helpers sit next to those:
cccc: throw away local changes (git restore .plusgit clean -fd)sameasremote: fetch and hard reset the current branch to its remote, with a warning if the working tree is dirtybranch: print the current branch name
One-letter git bothers some people. Collisions, and muscle memory that breaks on a clean machine. I keep these inside the dotfiles, every machine I use gets the installer, and short commands at the prompt matter more to me than purity.
.NET and Docker shortcuts I use at work
A lot of my day job is .NET and containers, so those get short names too.
b() { dotnet build; }
r() { dotnet restore; }
dr() { dotnet run; }
rr() { find . -name "packages.lock.json" -type f -exec rm -f {} +; }
d() { docker ps -a; }
bcs still pays for itself. It walks .csproj files, finds <VersionPrefix>, and bumps the patch number. I stop hunting which project file held the version last week.
On Windows I also have dd for lazydocker, plus a couple of helpers that start a family of local containers by name prefix. Those are tied to one monorepo. Dotfiles can be selfish. A function that only helps your tree still earns its keep.
Coding agents from the shell
I launch coding agents often enough that they got short names.
cc() { codex --yolo "$@"; }
occ() { IS_SANDBOX=1 openclaude --dangerously-skip-permissions "$@"; }
ee() { cd ~/Work/empty && claude "$@"; }
ee is the one I defend most. Sometimes I want an agent in a clean directory, away from the repo I was just touching, so it cannot edit the wrong tree by habit. Empty folder, run agent, stop.
API keys and machine-only model endpoints stay out of the shared shell files. Secrets go in ~/.zshrc.local or the PowerShell local override.
Shell config on zsh
.zshrc stays thin. It loads Oh My Zsh, sets a custom theme, sources the shared shell files, then branches by OS.
source "$DOTFILES/shell/aliases.sh"
source "$DOTFILES/shell/functions.sh"
case "$(uname)" in
Darwin) source "$DOTFILES/shell/macos.sh" ;;
Linux) source "$DOTFILES/shell/linux.sh" ;;
esac
[[ -f ~/.zshrc.local ]] && source ~/.zshrc.local
macOS picks up Homebrew quiet flags, fnm, zoxide, and a few path extras. Linux picks up linuxbrew when present, fnm, zoxide, and a small Windows Terminal hook under WSL.
# report CWD so new WT tabs/splits land in the same folder
printf "\e]9;9;%s\e\\" "$(wslpath -w "$PWD")"
# tab title = current folder or running command
I only missed that WSL hook after living without it for a while.
The zsh prompt is plain on purpose: path, git info, time, then $. Windows uses Oh My Posh with a separate theme file, which is where I already spent time on startup cost.
PowerShell gets equal treatment
Windows is a full shell environment in this setup. The PowerShell side has its own profile entrypoint, interactive-only loading, and a cache helper for expensive init.
The profile does a few careful things:
- Resolve the real path through the profile symlink back to the repo
- Detect whether this is an interactive shell or a
pwsh -Command/ automation run - Load prompt, tools, and aliases only for interactive sessions
- Load environment overrides last
Profiles also run for non-interactive PowerShell. Fancy prompt init during automation still costs time in places you never look at.
I already wrote about cutting PowerShell startup roughly in half by caching init scripts and lazy-loading modules. That post still has the long version:
Cutting PowerShell startup time in half
Short version: tools like zoxide init print shell code that barely changes between launches, so I cache the generated script behind a fingerprint of the binary. Oh-my-posh and fnm need more care, because parts of their output are session-specific and break if you cache them blindly.
Keyboard layer: AutoHotkey and Hammerspoon
Shell shortcuts only help inside the terminal. Outside it I still want arrows, window moves, app summoning, and small text expansions.
On Windows that lives in AutoHotkey as “coding mode”. Caps Lock becomes Escape. Right Alt and Right Ctrl become Home and End. The numpad turns into arrows with NumLock forced off. Ctrl+Alt+H/J/K/L does vim-style movement. There are window resize and center helpers, plus a pile of app-specific shortcuts.
On macOS, Hammerspoon carries a matching set:
- Ctrl+Option+H/J/K/L for vim-style movement
- matching Ctrl+Command variants
- date and time insertion hotkeys
- a filename sanitizer that lowercases selected text and turns junk into underscores
- app and URL launchers I hit all the time
I also have a cmux toggle on Ctrl+Space in Hammerspoon. That one has its own post:
Global hotkey to toggle cmux with Hammerspoon
I want the same body memory when I sit down at the other machine. Platform code can diverge as long as my fingers hit the same chords.
Password snippets and work secrets stay out of blog posts, and they should stay out of shared git history too. Text expansion is fine for email addresses and date stamps. Sensitive strings belong in a password manager or a local-only file.
Neovim stays small
My Neovim config is short on purpose.
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.fileformats = { "dos", "unix", "mac" }
-- strip carriage returns on read
vim.api.nvim_create_autocmd("BufReadPost", {
callback = function()
vim.cmd([[%s/\r//ge]])
end,
})
Relative numbers, plus automatic CR stripping because I bounce files across Windows and Unix enough that \r residue still appears. Neovim answers to vi, vim, n, and often v for “open this folder”.
These dotfiles leave Neovim as a fast terminal editor with zero plugin drama. For heavier editing I open a full IDE.
The installers
There are two installers. Windows and Unix need different steps, and a single shared script kept going wrong for me.
macOS / Linux
install.sh will:
- Make sure zsh exists
- Offer to install Oh My Zsh if needed
- Look at your current
~/.zshrcand detect export/source/eval lines the new setup does not cover - Optionally migrate those leftovers into
~/.zshrc.local - Symlink
.zshrc, the custom theme, and Hammerspoon config - Check for expected CLI tools and print what is missing
The migration step is my favorite part. Replacing someone’s .zshrc is easy. Keeping the random PATH export they added six months ago is the hard part.
Symlinks are boring and correct:
ln -sf "$DOTFILES/zsh/.zshrc" "$HOME/.zshrc"
After that, editing ~/.zshrc is editing the file inside the repo. No copy-back step.
Windows
install.ps1 is meant to run elevated so symlink creation works cleanly. It can:
- Install missing tools through winget (oh-my-posh, eza, bat, fnm, zoxide, fzf, lazygit, neovim, lazydocker)
- Symlink the PowerShell profile and related files
- Repair a few PATH footguns, including a MySQL Workbench embedded Python that likes to shadow real Python
I care a lot about PATH on Windows. One wrong python.exe earlier in the list can burn an afternoon.
What stays out of the shared setup
I keep these out of the files every machine clones:
- API tokens, VPN profiles, private keys
- one-machine path hacks
- experimental aliases I might delete next week
- work-only credentials and internal URLs that do not need a permanent home in every clone
Those go in local override files. The shared repo should make a machine feel like mine without turning into a junk drawer of secrets.
If you maintain dotfiles across more than one machine, copy that split even if you ignore the rest of this post.
Closing
Dotfiles feel like overkill until the day you set up a second machine, reinstall, or open a fresh WSL distro and notice half your brain lived in aliases you never wrote down.
Mine are a private toolkit shaped by how I work across a few OSes, with short commands I type all day, boring install steps, local secrets, and a keyboard layer that follows me outside the terminal.
If you are building your own, start smaller than this. Alias the five commands you type most and put them in git. Add an installer after doing the setup by hand gets old. The repo grows from irritation.
I already have posts about single pieces of this setup. Writing the tour helped me see how the parts sit next to each other.