Skip to content

Repository files navigation

Dotfiles

Stow-based dotfiles with a full-bootstrap installer for macOS and Linux.

Quick Start

git clone https://github.com/PortableProgrammer/dotfiles.git ~/dotfiles
cd~/dotfiles

Then 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.

What It Does

The installer handles everything a fresh machine needs:

  1. Package manager & dependencies — Homebrew (macOS) or apt (Linux), including all formulae, casks, and CLI tools
  2. Shell framework — Oh-My-Zsh and Powerlevel10k (skipped if already installed)
  3. SSH signing key (macOS) — Fetches your SSH signing public key from GitHub's API for git commit signing
  4. Stow dotfiles — Symlinks common (always) and mac (macOS only) packages into $HOME
  5. Platform extras (macOS) — Installs fonts, imports Terminal.app theme, configures Dock, optionally applies system defaults
  6. Shell switch — Sets zsh as the default shell if it isn't already

Directory Structure

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

Manual Usage

# 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 ~ common

Customization

.zshrc.d Modules

Shell configuration is split into numbered modules that load in sorted order. Use the numbering scheme to control load order:

RangePurposeExamples
0xxEarly initializationScreen auto-attach, system info display
1xxFramework & aliasesOh-My-Zsh setup, shell aliases
2xxBindings & completionKey bindings, tab completion
8xxPlatform-specificBrewfile drift warning, macOS environment, macOS aliases
9xxFinal setupPowerlevel10k 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.

Key Aliases

AliasPlatformDescription
laAllDetailed file listing
updateLinuxFull apt update/upgrade/autoremove/clean
updatemacOSSoftware Update + Homebrew update
brewupmacOSHomebrew + App Store update/upgrade/autoremove/cleanup, then a drift report and a dotfiles push check
brewup-deepmacOSForce auto-updating casks onto the cask's version (--greedy)
treesizeAllInteractive disk usage (ncdu)
neofetchAllSystem info (fastfetch)
extipmacOSExternal IP address
localipmacOSLocal IP address

Platform Notes

macOS

  • 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.sh sets Dock contents via dockutil — run after apps are installed
  • System defaults: scripts/macos-defaults.sh applies macOS preferences (Finder, Dock, Safari, etc.) — requires a reboot after running

Linux

  • Ubuntu < 24.10: The fastfetch PPA is added automatically since the package isn't in the default repos

Homebrew Packages (macOS)

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).

Brewfile conventions

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-office bundles OneDrive, so adding cask "onedrive" alongside it conflicts.
  • libsmi looks unused but backs Wireshark's SNMP dissector and homelab MIB work.

Deliberately not in the Brewfile

Absences are decisions too, and they are invisible in a file that only lists what is present.

PackageWhy it is absent
claude-codeThe 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.
onedriveAlready bundled by microsoft-office; declaring both conflicts.

Checking for drift

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
DirectionMeaningFailure mode
Installed but undeclaredYou brew installed it and never added itLost on the next machine, or deleted by brew bundle cleanup
Declared but unsatisfiedThe Brewfile names something brew doesn't manage — usually an app installed by hand into /ApplicationsSilent — works here, missing on rebuild
Stale on diskDeclared, installed, brew says current — but the app bundle is older than the caskSilent, and invisible to every other brew command
App Store outdatedA mas app has an update waitingNothing else surfaces it; brew bundle check calls it "not installed"
Orphaned dependenciesLeft behind after uninstalling a formulaSlow 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>

Drift warnings at the keyboard

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:

ChannelFiresCarries
brew wrapper (897)The instant you install or uninstallOne line, about the thing you just did
brewup tail (899)Every update runThe full five-section report
Staleness nag (897)Shell start, only when overdueThat 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.

Is any of this actually replicated?

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.

Formulae

CategoryPackages
Shell & dotfilesfastfetch, fd, git, nano, ncdu, stow, zsh
Homelab / infraansible, ansible-lint, fluxcd/tap/flux, helm, k9s, kubernetes-cli, powershell, pre-commit, sops, teleport
Developmentgh, shellcheck, swiftlint
Utilitiesdockutil, gnupg, httpie, jq, libsmi, mas, mole, nmap, socat, watch, yq

Casks

CategoryApplications
Browsersgoogle-chrome
Communicationslack
Developmentclaude, coteditor, docker-desktop, royal-tsx, visual-studio-code@insiders, wireshark-app
Productivitymicrosoft-office, transmit
Security1password, 1password-cli
System monitoringistat-menus
System utilitiesappcleaner, jordanbaird-ice, logi-options+, monitorcontrol, qlmarkdown, the-unarchiver

Mac App Store (via mas)

AppID
1Password for Safari1569813296
AdGuard for Safari1440147259
Codye1516894961
DaisyDisk411643860
DevCleaner1388020431
Discovery1381004916
Magnet441258766
TestFlight899247664
Userscripts1463298887
Xcode497799835

Note: Mac App Store apps require being signed in and having previously obtained the app (including free apps).

Deliberately not in the Brewfile

ThingWhy
Claude Code CLIInstalled by Anthropic's native installer in install.sh Phase 3b, which auto-updates in the background. The claude-code cask does not auto-update.
WiFimanAn 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 caskRemoved from homebrew-cask upstream; the powershellformula is used instead.

Post-Install Manual Steps

After install.sh completes, the following require manual configuration:

  1. 1Password — Sign in and configure the desktop app. To enable the op CLI (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.
  2. iStat Menus — Enter your license key, then import settings from resources/iStat Menus Settings *.ismp7 via iStat Menus > Preferences > Import
  3. TestFlight apps — Open TestFlight and install any beta apps (e.g. UniFi) that aren't available through the App Store or Homebrew
  4. 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.sh to place it in the correct Dock position.
  5. macOS defaults — If you skipped the prompt during install, run ./scripts/macos-defaults.sh manually and reboot
  6. Dock layout — If you skipped the prompt during install, run ./scripts/dock.sh manually after all apps are installed

Prerequisites

The installer handles all dependencies automatically. If you prefer manual setup, you'll need:

  • stow — GNU Stow for symlink management
  • zsh — Z shell
  • git — For cloning Oh-My-Zsh and Powerlevel10k
  • fd / fd-find — Used by .zshrc to discover .zshrc.d/ modules
  • fastfetch — System info display on login

About

Linux and macOS dotfiles and bootstrap installer

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Contributors

Languages