Modular, DAG-based machine setup for macOS, Ubuntu VPSs, and Ubuntu desktops. One command to install everything, with parallel execution and a rich terminal UI.
curl -fsSL https://raw.githubusercontent.com/tomagranate/primer/master/setup.sh | shPreview what would happen without making changes:
curl -fsSL https://raw.githubusercontent.com/tomagranate/primer/master/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 <module>- skip a module by name; repeatable (valid withupdate)--only <module>- run only one module; repeatable (valid withupdate)--profile <name>- force a profile; any name with a file inconfigs/profiles/, such asmac,linux-vps, orubuntu-desktop--tui- force alternate-screen terminal UI (valid withupdate)--log- force plain log output--help- show help text-h- show help text
primer update
primer update --dry-run
primer update --skip mac-app-store
primer update --profile linux-vps
primer update --profile ubuntu-desktop
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 |
|---|---|---|
| apt | -- | Installs configured Debian/Ubuntu packages for VPS profiles |
| flatpak | apt | Installs explicitly configured Flatpak apps |
| helium-browser | apt | Installs Helium Browser from the official Linux apt repository |
| github-cli | apt | Installs GitHub CLI from GitHub's official apt repository |
| npm-global | mise | Installs configured global npm CLIs |
| managed-settings | shell-installers/homebrew-apps | Applies configured JSON/TOML user settings, including AI CLI permission defaults |
| login-shell | zsh | Changes the user's login shell to zsh when possible |
| 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/ |
| agents | homebrew / apt + github login | Installs agents CLI, clones/pulls private agents-home into ~/.agents, runs agents sync |
| 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 |
| git | -- | Configures global Git CLI defaults and installs Git helper scripts to ~/bin/ |
Each module is a self-contained folder that owns its config files, scripts, and install logic. Profile config is split into configs/common.conf plus configs/profiles/<profile>.conf. Primer loads the common file first, then the profile file. A profile file holds only the keys that differ from the common file.
The engine is split by topic. lib/engine.zsh sources the other lib/ files, then defines the two public entry points. Source lib/engine.zsh to get the whole engine.
├── setup.sh # Bootstrap (curl-able, installs primer CLI)
├── configs/
│ ├── common.conf # Shared user-level config
│ └── profiles/ # mac, linux-vps, ubuntu-desktop fragments
├── lib/
│ ├── engine.zsh # Facade: loads the parts below, holds run_update/run_status
│ ├── config.zsh # Global registries + INI config parser
│ ├── dag.zsh # Dependency checks, filters, module lifecycle
│ ├── logins.zsh # Interactive login gates, pickers, reports
│ ├── render.zsh # Frame rendering, TUI drawing, run report
│ └── 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
│ ├── agents/
│ │ └── module.zsh # agents CLI + private agents-home (~/.agents)
│ ├── mise/
│ │ └── module.zsh # Installs tools from config via mise use --global
│ ├── touchid/
│ │ └── module.zsh
│ └── git/
│ ├── module.zsh
│ └── bin/ # git-clean, git-uncommit, 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
configs/common.confor a profile inconfigs/profiles/:
[name]
label = Display Name
depends_on = homebrew # optional module deps
depends_on_logins = github # optional login deps
needs_sudo = true # optional; ask for sudo before the runSet needs_sudo = true when the module runs sudo. Primer then asks for the
password once, before it starts any module. Primer also sets this flag for you
when a config value of the module contains a privileged: true line, such as a
privileged entry in installers.
Write mod_update() and mod_status() with whatever logic you need. Use mod_config <key> to read values from the active profile config.
A profile is a config file in configs/profiles/. Primer accepts any profile
name that has a configs/profiles/<name>.conf file. Add a file to add a
profile. Primer ships three profiles.
Primer auto-detects the profile when it can:
macon macOSlinux-vpson Debian/Ubuntu without a desktop sessionubuntu-desktopon Ubuntu with a desktop session
For ambiguous Linux machines, interactive runs prompt and default to linux-vps. Non-interactive runs should pass --profile or set PRIMER_PROFILE.
primer update --profile linux-vps
PRIMER_PROFILE=ubuntu-desktop primer statusLinux profiles install Tailscale through a dedicated tailscale module using Tailscale's official Linux installer, because the tailscale package is not part of Ubuntu's default apt repositories. They also install GitHub CLI through the github-cli module using GitHub's official apt repository, because the Ubuntu gh package can lag current CLI features.
Module settings live in configs/common.conf and configs/profiles/*.conf. Each [section] activates a module. Remove a section from the selected profile/common config 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
needs_sudo = true
app_path = /Applications/Xcode.app
simulator_platforms =
iOS
[mise]
label = Mise languages
depends_on = homebrew
tools =
node:lts
python:3.12
bun:latest
[npm-global]
label = Global npm CLIs
depends_on = mise
packages =
- name: t3
package: t3@latest
command: t3
check: t3 --version
[git]
label = Git CLI
settings =
user.name:Your Name
user.email:you@example.com
user.useConfigOnly:true
pull.rebase:false
init.defaultBranch:master
push.default:simple
push.autoSetupRemote:true
fetch.prune:true
merge.conflictStyle:zdiff3
diff.algorithm:histogramInteractive logins are configured in [logins]. Logins that modules list in
depends_on_logins run as soon as their module deps finish, so later modules
can use the account. Other logins 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.
Linux profiles also use this flow for Tailscale. After the Tailscale module
installs the client, Primer runs sudo tailscale up when the machine is not
connected and then sudo tailscale set --operator="$USER" so local tools such
as T3 Code can configure Tailscale Serve without requiring sudo.
[logins]
order =
github
github_label = GitHub CLI
github_default = yes
github_depends_on = ssh, git, homebrew
github_requires = gh
github_status =
gh auth status --hostname github.com &&
test "$(gh config get git_protocol --host github.com 2>/dev/null)" = ssh &&
key_body="$(awk 'NF >= 2 { print $2; exit }' "$HOME/.ssh/id_ed25519.pub")" &&
gh ssh-key list | grep -F "$key_body"
github_command =
(gh auth status --hostname github.com >/dev/null 2>&1 && gh auth refresh --hostname github.com --scopes admin:public_key || gh auth login --hostname github.com --git-protocol ssh --scopes admin:public_key) &&
gh config set git_protocol ssh --host github.com &&
key_body="$(awk 'NF >= 2 { print $2; exit }' "$HOME/.ssh/id_ed25519.pub")" &&
if gh ssh-key list | grep -F "$key_body" >/dev/null; then echo "SSH key already registered with GitHub."; else gh ssh-key add "$HOME/.ssh/id_ed25519.pub" --title "$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo primer)"; fi| 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 |
| Git config | ~/.gitconfig |
| 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/master/setup.sh | shReset to a clean slate with tart delete primer-test and re-clone.