Stow-based dotfiles with a full-bootstrap installer for macOS and Linux.
git clone https://github.com/PortableProgrammer/dotfiles.git ~/dotfiles
cd ~/dotfilesThen follow FIRST-RUN.md from the top. It walks the full setup linearly: pre-install gates (App Store sign-in, Xcode CLT, Terminal permissions), running ./install.sh itself, then post-install identity bootstrap (1Password, GitHub CLI, container runtime, macOS permissions, network, per-project handoff).
install.sh is idempotent — safe to re-run on an already-configured machine, and safe to re-run after a partial failure to pick up where it left off.
Note
Forking this repo? The mac/ package is personalized:
- mac/.gitconfig hardcodes my git identity (name, email, signing key path).
- install.sh fetches my SSH signing public key from GitHub's API.
Swap these for your own values before stowing, or you'll try to commit as me. The common/ package is portable and doesn't need changes.
The installer handles everything a fresh machine needs:
- Package manager & dependencies — Homebrew (macOS) or apt (Linux), including all formulae, casks, and CLI tools
- Shell framework — Oh-My-Zsh and Powerlevel10k (skipped if already installed)
- SSH signing key (macOS) — Fetches your SSH signing public key from GitHub's API for git commit signing
- Stow dotfiles — Symlinks
common(always) andmac(macOS only) packages into$HOME - Platform extras (macOS) — Installs fonts, imports Terminal.app theme, configures Dock, optionally applies system defaults
- Shell switch — Sets zsh as the default shell if it isn't already
dotfiles/
├── common/ # stow package: all platforms
│ ├── .bashrc # Bash initialization (fallback shell)
│ ├── .bash_aliases # Bash aliases
│ ├── .zshrc # Zsh entry point (loads .zshrc.d/*.sh)
│ ├── .zshenv # Zsh environment (EDITOR=nano)
│ ├── .p10k.zsh # Powerlevel10k theme config
│ ├── .screenrc # GNU Screen settings
│ ├── .nanorc # Nano editor config
│ ├── .hushlogin # Suppress login banners
│ ├── .zshrc.d/ # Modular zsh scripts (loaded in order)
│ │ ├── 010_screen.sh # Auto-attach screen on SSH
│ │ ├── 011_neofetch.sh # Run fastfetch on login
│ │ ├── 100_main.sh # Oh-My-Zsh + Powerlevel10k setup
│ │ ├── 110_aliases.sh # Shell aliases
│ │ ├── 200_bindings.sh # HOME/END key bindings
│ │ └── 900_p10k.sh # Load Powerlevel10k config
│ └── .config/
│ └── fastfetch/
│ └── config.jsonc # Fastfetch display config
├── mac/ # stow package: macOS only
│ ├── .claude/ # Claude Code global config
│ │ ├── CLAUDE.md # Global AI assistant preferences
│ │ ├── settings.json # Statusline configuration
│ │ └── statusline-command.sh # Custom statusline script
│ ├── .gitconfig # Git identity, signing, and pull config
│ ├── .ssh/
│ │ └── config # SSH config (1Password agent)
│ └── .zshrc.d/
│ ├── 897_brew_drift.sh # Warns when brew install/uninstall diverges from Brewfile
│ ├── 898_mac_env.sh # 1Password SSH agent
│ └── 899_mac_aliases.sh # macOS-specific aliases
├── resources/
│ ├── fonts/ # MesloLGS Nerd Font (4 variants)
│ ├── iStat Menus Settings *.ismp7 # iStat Menus settings backup (manual import)
│ └── Smyck.terminal # Terminal.app color theme
├── scripts/
│ ├── dock.sh # Dock layout configuration (via dockutil)
│ └── macos-defaults.sh # macOS system defaults (~300 settings)
├── bin/
│ ├── brew-drift.sh # Read-only Brewfile drift report (both directions)
│ └── verify-workstation.sh # Post-bootstrap sanity check (FIRST-RUN §12)
├── Brewfile # Homebrew formulae, casks, and App Store apps (macOS)
└── install.sh # Full bootstrap entry point# Symlink common dotfiles
stow -d ~/dotfiles -t ~ common
# Symlink macOS dotfiles (in addition to common)
stow -d ~/dotfiles -t ~ mac
# Remove symlinks
stow -D -d ~/dotfiles -t ~ common
# Dry run (preview what would be linked)
stow -n -v -d ~/dotfiles -t ~ commonShell configuration is split into numbered modules that load in sorted order. Use the numbering scheme to control load order:
| Range | Purpose | Examples |
|---|---|---|
0xx |
Early initialization | Screen auto-attach, system info display |
1xx |
Framework & aliases | Oh-My-Zsh setup, shell aliases |
2xx |
Bindings & completion | Key bindings, tab completion |
8xx |
Platform-specific | Brewfile drift warning, macOS environment, macOS aliases |
9xx |
Final setup | Powerlevel10k prompt |
To add a new module, create a .sh file in the appropriate common/.zshrc.d/ or mac/.zshrc.d/ directory with a number that places it in the right loading order.
| Alias | Platform | Description |
|---|---|---|
la |
All | Detailed file listing |
update |
Linux | Full apt update/upgrade/autoremove/clean |
update |
macOS | Software Update + Homebrew update |
brewup |
macOS | Homebrew + App Store update/upgrade/autoremove/cleanup, then a drift report and a dotfiles push check |
brewup-deep |
macOS | Force auto-updating casks onto the cask's version (--greedy) |
treesize |
All | Interactive disk usage (ncdu) |
neofetch |
All | System info (fastfetch) |
extip |
macOS | External IP address |
localip |
macOS | Local IP address |
- Fonts: MesloLGS Nerd Font is copied to
~/Library/Fonts/for Powerlevel10k glyph rendering - Terminal theme: Smyck.terminal is imported into Terminal.app
- Dock layout:
scripts/dock.shsets Dock contents viadockutil— run after apps are installed - System defaults:
scripts/macos-defaults.shapplies macOS preferences (Finder, Dock, Safari, etc.) — requires a reboot after running
- Ubuntu < 24.10: The fastfetch PPA is added automatically since the package isn't in the default repos
Packages are declared in Brewfile and installed via brew bundle. To add or remove packages, edit the Brewfile and re-run install.sh (or brew bundle --file=~/dotfiles/Brewfile directly).
The Brewfile records intent — what a machine built from this repo should have. That is why nothing auto-writes it, and why a package you are still evaluating is legitimately undeclared.
Prefer a cask over mas when the same binary ships both ways. Homebrew installs reproducibly on VMs and fresh machines where the App Store is unreachable or not signed in; mas requires an Apple ID that has already obtained the app. CotEditor and The Unarchiver both moved from mas to cask on this basis (2026-05-22). Use mas only where there is no cask.
Inline comments are reserved for constraints that bite while editing the file — a conflict, or a non-obvious reason a package exists. Two currently qualify:
microsoft-officebundles OneDrive, so addingcask "onedrive"alongside it conflicts.libsmilooks unused but backs Wireshark's SNMP dissector and homelab MIB work.
Absences are decisions too, and they are invisible in a file that only lists what is present.
| Package | Why it is absent |
|---|---|
claude-code |
The CLI comes from Anthropic's native installer (install.sh Phase 3b → ~/.local/bin/claude), which auto-updates in the background. Both Homebrew casks explicitly do not auto-update, so they would need a manual brew upgrade. If you ever want to switch, the casks are claude-code (stable, ~1 week behind) and claude-code@latest. The claude cask is declared — that is the desktop app, a different thing. |
powershell (cask) |
Removed from homebrew-cask 2026-05-22; only the deprecated powershell@preview remains, whose Gatekeeper check fails and which is disabled from 2026-09-01. Declared as a formula instead. Install manually from PowerShell releases if the formula ever goes too. |
onedrive |
Already bundled by microsoft-office; declaring both conflicts. |
brew does not update the Brewfile when you install something by hand, so the two diverge silently. bin/brew-drift.sh reports every direction the machine and the Brewfile can disagree — read-only, it never installs or removes anything:
./bin/brew-drift.sh| Direction | Meaning | Failure mode |
|---|---|---|
| Installed but undeclared | You brew installed it and never added it |
Lost on the next machine, or deleted by brew bundle cleanup |
| Declared but unsatisfied | The Brewfile names something brew doesn't manage — usually an app installed by hand into /Applications |
Silent — works here, missing on rebuild |
| Stale on disk | Declared, installed, brew says current — but the app bundle is older than the cask | Silent, and invisible to every other brew command |
| App Store outdated | A mas app has an update waiting |
Nothing else surfaces it; brew bundle check calls it "not installed" |
| Orphaned dependencies | Left behind after uninstalling a formula | Slow accretion |
The stale-on-disk check exists because brew's own records can be fiction. brew install --cask --adopt writes a Caskroom entry named for the cask's current version without inspecting the app it adopted, so brew outdated and brew upgrade --greedy compare that record against the cask, find them equal, and skip the app permanently. Verified 2026-08-04: Google Chrome sat nineteen months stale at 132.0.6834.84 while brew recorded 151.0.7922.72. The check reads CFBundleShortVersionString from the bundle instead — the only version here that is observed rather than asserted. Casks with no .app artifact (pkg, binary, prefpane) are listed as unchecked rather than passed over silently.
Only packages installed on request count — dependencies are correctly ignored, so you never declare a package's dep tree. Exits 1 on drift, so it works as a gate; --quiet suppresses output for hook use.
For an app that already exists in /Applications but isn't brew-managed, adopt rather than reinstall:
brew install --cask --adopt <name>mac/.zshrc.d/897_brew_drift.sh wraps brew and speaks up the moment an interactive install or uninstall diverges from the Brewfile:
$ brew install knockknock
[brewfile] knockknock installed but not declared — add: brew "knockknock"
[brewfile] ~/Code/dotfiles/Brewfile — nothing was written; a trial install needs no entry
It never writes the Brewfile. Declaring is a decision about intent, and a package you are still evaluating is legitimately undeclared — the module header records why auto-editing was rejected, and which of the original arguments for that turned out not to hold.
The wrapper only sees what you type interactively; installs from install.sh, Ansible, or any script bypass it entirely. So drift reaches you through three channels, each timed to arrive when it costs the least attention:
| Channel | Fires | Carries |
|---|---|---|
brew wrapper (897) |
The instant you install or uninstall | One line, about the thing you just did |
brewup tail (899) |
Every update run | The full five-section report |
Staleness nag (897) |
Shell start, only when overdue | That the check itself hasn't run |
The report tails brewup rather than greeting you at shell start on purpose: a terminal is opened holding a task, and a report printed there competes with it. brewup is already the housekeeping ritual, so the findings land when package state is what you're thinking about anyway.
That leaves one blind spot — the ritual lapsing — which is the nag's entire job. It stays silent until the check is over BREW_DRIFT_MAX_DAYS old (default 21), then prints one line at most once a day. A check nobody runs reports nothing, which reads exactly like a clean machine.
Silence all of it for a session with BREW_DRIFT_WARN=0.
brewup ends by asking whether the remote has your dotfiles, which is what decides how much survives the machine dying. Unpushed commits and uncommitted files are counted separately and worded differently: a commit is a declaration that something is done, while a dirty tree is often work in progress — or a file that dirtied itself, which is what mac/.claude/settings.json does on every settings toggle. A branch with no upstream gets its own message, because those commits aren't merely unpushed, they're unreachable from anywhere but that disk.
This rides brewup rather than taking a second shell-start slot. The staleness nag already guarantees the ritual can't lapse quietly, so unpushed work can't go unnoticed for longer than BREW_DRIFT_MAX_DAYS — which is precisely why a second nag would have been redundant rather than safer. Nothing auto-pushes: this repo is public, so an accidentally committed secret would publish with no window to amend.
| Category | Packages |
|---|---|
| Shell & dotfiles | fastfetch, fd, git, nano, ncdu, stow, zsh |
| Homelab / infra | ansible, ansible-lint, fluxcd/tap/flux, helm, k9s, kubernetes-cli, powershell, pre-commit, sops, teleport |
| Development | gh, shellcheck, swiftlint |
| Utilities | dockutil, gnupg, httpie, jq, libsmi, mas, mole, nmap, socat, watch, yq |
| Category | Applications |
|---|---|
| Browsers | google-chrome |
| Communication | slack |
| Development | claude, coteditor, docker-desktop, royal-tsx, visual-studio-code@insiders, wireshark-app |
| Productivity | microsoft-office, transmit |
| Security | 1password, 1password-cli |
| System monitoring | istat-menus |
| System utilities | appcleaner, jordanbaird-ice, logi-options+, monitorcontrol, qlmarkdown, the-unarchiver |
| App | ID |
|---|---|
| 1Password for Safari | 1569813296 |
| AdGuard for Safari | 1440147259 |
| Codye | 1516894961 |
| DaisyDisk | 411643860 |
| DevCleaner | 1388020431 |
| Discovery | 1381004916 |
| Magnet | 441258766 |
| TestFlight | 899247664 |
| Userscripts | 1463298887 |
| Xcode | 497799835 |
Note: Mac App Store apps require being signed in and having previously obtained the app (including free apps).
| Thing | Why |
|---|---|
| Claude Code CLI | Installed by Anthropic's native installer in install.sh Phase 3b, which auto-updates in the background. The claude-code cask does not auto-update. |
| WiFiman | An iOS app run on Apple Silicon via "Designed for iPad". mas can neither list nor install iOS apps, so a mas line would be permanently unsatisfiable. Install from the App Store's "iPhone & iPad Apps" tab. |
| PowerShell cask | Removed from homebrew-cask upstream; the powershell formula is used instead. |
After install.sh completes, the following require manual configuration:
- 1Password — Sign in and configure the desktop app. To enable the
opCLI (used for Ansible vault automation and similar workflows), also turn on "Integrate with 1Password CLI" in 1Password > Settings > Developer. The Safari extension is installed separately via the App Store. - iStat Menus — Enter your license key, then import settings from
resources/iStat Menus Settings *.ismp7via iStat Menus > Preferences > Import - TestFlight apps — Open TestFlight and install any beta apps (e.g. UniFi) that aren't available through the App Store or Homebrew
- Open Google Chrome Profile shortcut — Open Shortcuts.app, import the Open Google Chrome Profile shortcut, then right-click it > Add to Dock. After the app appears in
~/Applications/, re-run./scripts/dock.shto place it in the correct Dock position. - macOS defaults — If you skipped the prompt during install, run
./scripts/macos-defaults.shmanually and reboot - Dock layout — If you skipped the prompt during install, run
./scripts/dock.shmanually after all apps are installed
The installer handles all dependencies automatically. If you prefer manual setup, you'll need:
stow— GNU Stow for symlink managementzsh— Z shellgit— For cloning Oh-My-Zsh and Powerlevel10kfd/fd-find— Used by.zshrcto discover.zshrc.d/modulesfastfetch— System info display on login