Skip to main content

macsetup

CI

Declarative provisioning for macOS. Describe what a machine should have in YAML; macsetup plan shows you the difference against reality, and macsetup apply closes it.

$ macsetup plan
┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Module       ┃ Package                      ┃ State     ┃
┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ core         │ git, gh                      │ satisfied │
│ cli          │ ripgrep                      │ install   │
│ cli          │ ~/.config/macsetup/zsh/90-…  │ update    │
│ lang-python  │ uv                           │ satisfied │
└──────────────┴──────────────────────────────┴───────────┘
1 to install, 1 file to update, 6 satisfied

Adding software is a YAML edit, never a code change. Re-running on a converged machine is a no-op, and plan and apply share the same planner, so the preview is exactly what apply acts on.

Built for the case where you have several Macs that should share an environment without every machine getting every package.

Install

One line, on a Mac with nothing on it yet:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/neosergio/macsetup/main/install.sh)"

install.sh installs Homebrew if it is missing, then uv, then macsetup, and ends in the init wizard. Each step checks first, so it is safe to re-run. Arguments replace the wizard, for unattended machines:

curl -fsSL https://raw.githubusercontent.com/neosergio/macsetup/main/install.sh \
  | bash -s -- apply --profile work --yes

If you already have Homebrew, the manual route is three commands:

brew install uv
uv tool install macsetup
macsetup init

init runs a wizard (machine name → profile → per-module choices), saves the result to ~/.config/macsetup/machine.toml, and applies it. After that no flags are needed.

macsetup plan        # what apply would do; changes nothing
macsetup apply       # do it
macsetup status      # drift: manifest vs this machine
macsetup untracked   # installed packages no selected module accounts for
macsetup modules     # the whole catalog and its profiles

Every wizard answer has a flag equivalent, so a machine can be provisioned unattended: macsetup apply --profile work --yes.

Your own catalog

macsetup ships a small example catalog so a fresh install has something to run against. It is an example — replace it with yours:

export MACSETUP_MANIFESTS=~/dotfiles/manifests
export MACSETUP_FILES=~/dotfiles/files

Both are resolved in that order: the environment variable, then a manifests/ or files/ directory in the current working directory, then the copy baked into the wheel. Keeping your catalog in a private repo and the engine public is the intended arrangement.

Modules

One file per module in manifests/modules/, where id matches the filename stem:

id: cli
name: CLI tools
requires: [fonts]          # dependency-closed at resolve time
packages:
  - ripgrep                          # formula
  - {name: ghostty, cask: true}      # cask
files:
  - copy: zsh/cli.zsh
    to: ~/.config/macsetup/zsh/90-cli.zsh

Casks must be marked explicitly. Several names exist in both namespaces — docker is the CLI formula and Docker Desktop the cask — and a bare brew install docker silently picks the formula. Marking casks is the single biggest class of bug the manifest format exists to prevent.

always: true puts a module on every machine regardless of profile. select: pick turns a module into a per-package menu in the wizard, with default: pre-ticked. Pydantic forbids unknown keys, so a typo fails at load rather than halfway through an install.

Profiles

manifests/profiles.yaml bundles modules into named sets; "*" means everything.

profiles:
  work: [cli, lang-python, editors]
  minimal: [cli]
  full: ["*"]

machine.toml stores the resolved module list, not just the profile name, so a module added to a profile upstream cannot silently install itself on every machine the next time they sync. status reports unadopted modules; configure adopts them.

Files

Three actions:

files:
  - copy: zsh/python.zsh                        # verbatim
    to: ~/.config/macsetup/zsh/20-python.zsh
  - render: git/gitconfig.j2                    # Jinja2 + machine vars
    to: ~/.gitconfig
    mode: "600"
  - block: shell/zshrc-block.zsh                # region in a file you don't own
    into: ~/.zshrc
    position: top                               # default bottom

block: maintains a marker-delimited region. Lines outside the markers are preserved; re-applying rewrites only what's between them. position: top exists because some formats are order-sensitive — an Include in ssh_config is ignored if it lands after a matching block.

render: substitutes variables from manifests/vars.yaml, overridden per machine by the [vars] table in machine.toml, plus machine and home which are always supplied. Rendering uses StrictUndefined: a template referencing a variable with no value is a hard error, not a silently empty setting.

Before overwriting a file it did not create, copy/render leave a .macsetup-bak copy alongside it.

Nothing is symlinked. A symlink would point into the installed wheel and break on the next upgrade, so files are copied and you re-run apply after editing a fragment.

Design notes

A few decisions that are load-bearing, in case you're reading the source:

Data is separate from code. manifests/ says what to install; src/macsetup/ only knows how. If adding software requires touching Python, something is wrong.

brew list runs exactly twice per invocation, cached in detect.py — never once per package. Checking 60 packages costs two subprocess calls, so a converged machine plans in well under a second regardless of catalog size. There is a test asserting the call count.

Installs delegate to brew bundle against a generated Brewfile rather than shelling out per package. Homebrew already knows about retries, cask quarantine and sudo prompts. Generated Brewfiles stay in ~/.cache/macsetup/brewfiles/ so a failure can be inspected.

apply never upgrades. brew bundle install upgrades by default; macsetup passes --no-upgrade so converging a machine cannot turn into a 40-minute upgrade run. --upgrade is opt-in.

Subprocess calls go through runner.py. That protocol is the test seam — the suite runs without touching the machine.

Development

uv sync
uv run macsetup plan -p work
uv run pytest                 # no machine access required
uv run ruff check . && uv run ruff format .
uv tool install -e .          # put `macsetup` on PATH, tracking this checkout

Tests never mutate the machine: FakeRunner stands in for subprocesses, and file steps run against a sandboxed HOME. To try the file actions safely:

SB=/tmp/ms-try; mkdir -p $SB
HOME=$SB MACSETUP_CONFIG=$SB/.config uv run macsetup apply --only shell --yes

shell has no packages, so that never invokes brew.

CI runs the same two things on every push. Lint, the suite and a check that the wheel still carries the catalog go on Linux; plan and apply shell out to brew list, so every profile is planned and dry-run on a macOS runner, along with the file appliers against a throwaway HOME and a second apply to prove re-running converges to nothing.

Not built yet

  • sync (git pull + apply), add, prune, upgrade, doctor
  • macOS defaults write tweaks as a step type
  • Mac App Store apps (brew bundle handles mas entries natively; no manifest uses them yet)

Prior art

chezmoi does dotfiles thoroughly but not packages. brew bundle does packages but has no preview, no drift report and no notion of per-machine subsets. nix-darwin does all of it and more, if you want to learn Nix. macsetup is the middle: plain YAML, Homebrew underneath, a plan you read before anything happens.

License

MIT

Metadata

Release files for macsetup 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for macsetup 0.1.1
File Size Uploaded Provenance
macsetup-0.1.1.tar.gz 70.4 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for macsetup 0.1.1
File Interpreter ABI Platform
macsetup-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 104.7 kB

Release files / macsetup-0.1.1.tar.gz

Download URL macsetup-0.1.1.tar.gz
Size 70.4 kB
Tags Source
SHA-256 checksum
How to use checksums
0b6365da7a5d840414a4105e090164de7db66524306c4e21f7f721dc37a23049
BLAKE2b-256 checksum
How to use checksums
559928896e03fa56d2ea63a51449a1e443b82b948b599af7fd1f62adf723fb09
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

Release files / macsetup-0.1.1-py3-none-any.whl

Download URL macsetup-0.1.1-py3-none-any.whl
Size 34.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b31de9f1799d477e5893b1c234203f486584ac4cffbac55705af7e58c1bfd8f3
BLAKE2b-256 checksum
How to use checksums
64cc595b9c686f00aa02485c29a4658e1dee693cce080b29f1c471dd01240404
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

Security for macsetup 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for macsetup-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page