Modular, DAG-based Mac setup. One command to install everything, with parallel execution and a rich terminal UI.
curl -fsSL https://raw.githubusercontent.com/tomagranate/primer/main/setup.sh | shPreview what would happen without making changes:
curl -fsSL https://raw.githubusercontent.com/tomagranate/primer/main/setup.sh | sh -s -- --dry-runAfter the initial setup, primer is installed to ~/bin/:
primer <command> [options]update- install/update all enabled modules (idempotent)status- check install/health status for all enabled moduleshelp- show help text (same as--help/-h)
--dry-run- preview changes without applying them (valid withupdate)--skip-app-store- skip App Store (mas) installs (valid withupdate)--help- show help text-h- show help text
primer update
primer update --dry-run
primer update --skip-app-store
primer status
primer --help
primer -h
primer helpModules run in parallel as a DAG -- each starts as soon as its dependencies are met:
| Module | Depends On | What It Does |
|---|---|---|
| xcode-cli-tools | -- | Installs Xcode Command Line Tools and waits for the installer dialog to be accepted |
| shell-installers | xcode-cli-tools | Installs configured tools from remote shell installers |
| homebrew | xcode-cli-tools | Installs Homebrew and configured formulae |
| homebrew-apps | homebrew | Installs configured Homebrew cask apps |
| mac-app-store | homebrew | Installs configured Mac App Store apps via mas, including Xcode |
| xcode | mac-app-store | Selects full Xcode, runs first launch setup, and installs configured simulator platforms |
| macos | homebrew-apps | Applies macOS defaults and configures the Dock |
| zsh | homebrew | Updates managed section in ~/.zshrc, manages ~/.zimrc, installs Zim |
| starship | homebrew | Deploys starship.toml to ~/.config/ |
| mise | homebrew | Installs language runtimes (Node, Python, Bun) |
| ssh | xcode-cli-tools | Creates an SSH key and configures macOS keychain-backed agent support |
| touchid | -- | Enables Touch ID for sudo |
| scripts | -- | Installs custom scripts to ~/bin/ |
Each module is a self-contained folder that owns its config files, scripts, and install logic. primer.conf is an INI-style config that activates modules and holds their data (brew packages, mise tools, etc.).
├── setup.sh # Bootstrap (curl-able, installs primer CLI)
├── primer.conf # INI config (modules + deps + per-module settings)
├── lib/
│ ├── engine.zsh # Ready-queue DAG executor + INI parser
│ └── ui.zsh # Terminal UI (spinners, boxes, colors, helpers)
├── modules/
│ ├── xcode-cli-tools/
│ │ └── module.zsh
│ ├── xcode/
│ │ └── module.zsh
│ ├── shell-installers/
│ │ └── module.zsh
│ ├── homebrew/
│ │ └── module.zsh # Generates Brewfile from config, runs brew bundle
│ ├── mac-app-store/
│ │ └── module.zsh
│ ├── homebrew-apps/
│ │ └── module.zsh
│ ├── zsh/
│ │ ├── module.zsh
│ │ └── files/ # .zshrc managed block + .zimrc
│ ├── starship/
│ │ ├── module.zsh
│ │ └── files/ # starship.toml
│ ├── mise/
│ │ └── module.zsh # Installs tools from config via mise use --global
│ ├── touchid/
│ │ └── module.zsh
│ └── scripts/
│ ├── module.zsh
│ └── bin/ # rgf, etc.
└── bin/
└── primer # CLI entry point
- Create
modules/<name>/files/with your config files - Write a 5-line
module.zsh:
mod_update() {
deploy_files "$CONFIG_DIR/<name>"
primer::status_msg "configured"
}
mod_status() {
check_files "$CONFIG_DIR/<name>"
}- Add a section to
primer.conf:
[name]
label = Display Name
depends_on = homebrew # optionalWrite mod_update() and mod_status() with whatever logic you need. Use mod_config <key> to read values from primer.conf.
All module settings live in primer.conf. Each [section] activates a module. Remove a section to disable it. Indented lines continue the previous key's value.
[homebrew]
label = Homebrew
depends_on = xcode-cli-tools
taps =
tomagranate/tap
formulae =
mise
starship
fzf
corsa
[shell-installers]
label = Shell installers
depends_on = xcode-cli-tools
installers =
- name: example
url: https://example.com/install.sh
command: example
check: example --version
[homebrew-apps]
label = Mac Apps
depends_on = homebrew
casks =
google-chrome
slack
[mac-app-store]
label = Mac App Store
depends_on = homebrew
mas =
Xcode:497799835
[xcode]
label = Xcode app
depends_on = mac-app-store
app_path = /Applications/Xcode.app
simulator_platforms =
iOS
[mise]
label = Mise languages
depends_on = homebrew
tools =
node:lts
python:3.12
bun:latestInteractive logins are configured in [logins] and run after installation
finishes. *_depends_on names Primer modules that must complete first,
*_requires names commands that must exist, *_status detects whether the
account is already logged in, and *_command starts the login flow.
[logins]
order =
xcode-cli-terms
helium-google
dashlane
github
xcode-cli-terms_label = Xcode CLI terms
xcode-cli-terms_default = yes
xcode-cli-terms_depends_on = xcode-cli-tools
xcode-cli-terms_requires = xcodebuild, sudo
xcode-cli-terms_status = xcodebuild -checkFirstLaunchStatus
xcode-cli-terms_done_detail = accepted
xcode-cli-terms_instruction = Review and accept the Xcode Command Line Tools license.
xcode-cli-terms_command = sudo xcodebuild -license
github_label = GitHub CLI
github_default = yes
github_depends_on = ssh, homebrew
github_requires = gh
github_status = gh auth status
github_command = gh auth login
helium-google_label = Helium Google profile
helium-google_default = yes
helium-google_depends_on = homebrew-apps
helium-google_requires = open
helium-google_instruction = Sign in to your Google or Chrome profile in Helium.
helium-google_command = open -a Helium https://accounts.google.com/
dashlane_label = Dashlane
dashlane_default = yes
dashlane_depends_on = homebrew-apps
dashlane_requires = open
dashlane_instruction = Sign in to Dashlane.
dashlane_command = open -a Helium https://app.dashlane.com/login| What | Where |
|---|---|
| Zsh config | ~/.zshrc (Primer-managed section) |
| Zim modules | ~/.zimrc |
| Starship prompt | ~/.config/starship.toml |
| SSH config | ~/.ssh/config (Primer-managed section) |
| SSH key | ~/.ssh/id_ed25519 |
| Custom scripts | ~/bin/ |
Use a local checkout instead of fetching from GitHub:
PRIMER_LOCAL=/path/to/primer primer update
PRIMER_LOCAL=/path/to/primer primer statusTests use BATS-core. Unit tests live in tests/unit/, module tests are co-located in modules/<name>/tests.bats.
brew install bats-core
git clone --depth 1 https://github.com/bats-core/bats-support.git tests/helpers/bats-support
git clone --depth 1 https://github.com/bats-core/bats-assert.git tests/helpers/bats-assert# Everything (unit + module + dry-run smoke)
bats tests/unit/ tests/dry_run.bats modules/*/tests.bats
# Unit tests only
bats tests/unit/
# Single module
bats modules/starship/tests.bats
# Dry-run smoke test
bats tests/dry_run.batsFor full end-to-end validation on a clean macOS, use Tart. To test the current checkout before pushing, run Tart from this repo root and mount the working tree into the VM:
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest primer-test
tart run --dir="primer:$PWD" primer-testInside the VM:
# The host checkout is mounted here by tart run --dir.
cd "/Volumes/My Shared Files/primer"
# Run against the mounted local checkout, including uncommitted host changes.
PRIMER_LOCAL=$PWD zsh ./bin/primer update
PRIMER_LOCAL=$PWD zsh ./bin/primer statusTo test the published bootstrap flow instead of your local changes:
curl -fsSL https://raw.githubusercontent.com/tomagranate/primer/main/setup.sh | shReset to a clean slate with tart delete primer-test and re-clone.