Personal macOS development environment managed with Homesick. Configures Zsh (with Oh My Zsh), Vim via Vundle, tmux, Ruby development tools via rbenv, and a curated set of Homebrew packages for web development. Uses mise for managing non-Ruby tool versions.
tmux key bindings and options are documented inline in home/.tmux.conf.
-
Install Homebrew
-
Install rbenv and the rbenv-default-gems plugin
Homesick is a Ruby gem and recent versions of macOS no longer ship a system Ruby, so rbenv comes first. The Brewfile installs it too, but
brew bundlecannot run before this repository is on disk.The plugin installs a fixed list of gems into every Ruby that rbenv builds. Homesick is on that list, so setting the plugin up now is what lets the Ruby in step 4 arrive with Homesick already installed. Cloning it needs no Ruby of its own:
brew install rbenv ruby-build # `.zshrc` in this repository already runs `rbenv init`. Rather than let rbenv write to the # `.zshrc` that Homesick is about to replace, just load rbenv into the current shell: eval "$(rbenv init - zsh)" git clone https://github.com/rbenv/rbenv-default-gems.git "$(rbenv root)/plugins/rbenv-default-gems"
-
Clone this repository and point the plugin at its gem list
homesick clonewould normally do the first line, but Homesick is exactly the gem that does not exist yet. Plaingitis the way out of that loop, and cloning into~/.homesick/reposis allhomesick clonedoes anyway — step 5 picks the castle up from there.git clone https://github.com/mattmenefee/dotfiles.git ~/.homesick/repos/dotfiles mkdir -p "$(rbenv root)" ln -s ~/.homesick/repos/dotfiles/home/.rbenv/default-gems "$(rbenv root)/default-gems"
The symlink has to exist before the next step.
home/.rbenv/default-gemsis the list the plugin reads, and the plugin skips silently when that file is missing — a Ruby installed without it looks like a normal success and simply has none of the gems. -
Install Ruby
rbenv install -l # list all available versions rbenv install [version] rbenv global [version] # set global Ruby version
Everything in
home/.rbenv/default-gemslands in the new Ruby as part of that install — Homesick, gem_updater, mailcatcher, awesome_print and ruby-lsp — so none of them needs installing by hand, and every Ruby installed later gets the same set. -
Symlink the dotfiles
homesick link dotfiles
Homesick does not recognize a symlink that already points where it belongs, so it reports
conflict ~/.rbenv/default-gems existsand prompts to overwrite. Answern: that is the link from step 3, and it is already correct. -
Install tools managed by Homebrew
The Brewfile installs the CircleCI CLI from a third-party tap, which Homebrew's cask-trust gate blocks until it is explicitly trusted. Run the one-time
brew trustfirst, thenbrew bundle:cd ~/.homesick/repos/dotfiles/ brew trust --cask circleci-public/circleci/circleci@next brew bundle
Ruby versions are managed with rbenv. Other tool versions (e.g., Ansible, Terraform) are managed with mise, which is installed via Homebrew and activated through the Oh My Zsh
miseplugin. -
Install Claude Code
curl -fsSL https://claude.ai/install.sh | bashClaude Code isn't in the Brewfile: this installer keeps it updated in the background, and the Homebrew casks don't.
-
Start the database services
brew services start postgresql@18 brew services start redis
postgresql@18is a versioned formula, so Homebrew keeps it keg-only and does not symlink its binaries into the prefix. That is why.zshrcputs/opt/homebrew/opt/postgresql@18/binonPATHexplicitly, and whypsqlonly resolves once the dotfiles are linked. -
Update RubyGems
gem update --system
-
Install Oh My Zsh
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"The following Oh My Zsh plugins are enabled in
.zshrc:Plugin Source Description git built-in Git aliases and helper functions rails built-in Rails command aliases docker built-in Docker completions vi-mode built-in Vim keybindings in the shell mise built-in Activates mise for version management z built-in Jump to frequently used directories (e.g., z dotfiles)gh built-in GitHub CLI completions bundler built-in Runs bundled commands via bundle exec; addsbe,bi,bl,bp,buTwo additional Zsh plugins are installed via Homebrew (included in the Brewfile) and sourced at the bottom of
.zshrc:- zsh-syntax-highlighting — highlights commands as you type
- zsh-autosuggestions — suggests commands from history as you type
-
Select the iTerm2 profile
homesick linkputs a dynamic profile at~/Library/Application Support/iTerm2/DynamicProfiles/main.json. iTerm2 reads that directory at launch and again whenever a file in it changes, so the profile shows up with no import step — but it does not become the default on its own.Settings → Profiles, select Matt (dotfiles), then Other Actions… → Set as Default.
Dynamic profiles are read-only in iTerm2's UI. Changing one means editing
main.jsonin this repository, which is the point of keeping it here rather than in iTerm2's own preferences. -
Install Vundle and run the Vim plugin installer
Vundle is not itself installed by Vundle:
.vimrcadds~/.vim/bundle/Vundle.vimto the runtime path and calls into it, so that clone has to exist before Vim can install anything.git clone https://github.com/VundleVim/Vundle.vim.git ~/.vim/bundle/Vundle.vim cd ~/.homesick/repos/dotfiles zsh init.zsh
This installs Vundle plugins for MacVim. Neovim is in the Brewfile but is not configured here.
-
Set up Git config
home/.gitconfigcarries no[user]section on purpose — it ends with an include instead:[include] path = ~/.gitconfig.local
The identity belongs in that file, which this repository does not track.
git config --globalwould put it in~/.gitconfig, and Homesick has symlinked that to the tracked copy: Git resolves the symlink and writes the name and email into this public repository. Name the include file explicitly instead:# Insert appropriate values git config --file ~/.gitconfig.local user.name "$GIT_AUTHOR_NAME" git config --file ~/.gitconfig.local user.email "$GIT_AUTHOR_EMAIL"
GitHub Desktop writes the same two settings the same wrong way from its first-run Configure Git screen, prefilled from the signed-in account and without asking. Skip that screen, or undo it afterwards — a
[user]section appended tohome/.gitconfigis what it leaves behind, and agit diffin the castle is where it surfaces:cd ~/.homesick/repos/dotfiles/ git diff home/.gitconfig # a [user] section here is the identity that should have gone local git restore home/.gitconfig # discard it; re-run the `--file` commands above
-
Set up an SSH key and
~/.ssh/configNeither the key nor
~/.ssh/configis tracked here. A private key never belongs in a repository, and the config names one machine's key paths, so both stay local.# Insert appropriate value ssh-keygen -t ed25519 -C "$GIT_AUTHOR_EMAIL"
~/.ssh/configis what keeps the passphrase from coming back after every reboot.UseKeychainreads it from the macOS Keychain instead of prompting, andAddKeysToAgentloads the key into the agent the first time something needs it:Host github.com AddKeysToAgent yes UseKeychain yes IdentityFile ~/.ssh/id_ed25519
# Store the passphrase in the Keychain, which is what UseKeychain then reads ssh-add --apple-use-keychain ~/.ssh/id_ed25519 # Log in, then grant the scope that uploading a key requires gh auth login gh auth refresh -h github.com -s admin:public_key # Upload the public key to GitHub and confirm it authenticates gh ssh-key add ~/.ssh/id_ed25519.pub --title "$(scutil --get ComputerName)" ssh -T git@github.com
gh auth logindoes not requestadmin:public_keyby default, so agh ssh-key addrun before therefreshfails and uploads nothing. Granting the scope afterward does not retry it: rungh ssh-key addagain, orssh -Tkeeps answeringPermission denied (publickey).A successful
ssh -Tgreets you by name and exits 1, because GitHub never gives the connection a shell. That is the expected result, not a failure.Git operations stay on HTTPS:
.config/gh/config.ymlsetsgit_protocol: https,.gitconfigsetscredential.helper = osxkeychainto go with it, and Homesick clones over HTTPS too. The key is for the things that want SSH regardless — other hosts, deploy access, signing. -
Store secrets in the macOS Keychain
.zshrcis committed to this public repository, so secrets are never exported inline. Each one is stored in the Keychain and read back at shell startup, which keeps the lookup safe to publish while the value stays on the machine.# Insert appropriate values security add-generic-password -s "$KEYCHAIN_SERVICE" -a "$USER" -w "$SECRET"
The matching export in
.zshrcnames the service literally, and redirects stderr so that a machine which has not stored the secret yet still opens shells without an error:export ROLLBAR_ACCESS_TOKEN="$(security find-generic-password -s rollbar-mcp -w 2>/dev/null)"
Variable Keychain service Used by ROLLBAR_ACCESS_TOKENrollbar-mcpRollbar MCP server in Claude Code These lookups run only for interactive shells, because
.zshrcis where they live. An application launched from the Dock rather than a terminal does not inherit them.
# Homebrew
brewup
# RubyGems
gem update --system
# Bundler
gem update bundler
# mise tools (mise itself is updated by brewup)
mise upgrade
# Dotfiles via Homesick
homesick pull --all
homesick link --force dotfileshomesick pull only fetches; it does not relink. A file added inside a directory that is already
symlinked, such as home/.zsh, appears with no further action — but a new top-level file does not,
and until the link run it is simply absent, with nothing to say why. --force is what makes the
run unattended: without it homesick prompts for every path that already exists. Those paths are
normally correct symlinks that it removes and recreates, but a real file left at a managed path
would be overwritten the same way.
Oh My Zsh is configured to auto-update daily via zstyle settings in .zshrc.
A Docker Desktop update may append this to .zprofile, .bash_profile, and .profile — all three
of which are tracked here:
# The following lines were added by Docker Desktop to add commands to your PATH.
export PATH="$PATH:/Users/<username>/.docker/bin"
# End of Docker Desktop section.Delete it whenever it shows up in a diff. The block is inert, and it is wrong in this repository:
- The CLI tools are installed in System mode (see Settings → Advanced),
which symlinks them into
/usr/local/bin. Everything in$HOME/.docker/binduplicates those symlinks. /usr/local/binis the first entry in/etc/paths, and the block appends toPATH, so it could never win a lookup anyway —command -v dockerresolves to/usr/local/bin/docker.- The path is hardcoded to one absolute home directory, so it is wrong on any other machine these dotfiles are linked into.
Selecting System does not reliably prevent this: version 4.90.0 wrote the block during a self-update with System already selected.