dotfiles-mac
Dotfiles Mac
Help users create, update, or apply a macOS dotfiles repo using GNU Stow and plain git.
Repo Conventions
- Location:
~/.dotfiles/(or user's existing dotfiles repo) - Symlink manager: GNU Stow with
--no-folding(always file-level symlinks, never directory-level) - Structure: Each top-level directory is a stow package mirroring
$HOME - OS-specific: Directories prefixed
os-(e.g.,os-macos/) contain OS-specific files - Machine-specific:
.localfile pattern — gitignored files sourced/included by tracked configs
Repo Layout
~/.dotfiles/
├── setup.sh # Entry point: detects OS, delegates
├── .gitignore
├── .stow-local-ignore
├── README.md
│
├── # Cross-platform stow packages (each mirrors $HOME)
├── shell/ # → ~/.zshrc, ~/.zprofile, etc.
├── git/ # → ~/.gitconfig, ~/.gitignore_global
├── ssh/ # → ~/.ssh/config (NOT keys)
├── gpg/ # → ~/.gnupg/gpg.conf, gpg-agent.conf
├── tmux/ # → ~/.tmux.conf or ~/.config/tmux/
├── nvim/ # → ~/.config/nvim/
├── ghostty/ # → ~/.config/ghostty/
├── claude/ # → ~/.claude/settings.json, skills, etc.
├── <tool>/ # Additional stow packages as needed
│
├── os-macos/ # macOS-specific (NOT auto-stowed)
│ ├── Brewfile # Homebrew packages/casks/taps/mas
│ ├── setup.sh # brew, Xcode CLT, stow, defaults
│ ├── defaults.sh # macOS defaults write commands
│ └── gpg/ # OS-specific override (e.g., pinentry-mac)
│
└── os-linux/ # Future: Linux-specific
├── setup.sh
└── gpg/ # Linux-specific override
The os- prefix keeps OS directories sorted together, visually distinct from stow packages.
OS-Specific Overrides
For each stow package, check if os-{current_os}/ has a directory with the same name. If so, stow the OS-specific version instead of the common one — the OS version wins entirely.
Example: gpg/ has base config. os-macos/gpg/ has macOS-specific config (e.g., pinentry-program set to pinentry-mac). On macOS, only os-macos/gpg/ is stowed.
If OS-specific package directories contain files that should be ignored by stow (e.g., README.md), place a .stow-local-ignore in the os-macos/ directory — stow only reads this file from its -d directory.
Machine-Specific Overrides
Use the .local file pattern — tracked configs source/include an untracked .local counterpart:
.zshrc→ sources~/.zshrc.localat end (if it exists).gitconfig→[include] path = ~/.gitconfig.local.ssh/config→Include ~/.ssh/config.localat top
All .local files are gitignored. This avoids templating engines entirely.
Instructions for Claude
You are helping a user manage their macOS dotfiles. Determine which workflow applies:
- Create: User has no dotfiles repo — audit their system, generate the repo
- Update/Capture: User has a dotfiles repo — capture current system state into it
- Apply: User has a dotfiles repo — apply it to a new or existing machine
If unclear, ask the user which workflow they want.
Workflow A: Create a New Dotfiles Repo
Step 1: Audit the System
Scan the user's machine to discover what's worth tracking. Run these in parallel where possible:
Existing dotfiles managers:
- Check for chezmoi (
~/.local/share/chezmoi/), yadm (~/.local/share/yadm/), or bare git repos in$HOME(~/.cfg/,~/.dotfiles.git/) - If detected, warn the user before proceeding — creating a competing dotfiles system can cause conflicts
Homebrew:
brew bundle dump --force --describe --file="$(mktemp /tmp/dotfiles-audit-Brewfile.XXXXXX)"
Shell configs:
- Check for
~/.zshrc,~/.zsh/,~/.zprofile,~/.zshenv,~/.bashrc,~/.bash_profile - Fish:
~/.config/fish/config.fish,~/.config/fish/conf.d/,~/.config/fish/functions/ - Detect framework: Oh My Zsh (
~/.oh-my-zsh/), Prezto, Starship, plain zsh - For fish or bash users, skip zsh-specific sections (Oh My Zsh, zsh plugins) and adapt shell configuration steps accordingly
- If Oh My Zsh: note custom themes in
~/.oh-my-zsh/custom/themes/and custom plugins in~/.oh-my-zsh/custom/plugins/— these are user content worth tracking. Do NOT track OMZ core (it's managed by its own installer).
Git:
- Read
~/.gitconfig(may contain[user]with name/email — fine to track) - Check for conditional includes (
[includeIf]sections) — these reference paths that may need adjustment on other machines. Suggest moving[includeIf]blocks to~/.gitconfig.localsince they reference machine-specific paths - Check for
~/.gitignore_globalor equivalent
SSH:
- Read
~/.ssh/config(track this) - NEVER track
~/.ssh/id_*,~/.ssh/*.pub,~/.ssh/known_hosts,~/.ssh/authorized_keys
GPG:
- Read
~/.gnupg/gpg.conf,~/.gnupg/gpg-agent.conf(track these) - NEVER track:
~/.gnupg/private-keys-v1.d/,~/.gnupg/*.kbx,~/.gnupg/trustdb.gpg,~/.gnupg/openpgp-revocs.d/,~/.gnupg/S.gpg-agent*
Claude/AI configs:
- Check for
~/CLAUDE.local.md,~/.claude/settings.json, non-symlinked skills in~/.claude/skills/ - NEVER track:
~/.claude/auth/,~/.claude/sessions/,~/.claude/cache/,~/.claude/telemetry/,~/.claude/*.local.json
Terminal emulator:
- Ghostty:
~/.config/ghostty/config - iTerm2: check for plist or JSON profile exports
- Alacritty:
~/.config/alacritty/alacritty.toml(current, since v0.13) or~/.config/alacritty/alacritty.yml(legacy) - Kitty:
~/.config/kitty/kitty.conf
Editor configs:
- Neovim:
~/.config/nvim/ - Vim:
~/.vimrc - VS Code:
~/Library/Application Support/Code/User/settings.json,keybindings.json - VS Code/Cursor settings live in
~/Library/Application Support/(path with spaces). These can't be managed cleanly with stow — handle with direct symlinks in setup.sh instead:ln -sf "$DOTFILES_DIR/vscode/.config/Code/User/settings.json" \ "$HOME/Library/Application Support/Code/User/settings.json"
Other common configs:
- tmux:
~/.tmux.confor~/.config/tmux/tmux.conf - Starship:
~/.config/starship.toml - ripgrep:
~/.ripgreprc - bat:
~/.config/bat/config - Any
~/.config/subdirectories for tools installed via Homebrew - Check
$XDG_CONFIG_HOME(default:~/.config/). If set to a non-default path, use it as the stow target (-t $XDG_CONFIG_HOME) for packages that install into~/.config/.
macOS defaults:
- Ask the user if they want to capture macOS system preferences
- If yes, identify commonly customized domains:
NSGlobalDomain,com.apple.dock,com.apple.finder,com.apple.Safari,com.apple.screencapture, etc.
Step 2: Security Scan
Before proposing anything to track, scan discovered files for secrets:
- Auth tokens: Look for patterns like
token,api_key,secret,password,credentialin config files - Specific files to exclude:
~/.npmrc(may contain auth tokens) — detect and either exclude or template with placeholder~/.config/graphite/user_config(contains auth) — exclude~/.netrc— exclude~/.aws/credentials— exclude (but~/.aws/configis safe)~/.docker/config.json(Docker registry auth) — exclude~/.kube/config(Kubernetes tokens/certs) — exclude~/.config/gh/hosts.yml(GitHub CLI OAuth tokens) — exclude~/.config/gcloud/(Google Cloud credentials) — exclude~/.boto,~/.s3cfg(S3 credentials) — exclude- Any file containing token prefixes listed in the Security Rules section below
- Scan file contents for
-----BEGIN.*PRIVATE KEY-----headers — this catches embedded private keys regardless of filename - In shell configs, scan for
exportstatements where the variable name contains KEY, SECRET, TOKEN, PASSWORD, or CREDENTIAL — these often contain inline secrets - If a file contains both safe config and embedded secrets, note it for the user and suggest the
.localfile pattern to split them
Step 3: Present Findings
Show the user what was discovered, grouped by category:
## Discovered Configuration
### Homebrew (N formulae, N casks, N taps)
[summary of what's in the Brewfile]
### Shell (zsh + Oh My Zsh)
- .zshrc, .zprofile, .zshenv
- OMZ custom themes: [list]
- OMZ custom plugins: [list]
### Git
- .gitconfig (user: name <email>)
- .gitignore_global
### SSH
- config (N hosts configured)
- ⚠ Keys will NOT be tracked
### GPG
- gpg.conf, gpg-agent.conf
- ⚠ Secret keys will NOT be tracked
### [other categories...]
### ⚠ Excluded (secrets detected)
- ~/.npmrc (contains auth token)
- [other excluded files]
Ask the user:
- Which categories to include (all are opt-in by default)
- Whether to capture macOS defaults
- Where to create the repo (default:
~/.dotfiles/) - Whether to create a GitHub repo
Step 4: Generate the Repo
- Create the directory structure with stow packages for each selected category
- Copy config files into the appropriate stow package directories, mirroring home directory structure
- Place the
Brewfilefrom the audit dump intoos-macos/ - Generate
setup.sh(see Setup Script section below) - Generate
.gitignorecovering:- Secret key patterns (
id_*,*.key,*.pem,private-keys-v1.d/) - Auth files (
.npmrc,.netrc, auth tokens) .localoverride files (*.local,.local/)- Backup directory (
.dotfiles-backup/) - OS artifacts (
.DS_Store)
- Secret key patterns (
- Generate
.stow-local-ignore(skipREADME.md,setup.sh,os-*,.git,.gitignore) - Generate
README.mdwith repo overview and usage instructions - If macOS defaults selected, generate
os-macos/defaults.sh - Ensure tracked shell configs include the
.localsourcing pattern at the end - Ensure
.gitconfigincludes[include] path = ~/.gitconfig.local - Ensure
.ssh/configincludesInclude ~/.ssh/config.localat top git init, create initial commit- If user wants GitHub: create remote repo and push
Step 5: Apply (Optional)
After generating, ask if the user wants to apply the dotfiles now (stow them). If yes, run setup.sh with the stow subcommand.
Workflow B: Update/Capture Existing Repo
The user has a dotfiles repo and wants to sync their current system state into it.
Step 1: Locate and Understand the Repo
- Find the dotfiles repo (check
~/.dotfiles/, or ask) - Read the repo structure to understand what's already tracked
- Identify which stow packages exist
Step 2: Diff Current State vs Tracked
For each tracked category, compare current system files with repo contents:
Brewfile:
brew bundle dump --force --describe --file="$(mktemp /tmp/dotfiles-capture-Brewfile.XXXXXX)"
Then diff against the tracked os-macos/Brewfile. Show added/removed packages.
Config files: For each stow package, diff the target file against the repo copy. Show meaningful changes (ignore whitespace, comments-only changes are low priority).
New configs: Scan for config files that exist on the system but aren't tracked in any stow package. Suggest new packages.
Step 3: Present Changes
Show the user a summary of what changed:
## Changes Since Last Capture
### Brewfile
- Added: package-a, package-b, cask-c
- Removed: old-package
### shell/.zshrc
- [diff summary or key changes]
### New (untracked)
- ~/.config/ghostty/config (suggest: ghostty/ stow package)
### Unchanged
- git/, ssh/, gpg/
Ask the user which changes to apply to the repo.
Step 4: Apply Updates
- Update selected files in the repo (copy current system files into stow packages)
- Update Brewfile if selected
- Run the security scan on any new/changed files before staging
- Stage and commit with a descriptive message (e.g.,
chore: capture updated shell config and new packages)
Workflow C: Apply Repo to a Machine
The user has a dotfiles repo and wants to apply it to a new or existing machine.
Step 1: Validate
- Read the repo to understand what will be applied
- Check for conflicts: existing files at target locations that aren't symlinks to the repo
- Present a summary of what will happen
- Ask the user: "Proceed with applying these changes?" — never run setup.sh without explicit confirmation
Step 2: Run Setup
Execute setup.sh or walk through it step by step if the user prefers. See Setup Script section for the execution order.
If setup.sh fails partway through: the script uses set -euo pipefail so it stops on error. Some steps may have already completed (packages installed, some stow links created). Since each phase is idempotent, it's safe to fix the issue and re-run the script. Watch for stow conflicts or partial symlinks that may need manual cleanup before re-running.
Step 3: Post-Apply Checklist
After setup completes, present a next-steps checklist:
## Next Steps (manual)
- [ ] Import GPG secret keys: `gpg --import /path/to/private-key.asc`
Then set trust: `gpg --edit-key <KEY_ID>` → `trust` → `5` → `quit`
- [ ] Copy SSH keys to ~/.ssh/ and `chmod 600 ~/.ssh/id_*`
(or generate new: `ssh-keygen -t ed25519`)
(if using encrypted secrets with age, keys are already in place after decryption)
- [ ] Sign into Mac App Store (for `mas` packages in Brewfile)
- [ ] Authenticate services:
- [ ] `gh auth login` (GitHub CLI)
- [ ] `npm login` (npm registry)
- [ ] `gt auth` (Graphite)
- [ ] Create machine-specific overrides in ~/.zshrc.local, ~/.gitconfig.local, etc.
- [ ] Review and run macOS defaults: cd ~/.dotfiles && ./os-macos/defaults.sh
Ask me to help with any of these!
Workflow D: Unstow / Restore
If the user wants to revert to their pre-stow state:
- Un-stow all packages:
stow -D -d $DOTFILES_DIR -t $HOME <package>for each - If
~/.dotfiles-backup/exists, offer to restore backed-up files - List any files that were in the backup and confirm before restoring
- Print what was restored vs what was removed
Setup Script Design
Root setup.sh
The root setup.sh detects the OS and delegates:
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "$(uname -s)" in
Darwin) source "$DOTFILES_DIR/os-macos/setup.sh" ;;
Linux) source "$DOTFILES_DIR/os-linux/setup.sh" ;;
*) echo "Unsupported OS"; exit 1 ;;
esac
os-macos/setup.sh
Supports subcommands:
./setup.sh # Full install (all phases)
./setup.sh brew # Homebrew + brew bundle only
./setup.sh stow # Stow all packages only
./setup.sh macos # macOS defaults only
./setup.sh capture # Capture current state back to repo
./setup.sh restore # Un-stow all packages and restore backups
Execution Order (Full Install)
Phase 1: Foundation
1. Xcode Command Line Tools
- xcode-select -p &>/dev/null || xcode-select --install
2. Homebrew
- Detect arch: /opt/homebrew (ARM) vs /usr/local (Intel)
- Install if missing, eval brew shellenv
Phase 2: Packages
3. brew bundle install --file=os-macos/Brewfile --no-lock
- Non-fatal: individual failures warn but continue
Phase 3: Decrypt Secrets (if age-encrypted files exist)
4. Find all .age files in stow packages
- If none found, skip this phase
- Prompt for master passphrase once
- Decrypt each .age file to its non-.age counterpart
- Unset passphrase from environment after decryption
Phase 4: Frameworks
5. Oh My Zsh (if shell/ stow package uses it)
- Install if ~/.oh-my-zsh/ doesn't exist
- Stow custom themes/plugins into place
Phase 5: Configuration
6. Stow all packages
- For each directory that isn't os-*, .git, or special files:
- Check for os-macos/ override → stow that instead if present
- Backup conflicting real files to ~/.dotfiles-backup/<timestamp>/
- stow --no-folding -d $DOTFILES_DIR -t $HOME <package>
- Skip packages the user has excluded (via env var or config)
Phase 6: System Preferences (opt-in)
7. macOS defaults (only if explicitly requested or --with-defaults flag)
- Source os-macos/defaults.sh
- killall affected apps at the end (Dock, Finder, SystemUIServer)
Phase 7: Post-install
8. Change default shell to brew zsh (if not already)
- Ensure brew's zsh is in /etc/shells: sudo sh -c 'echo $(brew --prefix)/bin/zsh >> /etc/shells'
- Then: chsh -s $(brew --prefix)/bin/zsh
9. Print next-steps checklist
Backup Strategy
Before stowing, handle existing non-symlink files:
backup_if_needed() {
local target="$1"
if [ -L "$target" ]; then
# Existing symlink (possibly from another dotfiles manager)
local link_target="$(readlink "$target")"
echo " Replacing symlink: $target → $link_target"
rm "$target"
elif [ -e "$target" ]; then
local rel_path="${target#$HOME/}"
local backup_path="$BACKUP_DIR/$rel_path"
mkdir -p "$(dirname "$backup_path")"
mv "$target" "$backup_path"
echo " Backed up: $target → $backup_path"
fi
}
Idempotency
Every operation is safe to re-run:
- Xcode CLT: checks before installing
- Homebrew: checks before installing
- brew bundle: only installs missing packages
- Stow: re-stowing already-linked files is a no-op
- Defaults:
defaults writeis idempotent
Error Handling
# Critical (stop): Can't install Homebrew, stow has unresolvable conflicts
# Non-critical (warn + continue): Individual brew packages, missing optional tools
# Since setup.sh uses set -euo pipefail, non-fatal sections must trap errors:
# brew bundle install ... || echo "⚠ Some packages failed (continuing)"
# stow ... || echo "⚠ Stow failed for $package (continuing)"
Security Rules
NEVER track or commit (unless encrypted with age — see Encrypted Secrets section):
- Private keys (SSH, GPG, TLS)
- Auth tokens, API keys, credentials
.envfiles, environment secrets- Session data, cookies, browser profiles
- Keyrings and trust databases
- Files matching:
id_*,*.key,*.pem,*.p12,private-keys-v1.d/,*.kbx,trustdb.gpg,.env* - Files containing token prefixes:
ghp_,gho_,ghs_,github_pat_,sk-,npm_,xoxb-,xoxp-,xoxe-,AKIA,AIza,glpat-,pypi-,sk_live_,pk_live_,rk_live_,SG.,dop_v1_ - Files containing:
token,secret,passwordvalues - Files containing
-----BEGIN.*PRIVATE KEY-----headers
For files with mixed content (safe config + embedded secrets):
- Suggest splitting into tracked config + gitignored
.localoverride - Or template with placeholders and a warning comment:
token = <YOUR_TOKEN_HERE> # REPLACE with actual token
Always run a secret scan before git add — grep for token-like patterns in staged files.
Encrypted Secrets (Optional)
This section is entirely optional. Users who don't want encryption skip it — the skill works exactly as before. Present this as a choice during Workflow A (Step 3).
Tool: age
age provides simple, modern file encryption using scrypt KDF and ChaCha20-Poly1305 (AEAD). Designed by Filippo Valsorda (Go security lead).
Install: brew install age
Security: scrypt KDF (adjustable work factor) → ChaCha20-Poly1305 authenticated encryption
How It Works with Stow
Unlike transparent git encryption, age uses an explicit encrypt/decrypt model:
- Encrypted files have
.ageextension and ARE committed to git - Decrypted counterparts are gitignored
setup.shfinds.agefiles, prompts for password, decrypts them (strips.ageextension), then stows
ssh/
.ssh/
config # plaintext (stowed normally)
id_ed25519.age # encrypted (committed to git)
id_ed25519 # decrypted (gitignored, created by setup.sh)
Commands
# Encrypt a file
AGE_PASSPHRASE="pw" age -e -j batchpass -o file.age file
# Decrypt a file
AGE_PASSPHRASE="pw" age -d -j batchpass -o file file.age
Always use -j batchpass with the AGE_PASSPHRASE env var — never age -p (which is interactive/TTY only and unsuitable for scripting). The batchpass plugin ships with brew install age.
What This Enables
- SSH private keys CAN be tracked (as
.agefiles) - GPG secret keys CAN be tracked (as
.agefiles) .npmrcwith auth tokens CAN be tracked (as.agefiles)- Any sensitive file can be encrypted and committed alongside its plaintext config
If using encrypted secrets, add the decrypted filenames to .gitignore (e.g., id_ed25519, private-keys-v1.d/). The .age versions stay tracked.
Workflow Integration
- Create (Workflow A): Ask user if they want to encrypt secrets. If yes, encrypt selected files with
age -e -j batchpass, add.ageextension. Add decrypted filenames to.gitignore. Commit.agefiles. - Capture (Workflow B): For files that have
.agecounterparts in the repo, prompt for password, re-encrypt current versions:AGE_PASSPHRASE="pw" age -e -j batchpass -o file.age file. Commit updated.agefiles. - Apply (Workflow C / setup.sh): After
brew bundle(soageis installed), find all.agefiles, prompt for password once, decrypt each to its non-.agecounterpart (see setup.sh integration below). Then stow as normal — stow sees the decrypted files.
setup.sh Integration
Add an age decrypt phase between brew bundle (Phase 2) and stow (Phase 4). Only runs if .age files exist in the repo:
# Phase 3: Decrypt secrets (if any)
age_files=$(find "$DOTFILES_DIR" -name '*.age' -not -path '*/.git/*')
if [ -n "$age_files" ]; then
echo "Encrypted secrets found. Enter master passphrase to decrypt."
read -sp "Passphrase: " AGE_PASSPHRASE; echo
export AGE_PASSPHRASE
for f in $age_files; do
age -d -j batchpass -o "${f%.age}" "$f"
echo " Decrypted: ${f%.age}"
done
unset AGE_PASSPHRASE
fi
Caveats
- Password strength matters — recommend a strong passphrase, store it in a password manager
- Unrecoverable if lost — if the password is lost, encrypted files cannot be recovered
- Non-deterministic encryption — each encryption produces different ciphertext. This is normal (age uses a random salt). Only re-encrypt when content actually changes, otherwise git sees a diff on every encryption even if the plaintext is identical.
- Always use
-j batchpass—age -pprompts interactively on TTY and cannot be scripted. The batchpass plugin readsAGE_PASSPHRASEfrom the environment. - Unset passphrase after use — always
unset AGE_PASSPHRASEwhen done to avoid leaking the passphrase to child processes
.gitignore Template
# Secrets & keys
id_*
*.key
*.pem
*.p12
*.pfx
private-keys-v1.d/
*.kbx
trustdb.gpg
openpgp-revocs.d/
secring.gpg
S.gpg-agent*
.npmrc
.netrc
.env*
known_hosts*
authorized_keys
random_seed
credentials
# Decrypted secrets (age)
# When using age encryption, the .age files are committed and
# decrypted counterparts are gitignored. Add specific filenames here:
# id_ed25519
# id_ed25519.pub
# private-keys-v1.d/*
# Machine-specific overrides (e.g., .zshrc.local, .gitconfig.local)
*.local
.local/
# Backups
.dotfiles-backup/
# OS artifacts
.DS_Store
.stow-local-ignore Template
\.git
\.gitignore
\.stow-local-ignore
^README\.md
^setup\.sh
^os-.*
^LICENSE