Idempotent macOS setup automator. Reinstall all your dev tools, apps, and configs after a fresh format — one click or one command.
- Installs the tools your profile picks from a public catalog, in dependency order: Command Line Tools (headless, no dialog), Homebrew, then everything else
- Starter profiles (
minimal,web,backend,mobile) or your own profile directory with your own tools and dotfiles - Language runtimes (Node, Bun, Go) through mise, App Store apps through mas
- Applies macOS settings (Dock, Finder, Dark mode, trackpad) only when your profile turns them on, writing only what differs
- Checks what's already installed and skips it, so a re-run resumes where the last one stopped
- A failed tool skips the tools that depend on it; each run ends with a summary and a Retry Failed button
- Dotfiles are opt-in, per file, with a diff first: nothing replaces your files unless you choose it, and every replaced file is backed up and can be restored
- Asks for the admin password once per run, in a native sheet, and shares it with every installer
- Lists the manual steps left after installing (permissions to grant, App Store sign-in)
- Native SwiftUI app with Liquid Glass UI on macOS Tahoe; Python CLI backend does the work
setmac/
├── Sources/ # SwiftUI macOS app
├── cli/ # Python CLI (uv + click)
├── Resources/
│ ├── tools.json # The public tool catalog
│ └── profiles/ # Starter profiles: minimal, web, backend, mobile
├── scripts/ # Build, bundle, DMG scripts
├── justfile # All commands
└── Package.swift # SPM config
The CLI merges the active profile with the catalog; the app asks the CLI for that list (setmac-cli profile show --json) and shows exactly the tools the CLI installs. The SwiftUI app spawns the CLI via Process, reads structured JSON output, and updates the UI in real-time.
# Run the GUI
just dev
# Or use the CLI directly
cd cli && uv sync
uv run setmac profile list # Starter profiles and the active one
uv run setmac profile use web # Choose a profile (or a path to your own)
uv run setmac status # Check what's installed
uv run setmac install all --dry-run # See what would be installed
uv run setmac install all # Install the profile's tools
uv run setmac configs list # Your profile's dotfiles: missing, identical or differs
uv run setmac configs diff zshrc # See the difference for one file
uv run setmac configs apply --only zshrc # Apply chosen files (asks per file; --yes to skip)
uv run setmac configs restore latest # Undo the last apply
uv run setmac configs capture # Copy dotfiles from this Mac into your own profile
uv run setmac brewfile # Print a Brewfile, for plain `brew bundle`- macOS 26 (Tahoe) — for SwiftUI Liquid Glass
- Xcode (for Swift SDK, not the IDE)
- uv — Python package manager
- Python 3.12+
Installers run without a terminal, so sudo uses the SUDO_ASKPASS helper the CLI sets up for each run:
- The first time any installer needs admin, the CLI emits
auth_required - The app shows a native password sheet (the terminal prompts instead when run by hand)
- The CLI checks the password with
sudo -S -v, asks again if it is wrong, and keeps it in memory for the rest of the run - Every later
sudo(Homebrew,.pkgcasks,softwareupdate,mas) gets it from the helper over a private unix socket; it is never written to disk
"requires_admin": true in tools.json marks tools that will ask.
A profile is a folder with a profile.json. setmac uses, in order: --profile <spec>, the SETMAC_PROFILE environment variable, the profile chosen with setmac profile use, then minimal. A spec is a starter name, an absolute or ~ path, or a git URL with an optional #branch, #tag or #sha (for example git@github.com:you/setmac-profile.git).
{
"schema": 1,
"name": "me",
"extends": "web",
"tools": { "add": ["neovim", "tmux"], "remove": ["bun"] },
"custom_tools": [ { "id": "my-app", "name": "My App", "check": { "path": "/Applications/My App.app" }, "install": { "method": "brew_cask", "target": "my-app" } } ],
"overrides": { "git": { "post_install": "Run gh auth login" } },
"configs_dir": "configs",
"macos_defaults": { "enabled": false }
}extends: a starter name, or a path inside the profile folder (./base). At most 3 levels.tools.add/tools.remove: catalog tool ids. Dependencies come along; removing one that another tool needs is an error.custom_tools: your own tools, in the catalog's format. They may not reuse a catalog id; useoverridesto change a catalog tool.configson your tools:sourceis the file on the Mac (~/...),targetis its copy underconfigs_dir.~/.ssh,~/.gnupg,~/.aws,~/Library/LaunchAgentsand~/.config/ghare refused unless listed insensitive_ok.setmac profile validate <spec>lists every problem;setmac profile show <spec>lists the tools.
Dotfiles live in your own profile (configs_dir), never in setmac. install all does not touch them; install all --with-configs or configs apply does, asking per file on a terminal (or --yes). Each replaced file goes to ~/.config/setmac-backup/<time>/ with a manifest; configs restore <time|latest> puts it back and removes files the apply created.
configs capture copies files from this Mac into your local profile folder, and refuses a starter, a pinned git checkout, or a clone of the public setmac repo. It scans everything first: tokens and private keys always block; emails, home paths, local host names and random-looking strings block unless you pass --accept-medium. Allow a reviewed finding with a .setmac-scan-allow line (rule-id:path-glob), and skip files with .setmacignore in the profile or ~/.config/setmac/ignore.
If your setup lived in an older setmac tools.json (or a fork of it), turn it into a private profile:
setmac profile export --repo path/to/old/setmac --from-ref <old-tag-or-branch> --out ~/my-setmac-profile --name meTools that are now in the catalog are added back by id; the rest become your custom_tools. Each dotfile is copied from this Mac when it exists here, else from the ref. Everything is scanned (secrets abort, personal data needs --accept-reviewed) and validated, and the folder gets git init; make the first commit yourself and push it to a private repository.
A profile you didn't get from setmac can run any shell command, so setmac runs nothing from it until you have reviewed it:
setmac profile review <spec> # every install command, check, dotfile, admin tool and note, verbatim
setmac profile trust <spec> # trust exactly this content (asks; or pass --hash <content hash>)Trust covers the exact files reviewed: editing a local profile, or a new commit in a git profile, needs a new review. A git profile is cloned with your own git credentials and pinned to one commit on first use; setmac profile update <spec> shows what changed and moves the pin only after you confirm. Starters are always trusted.
The public catalog. Every tool is defined in Resources/tools.json with:
- id — unique identifier
- category — essentials, cli-tools, applications, languages, standalone, configs, system
- check — how to verify it's installed (command, path, or version check). Brew, mise, mas and defaults tools are checked by their own method.
- install —
brew_formula,brew_cask,mise(node@lts),mas(App Store id),defaults,script, orcustom - defaults / restart — for the
defaultsmethod:{domain, key, type, value}settings and apps to restart after a change - post_install — a manual step to show after install
- depends_on — tools that must be installed first
- configs — dotfiles to capture/restore (profiles only; the catalog ships none)
just dev # Build and run in debug mode
just build # Build debug binary
just status # Check install status of all tools
just capture-configs # Copy dotfiles from this Mac into your profile
just apply-configs # Apply your profile's dotfiles (asks per file)
just bundle # Create .app bundle with embedded CLI
just dmg # Create DMG for distribution
just # List all recipes
Releases are automated with python-semantic-release.
- Canary: Push to
canaryto publishvX.Y.Z-canary.N - Beta: Push to
betato publishvX.Y.Z-beta.N - Stable: Run the
Release Stableworkflow manually frommain - Version bumps: Use Conventional Commits (
feat:,fix:, etc.). semantic-release owns the version number.
Release builds now do the following:
- ad-hoc sign the standalone
setmac-cli - ad-hoc sign
Setmac.appand the DMG by default - automatically switch to Developer ID signing when these GitHub secrets are configured:
APPLE_SIGNING_IDENTITYAPPLE_CERTIFICATE_P12_BASE64APPLE_CERTIFICATE_PASSWORDAPPLE_IDAPPLE_APP_SPECIFIC_PASSWORDAPPLE_TEAM_ID
- automatically notarize and staple the DMG when the Apple secrets are present
Ad-hoc signing makes the bundle structurally valid, but it does not satisfy Gatekeeper on a downloaded build. Until the Apple signing secrets are configured, users should expect to use the workaround below.
If you open a non-notarized build and macOS reports the app as damaged or blocked, move it to /Applications and run:
xattr -cr /Applications/Setmac.appYou can also Control-click the app and choose Open. This is only a temporary workaround for older unsigned releases; the proper fix is signed and notarized builds.
- Release and hotfix playbook:
docs/ai-maintainer-playbook.md - Persistent Cursor rule:
.cursor/rules/release-policy.mdc - Project skill for AI release work:
.cursor/skills/setmac-release-ops/SKILL.md - Agent guidance:
CLAUDE.md
The catalog (profiles pick from it):
Essentials: Xcode CLT, Rosetta, Homebrew, mise, mas CLI: git, gh, neovim, tmux, just, fzf, bat, eza, fd, jq, yq, ripgrep, starship, lazygit, btop, fastfetch, yazi, tree-sitter, gnupg, pinentry-mac, openssh, imagemagick, ffmpeg, oxipng, poppler, typst, ollama, watchman, cocoapods DevOps and security: kubectl, Helm, kubeconform, k6, flyctl, cloudflared, AWS CLI, SOPS, age, gitleaks, git-filter-repo, lefthook, actionlint, hadolint, golangci-lint, psql (libpq) Apps: Cursor, Claude Code, Ghostty, Raycast, Rectangle, Visual Studio Code, Docker Desktop, Hoppscotch, pgAdmin 4, Keynote, Pages, Numbers Languages: Go, Node, Python, uv, LuaJIT, Bun, pnpm, Elixir, Rust Standalone: Oh My Zsh, TPM macOS settings: Dock, Finder, Dark mode and trackpad defaults (off unless a profile enables them)
MIT