My dotfiles setup: how I manage my dev environment
On this page12 sections ▾
- The Management Layer: GNU Stow
- Shell Setup: Zinit + Starship
- Multi-Identity Git
- 1Password SSH Integration
- Editor-Like Terminal Input
- Custom Aliases and Functions
- AI Agent Configurations
- Homebrew Package Tracking
- VS Code Insiders Config
- The PATH Situation
- Getting Started with Your Own Dotfiles
- Why this approach works for me
I've had a few people ask me how my dotfiles are set up, what tools I use, how I keep things in sync, and how I manage multiple Git identities across personal and work projects. So I figured I'd just write it all down.
My dotfiles repo is private for now (there are some experiments in there that can't be public just yet) so I'll walk through the interesting parts here instead.
#The Management Layer: GNU Stow
The whole thing is managed with GNU Stow. If you haven't used it before, Stow is a symlink manager. You put your config files in a directory structure that mirrors where they'd live in your home folder, then run stow . and it creates all the symlinks for you.
So my dotfiles repo looks roughly like this:
~/dotfiles/
├── .zshrc → ~/.zshrc
├── .gitconfig → ~/.gitconfig
├── .copilot_here.sh → ~/.copilot_here.sh
├── .config/
│ ├── starship.toml → ~/.config/starship.toml
│ ├── gh/config.yml → ~/.config/gh/config.yml
│ └── opencode/agents/→ ~/.config/opencode/agents/
├── .ssh/config → ~/.ssh/config
├── files/vscode/ → ~/Library/Application Support/Code - Insiders/User/
├── backup-files.sh
├── restore-files.sh
└── brew.txtThe backup-files.sh and restore-files.sh scripts handle the VS Code Insiders settings — since those live in a deeply nested macOS path, they copy settings into the repo for versioning and back out to the app directory when needed. Everything else is just Stow symlinks.
There's a .stow-local-ignore file that tells Stow to ignore .git, .DS_Store, and README.md so those don't get symlinked into my home directory.
No Makefile, no complex install script. Just stow . and you're done.
#Shell Setup: Zinit + Starship
I recently migrated away from Oh My Zsh to a much lighter setup. The core of it is Zinit for plugin management and Starship for the prompt.
Instead of loading all of Oh My Zsh, I cherry-pick only the four OMZ libraries I actually need:
zinit snippet OMZL::git.zsh
zinit snippet OMZL::directories.zsh
zinit snippet OMZL::theme-and-appearance.zsh
zinit snippet OMZL::async_prompt.zsh
zinit snippet OMZP::gitOn top of that, a few plugins that actually improve the shell experience:
- eza — replaces
lswith icons, git status, and directory-first sorting - zsh-syntax-highlighting — highlights commands as you type them
- zsh-autosuggestions — suggests commands from your history
- fzf-tab — fuzzy interactive tab completion
- Atuin — full-text fuzzy search across shell history
- zoxide — smart directory jumping (
z xylemfrom anywhere)
My Starship config is two lines. It just disables the gcloud module. Everything else uses the defaults.
#Multi-Identity Git
This is probably the thing people ask about most. I work across personal projects, SSW projects, and occasionally other identities — each with different SSH keys and commit signing configs.
Rather than messing with conditional includes or directory-based git configs, I use simple git aliases that set the identity on the current repo:
[alias]
me = "!f() { git config user.name 'Gordon Beeming' && git config user.email 'gordon@beeming.net' && git config user.signingkey 'key:ssh-ed25519 ...' && git config gpg.ssh.program '/Applications/1Password.app/Contents/MacOS/op-ssh-sign' && git config core.sshCommand 'ssh -i ~/.ssh/me.pub'; }; f"
ssw = "!f() { git config user.name 'Gordon Beeming' && git config user.email 'GordonB@ssw.com.au' && git config user.signingkey 'key:ssh-ed25519 ...' && git config gpg.ssh.program '/Applications/1Password.app/Contents/MacOS/op-ssh-sign' && git config core.sshCommand 'ssh -i ~/.ssh/ssw.pub'; }; f"When I clone a repo, I just run git me or git ssw and it configures the local repo with the right name, email, SSH key, and signing key. No global defaults to accidentally leak the wrong identity.
I've also got git azdo-ssw2 for Azure DevOps repos that need RSA keys, and a couple of other identity aliases for specific use cases. Each one is a single command.
#Git Workflow Aliases
A few other aliases I use daily:
[alias]
nb = "!f() { git stash && git checkout main && git pull && git checkout -b \"$1\"; }; f"
md = "!f() { git checkout main && git pull && git branch -d \"$1\"; }; f"
u = "!f() { git stash && git pull && git stash pop; }; f"
ca = "!f() { git add -A && git commit -m \"$1\"; }; f"git nb feature-x— stashes work, pulls main, creates a new branchgit md feature-x— switches to main, pulls, deletes the merged branchgit u— smart pull that stashes and pops automaticallygit ca "message"— stages everything and commits in one go
#1Password SSH Integration
All my SSH keys live in 1Password. No private keys on disk at all. My .zshrc sets SSH_AUTH_SOCK to 1Password's agent socket, the .ssh/config points IdentityAgent to the same socket, commits are signed using 1Password's op-ssh-sign binary, and OP_BIOMETRIC_UNLOCK_ENABLED=true means I authenticate with Touch ID.
When I push to GitHub or sign a commit, 1Password pops up a Touch ID prompt. That's it. No ssh-add, no passphrase management, no key files sitting on disk.
#Editor-Like Terminal Input
One thing that really bugged me after switching to Ghostty was losing Warp's text-editing behaviour. I need Shift+Arrow selection, Shift+Enter for newlines, and type-to-replace-selection in my terminal. My .zshrc has a set of ZLE widgets that recreate this:
# Shift+Enter inserts a newline (like Warp)
shift-enter-newline() { LBUFFER+=$'\n'; }
zle -N shift-enter-newline
bindkey '^[[27;2;13~' shift-enter-newline
# Shift+Arrow starts/extends selection
# Plain arrows while selected deselects
# Typing with selection active replaces it
# Backspace/Delete with selection kills the regionIt's not quite as polished as Warp, but it covers the cases I actually use.
#Custom Aliases and Functions
Here's a selection of the aliases I use most:
# Claude Code shortcuts
alias clwd='claude --dangerously-skip-permissions -p worktree'
alias clw='claude -p worktree'
alias clr='claude --resume'
alias cld='claude --dangerously-skip-permissions'
alias cl='claude'
# Utilities
alias guid='uuidgen | tr "[:upper:]" "[:lower:]" | pbcopy && pbcopy | cat'
alias reload='source ~/.zshrc && source ~/.copilot_here.sh'
alias gitclean='git clean -xfd'
# Cleanup
alias clear-ds-store='find . -name ".DS_Store" -type f -delete'
alias clear-bins='find . -name "bin" -type d -exec rm -rf {} +'
alias clear-objs='find . -name "obj" -type d -exec rm -rf {} +'And a few Docker-based utilities for one-off tasks:
alias testssl='docker run --rm -it drwetter/testssl.sh'
alias dns='docker run --rm alpine nslookup'
alias cert='docker run --rm -it alpine/openssl s_client -connect'These mean I don't need to install testssl.sh or other infrequently-used tools locally.
#AI Agent Configurations
I've got 18 pre-configured specialist agents for OpenCode in my dotfiles. Each agent is a YAML file with a specific role, model, temperature, and tool permissions: coders for dotnet, react, and bicep; reviewers for dotnet, react, and Vertical Slice Architecture; Blazor, DDD, EF Core, and .NET Aspire specialists; unit and integration test agents; and a couple of architect agents for VSA patterns.
Each one is tuned for its domain. The dotnet coder knows about C# conventions, the Blazor specialist knows about component patterns, the VSA reviewer checks for architectural violations. Having these in my dotfiles means the configs are consistent across machines.
#Homebrew Package Tracking
I keep a brew.txt file that's a snapshot of all installed Homebrew packages. It's not an install script, it's a reference. When I set up a new machine, I go through the list and install what I need. When I install something new, I update the list.
The current list has 100+ packages: multiple .NET SDK versions (3.1 through 9-preview), Python 3.11/3.12/3.13, Azure CLI and Azure Developer CLI, container tools (Docker, Colima), YubiKey management tools, and GUI apps via cask (Rider, VS Code Insiders, Raycast, Rectangle, 1Password, Ghostty).
#VS Code Insiders Config
My editor setup lives in files/vscode/ and gets synced via the backup/restore scripts. Monaspace Neon with ligatures (ss01 through ss09), GitHub Light Default theme (yes, light theme, fight me), format on save for everything, Copilot with auto-completions, and Prettier for JS/JSON with RedHat extensions for XML/YAML.
#The PATH Situation
Over time, I've accumulated quite a few custom PATH entries. My .zshrc adds:
~/.local/bin
~/bin
~/.claudenest/bin
~/.aspire/bin
~/.opencode/bin
~/jetbrains
~/.dotnet/tools
/Developer/shellEach one is for a different tool ecosystem. It's a lot of entries, but they all serve a purpose and I'd rather have them organized in separate directories than everything dumped into one ~/bin.
#Getting Started with Your Own Dotfiles
If you've been putting off setting up your own dotfiles repo, here's how to get going. It's simpler than it looks.
#1. Create the folder and install Stow
mkdir ~/dotfiles
cd ~/dotfiles
git init
brew install stowThat's your dotfiles repo. Whatever you put in here mirrors the structure of your home directory.
#2. Set up the ignore file
Before you stow anything, create a .stow-local-ignore file so Stow doesn't try to symlink your repo metadata into your home folder:
cat <<'EOF' > .stow-local-ignore
\.git
\.DS_Store
README\.md
EOFYou can add more patterns here later — scripts, notes, anything that lives in the repo but shouldn't be symlinked.
#3. Move your config files in
Pick a config file you want to manage. .gitconfig is a good starting point. The thing to understand is that the file's path inside ~/dotfiles/ needs to match its path relative to your home directory.
You can do this manually:
# Move the file into your dotfiles repo
mv ~/.gitconfig ~/dotfiles/.gitconfig
# Now stow it back as a symlink
cd ~/dotfiles
stow .After running stow ., check that it worked:
ls -la ~/.gitconfig
# Should show: .gitconfig -> dotfiles/.gitconfigYour .gitconfig now lives in a git repo, but your system still finds it exactly where it expects it.
Do the same for .zshrc, .config/starship.toml, or whatever else you want to track:
mv ~/.zshrc ~/dotfiles/.zshrc
mv ~/.config/starship.toml ~/dotfiles/.config/starship.toml
cd ~/dotfiles
stow .Stow creates the symlinks, and everything keeps working as before, except now it's versioned.
#4. Or let AI do the heavy lifting
If you've already got a bunch of config files scattered around and don't want to move them one by one, this is a good task for an AI coding assistant. I used Claude Code for my setup, and you could say something like:
"I want to set up a dotfiles repo at ~/dotfiles using GNU Stow. Find my .gitconfig, .zshrc, .config/starship.toml, and any other config files worth tracking, move them into the right structure in ~/dotfiles, and stow them back as symlinks."
Claude (or whatever assistant you use) can explore your home directory, identify the configs worth managing, create the directory structure, move the files, set up the ignore file, and run stow . in one go. It's especially useful for catching things you might forget, like SSH configs or tool-specific settings buried in .config/.
#5. Commit and push
cd ~/dotfiles
git add -A
git commit -m "Initial dotfiles setup"After that, whenever you change a config, the change happens in ~/dotfiles/ (because that's where the symlink points), so you just commit and push. New machine? Clone the repo and run stow ..
#Start small, grow over time
Mine has grown to include 18 AI agent configs, multi-identity Git aliases, Docker-based CLI tools, and VS Code settings with backup scripts. It started as a .gitconfig and a .zshrc.
Adding a new file is always the same: put it in the right place inside ~/dotfiles/, run stow ., done. No manifest to update, no config file to edit.
#Why this approach works for me
I've tried more complex dotfiles managers: Chezmoi, Ansible playbooks, custom bash installers. They all added enough overhead that I stopped keeping things updated. Stow is simple enough that I actually commit changes when I make them. That's really the only metric that matters.
The multi-identity Git setup saves me from the wrong-email-on-commit embarrassment at least once a week. The 1Password integration means I never think about SSH keys. Having my AI agent configs versioned means I can tweak a reviewer's prompt on one machine and it's just there everywhere else.
If you've got questions about specific parts of the setup, feel free to reach out.