Bootstrap and maintain a macOS development environment. Recipes handle installation of dotfiles, tools, and configuration. All recipes are idempotent — safe to run multiple times.
Recipes live in recipes/<name>/ and follow a consistent directory structure:
recipes/
├── dotfiles/
│ ├── install # symlinks source/ → $HOME
│ └── teardown # removes those symlinks
├── claude/
│ ├── install # symlinks source/.claude/ → ~/.claude/
│ ├── teardown # removes those symlinks
│ └── docker/ # Containerized Claude Code (C3) — see below
├── homebrews/
│ └── install
├── vim-plugins/
│ └── install
└── ... # all other recipes follow the same pattern
The main installer (bin/install.sh) and teardown script (bin/teardown.sh) source the appropriate recipe file. Recipes that install global system packages (homebrews, rubies, kiex, etc.) do not have teardown scripts — those must be removed manually.
source/ contains the actual dotfiles. The dotfiles recipe creates symlinks from $HOME to each file in source/, making configuration easy to track and remove without touching the source.
Conflict handling is interactive — you'll be prompted to overwrite or skip any file that already exists at the target path.
Special cases:
.ssh/— the directory is created with correct permissions (700); only.ssh/configis symlinked (private keys are never committed).claude/— managed by theclauderecipe (see below)
~/.claude/ is Claude Code's live home directory — a real directory that
holds runtime state (sessions, history, caches, hydrated plugins). We version
only a minimal, curated subset and symlink it in; everything else stays local.
source/.claude/ holds exactly that curated set:
source/.claude/
├── CLAUDE.md # Global user instructions
├── settings.json # Claude Code settings (no secrets)
├── statusline-command.sh # Custom statusline (referenced by settings.json)
└── skills/ # Custom slash-command skills
├── patch-bundler/
└── patch-bundler-poetry/
A .gitignore allowlist enforces this: everything under source/.claude/ is
ignored except the files above, so runtime state (sessions/, history.jsonl,
projects/, caches, and secrets like mcp-needs-auth-cache.json) can never be
committed.
Plugins and marketplaces are not tracked as state files. They're declared
in settings.json via enabledPlugins and extraKnownMarketplaces; Claude
rehydrates the actual plugin code (and the regenerated plugins/*.json state)
on first run. This keeps the footprint to a single declarative source of truth.
settings.json intentionally omits AWS_BEARER_TOKEN_BEDROCK and any other
secrets. Set those in your shell environment or a local .env file — never
committed. Its statusLine.command references ~/.claude/statusline-command.sh
by a portable path (no hardcoded home), and the statusline needs jq (installed
by the homebrews recipe).
The claude recipe symlinks each curated file into ~/.claude/ and offers to
install the Claude Code CLI via Anthropic's native installer. It requires
~/.claude/ to be a real directory — if it's a symlink (a legacy whole-dir
symlink into the repo), the recipe refuses to run rather than write runtime
state into version control.
# Install everything
bin/install.sh all
# Install a specific recipe
bin/install.sh dotfiles
bin/install.sh claude
bin/install.sh vim-plugins
bin/install.sh homebrews
bin/install.sh postgresql
bin/install.sh rubies
bin/install.sh kiex
bin/install.sh python
bin/install.sh nvm
bin/install.sh bats
bin/install.sh zshUse --target to point symlinks at a directory other than $HOME. Useful for verifying provisioning before touching your live environment:
mkdir /tmp/test-home
bin/install.sh --target /tmp/test-home dotfiles
bin/install.sh --target /tmp/test-home claudeRemoves symlinks created by a recipe. Only removes symlinks — real files are never touched.
# Remove all managed symlinks
bin/teardown.sh all
# Remove symlinks for a specific recipe
bin/teardown.sh dotfiles
bin/teardown.sh claude
# Remove installed artifacts for project-local recipes
bin/teardown.sh vim-plugins # removes cloned plugin dirs from source/.vim/
bin/teardown.sh bats # removes cloned bats from source/.bats/
bin/teardown.sh python # removes base-dev and base-ml conda environments
bin/teardown.sh nvm # removes nvm and all installed Node.js versions
# Teardown into a non-$HOME target (mirrors --target from install)
bin/teardown.sh --target /tmp/test-home dotfilesRecipes that install global packages (homebrews, rubies, postgresql, etc.) print a message instead of attempting teardown.
The postgresql recipe bootstraps a local PostgreSQL 16 instance for development:
bin/install.sh postgresqlWhat it does:
- Ensures PostgreSQL@16 is running via Homebrew services
- Creates
postgresrole with passwordmysecretpassword(standard dev password) - Offers to clean up old PostgreSQL installations (14, 17)
Requirements:
- Must run
bin/install.sh homebrewsfirst (installs postgresql@16)
Connection details:
- Host:
localhost - Port:
5432 - Role:
postgres - Password:
mysecretpassword - Database:
postgres(default)
Use psql -U postgres -d postgres to connect (will prompt for password).
bin/diskclean reclaims disk space from the caches and build artifacts that development tools accumulate. It's recipe-based like the installer, but the recipes are cleanup scripts that scan rather than install.
# Scan everything, then pick what to delete
bin/diskclean all
# Scan a single category
bin/diskclean library # ~/Library: iOS device backups + app caches
bin/diskclean simulators # Xcode DerivedData, iOS DeviceSupport, simulators, Android AVDs
bin/diskclean homebrews # Homebrew download cache, stale formulae, logs
bin/diskclean docker # images, stopped containers, build cache, dangling volumes
bin/diskclean npm # npm/yarn/pnpm caches + project node_modules
bin/diskclean python # conda/pip/poetry caches, pyenv versions, .venv, __pycache__
bin/diskclean rubies # rbenv versions, gem/bundler caches, project .bundle
bin/diskclean build # .build, Rust target/, Gradle, Maven, .next/.nuxt, dist/, _build
bin/diskclean cocoapods # CocoaPods spec repos, cache, project Pods/How it works: each recipe registers candidate artifacts (with size and last-modified date), then everything is shown in an fzf multi-select. You pick items, review a confirmation manifest, and only then are they deleted. Nothing is removed without explicit confirmation, and tool-native reclaim (brew cleanup, docker rmi, pod cache clean, etc.) is preferred over raw rm where available.
Notes:
- Requires
fzffor the interactive picker (bin/install.sh homebrewsinstalls it). - Recipes that scan for project artifacts (
npm,python,rubies,build,cocoapods, andall) prompt for a base directory to scan, defaulting to~/dev. Large project trees can take a few minutes to size. library,simulators,homebrews, anddockertarget~/Libraryand system caches — they don't walk your project tree, so they return quickly.iOS DeviceSupportkeeps the current (latest) version and only offers older ones;libraryflags iOS device backups as DATA since deleting one loses that backup.- Docker space is reclaimed via the daemon, but macOS doesn't auto-shrink the
Docker.rawdisk image — reclaim that via Docker Desktop → Settings → Resources → Disk.
Two scripts manage snapshots of ~/.claude/:
# Create a snapshot (excludes large/ephemeral dirs: local/, debug/, projects/)
bin/claude-backup
# Create a full snapshot (includes history.jsonl, projects/)
bin/claude-backup --full
# Write snapshot to a custom directory
bin/claude-backup --dir /path/to/backups
# Restore most recent snapshot
bin/claude-restore
# Restore a specific snapshot
bin/claude-restore claude-20260101-120000
# Preview what would be restored without extracting
bin/claude-restore --dry-runSnapshots are written to ~/.claude-snapshots/ by default. Override with the CLAUDE_SNAPSHOTS_DIR environment variable.
Shell aliases (available after installing dotfiles):
cb→claude-backupcr→claude-restore
Run Claude Code in an ephemeral Docker container against an isolated git worktree. Useful for sandboxed sessions that don't touch your working tree.
Infrastructure lives in recipes/claude/docker/:
recipes/claude/docker/
├── base/Dockerfile # Ubuntu 24.04 + Node LTS + Claude Code CLI
├── python/Dockerfile # Extends base + Python 3.12 + Poetry
├── node/Dockerfile # Extends base + Yarn
├── docker-compose.yml # Service definitions
└── .env.example # Template for local secrets (never committed)
bin/claude-worktree <repo-path> [platform] [branch-name]# Start a base session against a repo
bin/claude-worktree ~/dev/projects/my-app
# Start a Python session with a specific branch name
bin/claude-worktree ~/dev/projects/my-app python fix/dependency-updatesThe script:
- Creates a git worktree at
<repo>/.worktrees/<branch> - Launches the container with the worktree mounted at
/workspace - Mounts
~/.claude/config read-only into the container - Passes
AWS_BEARER_TOKEN_BEDROCKfrom your host environment (never baked into the image) - On exit, prompts whether to remove the worktree
The bearer token is passed into the container at runtime via environment variable — never stored in the image or committed. Source it from the Keychain (see Secrets management) so it's never typed in plaintext:
export AWS_BEARER_TOKEN_BEDROCK="$(security find-generic-password -s claude-bedrock-token -w)"
bin/claude-worktree ~/dev/projects/my-appdocker build -t claude-base -f recipes/claude/docker/base/Dockerfile .
docker build -t claude-python -f recipes/claude/docker/python/Dockerfile .
docker build -t claude-node -f recipes/claude/docker/node/Dockerfile .Secrets are never stored in plaintext in committed files or shell history. They live encrypted at rest in the macOS Keychain and are resolved at runtime via command substitution — committed files contain only the lookup, never the value.
Store a secret. Always use pbpaste for long values — the interactive -w prompt silently truncates long pastes (see gotcha below). tr -d '[:space:]' strips any stray whitespace the clipboard carried (safe, since base64 keys contain none):
security add-generic-password -a "$USER" -s <service-name> -U -w "$(pbpaste | tr -d '[:space:]')"Verify without printing the secret:
security find-generic-password -s <service-name> -w >/dev/null && echo "stored OK"
printf '%s' "$(security find-generic-password -s <service-name> -w)" | wc -c # confirm expected lengthReference it where needed — committed files hold only this lookup:
export SOME_TOKEN="$(security find-generic-password -s <service-name> -w)"Rotate by revoking the old credential at its source, then re-running the store command.
claude-chartpro() (source/.zshrc) loads the Bedrock API key from Keychain only when invoked, so personal Claude sessions never carry it:
export AWS_BEARER_TOKEN_BEDROCK="$(security find-generic-password -s claude-bedrock-token -w)"Store the key under service name claude-bedrock-token using the pbpaste method above.
security add-generic-password … -w (with -w last) prompts for hidden input, but the terminal can silently drop characters from a long paste. A truncated Bedrock key (128 chars instead of the expected ~132) is accepted into Keychain yet rejected by AWS as invalid — a confusing failure. Always store long secrets via pbpaste, then verify the length.
After running the dotfiles recipe, generate SSH keys for each GitHub profile:
ssh-keygen -t ed25519 -f ~/.ssh/github.com-profile1 -C "profile1@example.com"
cat ~/.ssh/github.com-profile1.pub # add to GitHub accountThe SSH config is symlinked from source/.ssh/config. Private keys are generated locally and never committed.