macmaint
A macOS maintenance CLI that keeps your system clean and up-to-date.
Run macmaint to update Homebrew packages, the Mac App Store, uv-managed tools,
and directly installed apps. Add --all to also reclaim developer cache space,
vacuum Mail.app, and flush DNS. Every step reports what it actually did in a
closing run summary.
Everything is idempotent and safe to run as a daily cron job. Use --dry-run to preview every
action before it executes.
Install
uv tool install macmaint
Or with pipx:
pipx install macmaint
To update directly installed applications that publish Sparkle appcasts, build
the pinned official sparkle-cli helper:
macmaint install-sparkle-cli
The installer verifies Sparkle's release SHA-256 and source tag commit, builds
the official CLI source with a macmaint-specific bundle identifier, and installs
it under ~/.local/libexec/macmaint.
Prerequisites
| Requirement | Notes |
|---|---|
| macOS 13 Ventura or later | macOS-only by design |
| Python 3.13+ | Installed automatically by uv/pipx |
| Homebrew | Optional — needed for update and cleanup brew tasks |
| mas | Optional — brew install mas for App Store updates |
| uv | Optional — for uv tool upgrade --all in update |
| bun | Optional — for bun update -g --latest and auditing the Bun-owned ~/package.json dependency tree |
| npm | Optional — for npm update -g (global JS CLIs like @openai/codex) in update |
| sparkle-cli | Optional — official Sparkle helper for directly installed macOS applications |
| Docker | Optional — cleanup prunes this if available |
Quick start
# Check which tools are wired up on your machine
macmaint doctor
# Preview software updates without executing anything
macmaint --dry-run
# Run software updates
macmaint
# Run updates, cleanup, and maintenance
macmaint --all
Subcommands
| Command | What it does |
|---|---|
macmaint update |
Homebrew update/upgrade/cleanup + Mac App Store upgrades + uv tool upgrade --all + bun update -g --latest + Bun audit on ~/package.json + npm update -g + probe-first updates for direct Sparkle and electron-updater apps |
macmaint cleanup |
Prune the uv cache, remove Xcode DerivedData, prune Docker |
macmaint maintenance |
Flush DNS cache, vacuum Mail.app envelope index, thin local Time Machine snapshots when disk space is low |
macmaint apps |
Show all installed apps sorted by last used date (GUI apps, Homebrew formulae, Steam games) |
macmaint doctor |
Check which optional tools are available and what macmaint can do |
Common flags
# Preview mode — show what would run, no changes
macmaint --dry-run
macmaint --all --dry-run
macmaint cleanup --dry-run
# Full run — updates + cleanup + maintenance
macmaint --all
macmaint --full # alias for --all
# Skip specific tasks
macmaint update --no-brew # skip Homebrew
macmaint update --no-mas # skip App Store
macmaint update --no-uv-tools # skip uv tool upgrade --all
macmaint update --no-bun # skip bun update -g --latest
macmaint update --no-bun-audit # skip bun audit on ~/package.json
macmaint update --no-npm-globals # skip npm update -g
macmaint update --no-sparkle # skip direct Sparkle applications
macmaint update --sparkle-probe-only # report Sparkle updates without installing
macmaint update --no-electron # skip direct electron-updater applications
macmaint update --electron-probe-only # report electron-updater updates without installing
macmaint update --no-extras # skip user-defined extras
macmaint cleanup --no-uv # skip uv cache prune
macmaint cleanup --no-xcode # skip Xcode DerivedData
macmaint cleanup --no-docker # skip Docker prune
macmaint cleanup --deep-docker # remove stopped stacks, unused images, and volumes
macmaint maintenance --repair-permissions # opt-in repair: reset ownership and ACLs in $HOME
macmaint maintenance --reindex-spotlight # opt-in repair: rebuild the Spotlight index
# App usage filtering
macmaint apps --days 90 # highlight apps unused for 90+ days
macmaint apps --unused # show only unused apps
macmaint apps --size # include disk footprint column
cleanup --deep-docker is intentionally opt-in. It first removes stopped
containers and all unused images and build cache, then prunes unused anonymous
and named Docker volumes. Running containers and resources they reference are
not pruned, but an otherwise unused named volume may still contain data you
intended to keep. Use --dry-run --deep-docker to inspect both commands first.
What macmaint deliberately does not do
Five cleanup steps were removed because they reclaimed nothing, two
maintenance tasks became opt-in because they are repair tools rather than
routine maintenance, and the Backblaze updater was dropped because the product
is no longer installed. Reinstalling Backblaze does not bring the updater back;
its silent installer ran under sudo and verified its own download signature,
which is a maintenance burden macmaint no longer carries for a single vendor.
| Removed cleanup step | Why it went | What covers it instead |
|---|---|---|
Emptying ~/.Trash and mounted volume trashes |
The path is TCC-protected, so the command failed with Operation not permitted unless macmaint had Full Disk Access; the volume variant also deleted other users' trashes |
macOS: System Settings > General > Storage > "Empty Trash automatically". cleanup prints a hint when that setting is off |
The ~/Library/Caches allowlist (Safari, Chrome, IDEs) |
Also TCC-protected, and APFS marks cache content purgeable | macOS reclaims purgeable cache space by itself under disk pressure |
pip cache purge |
Reported No matching packages, 0 bytes because Python here runs through uv |
uv cache prune, which cleanup still runs |
npm cache clean --force |
npm prints Recommended protections disabled and only forces re-downloads |
npm has treated its cache as self-healing since npm@5 |
Deleting *.log files older than N days from ~/Library/Logs |
Missed rotated names such as .log.gz, for a few megabytes |
macOS log rotation |
Removing all five means cleanup no longer needs Full Disk Access, and no
longer needs administrator rights at all.
| Opt-in maintenance task | Flag | Run it when |
|---|---|---|
Reset ownership and default ACLs across $HOME (diskutil resetUserPermissions) |
--repair-permissions |
Permission errors on your own files, preferences that do not persist, or a Migration Assistant / Time Machine restore that left a foreign uid behind. It flattens deliberately set permissions such as group-writable shares or backup-tool ACLs |
Erase and rebuild the Spotlight index (mdutil -E /) |
--reindex-spotlight |
Searches return nothing, or mdutil -s / reports indexing as disabled. A rebuild puts the volume under heavy load for hours |
The default maintenance flow checks both indicators and recommends the
matching flag only when an indicator actually fires. The ownership scan stays on
the filesystem $HOME lives on, so a separate mount below $HOME such as
~/OrbStack never triggers the recommendation for its own root-owned files. The
Spotlight indicator is switched off with spotlight-indicator = false under
[maintenance] on a machine where indexing is disabled deliberately. Local Time Machine
snapshots are thinned only when free space on / falls below 20 GB, and then
with the gentlest tmutil urgency, so recent recovery points survive.
Exit codes and output streams
| Exit code | Meaning |
|---|---|
0 |
Every step succeeded or was skipped. The one step that runs without succeeding or failing is bun audit: a vulnerability it finds is a fact about the system, not a macmaint malfunction, and failing on it would hold the exit code at 1 until the advisory is fixed upstream. A --dry-run preview always exits 0. |
1 |
At least one step failed. That includes the direct updaters: if any Sparkle or electron-updater application fails to probe, verify or install, the step reports how many of how many failed and the run exits 1. macmaint --all still executes every phase it would have executed and reports the combined status at the end, so one failing phase never suppresses the others. |
2 |
Usage error from the argument parser. |
Failed steps are written to stderr; the run narrative and the rest of the
summary go to stdout. An unattended caller — a cron entry, a launchd job, a
wrapper script — can therefore watch one stream for trouble without parsing the
report, while macmaint cleanup > run.log keeps the readable log in order.
Redirect both with > run.log 2>&1 when you want a single complete transcript.
Run logs
Every invocation through the installed macmaint command writes one plain-text
transcript to $XDG_STATE_HOME/macmaint/logs. When XDG_STATE_HOME is unset,
blank, or relative, macmaint follows the XDG fallback and uses
~/.local/state/macmaint/logs.
Files are named
YYYYMMDDTHHMMSS.ffffffZ-PID-RANDOM.log. Each contains the start time, invoked
command, process ID, stdout and stderr in write order, end time, and final exit
code. ANSI colour remains visible in an interactive terminal but is removed
from the saved transcript. Automatic logging does not alter redirected output,
JSON output, or exit codes.
macmaint does not delete or rotate these files. Retention is owned by the user or the scheduler that invokes it.
Configuration
Create ~/.config/macmaint/config.toml to override defaults. When
$XDG_CONFIG_HOME/macmaint/config.toml exists it is read instead; if it does
not, the ~/.config location is used even with XDG_CONFIG_HOME set. All keys
are optional.
The
[cleanup]section withcache-allowlistandlog-retention-daysno longer exists. A config file that still contains it loads unchanged and those keys are ignored.
[apps]
# Glob patterns for Steam library volumes to scan in addition to ~/Library.
# Default: ["/Volumes/*"]
steam-volumes = ["/Volumes/*"]
[sparkle]
# Bundle-ID glob patterns. An empty allowlist includes every direct Sparkle app.
allowlist = []
denylist = [
"com.example.problematic-app",
]
[electron]
# Bundle-ID glob patterns for apps that update through an electron-updater feed.
# An empty allowlist includes every discovered app.
allowlist = []
denylist = [
"com.example.problematic-electron-app",
]
[maintenance]
# The default maintenance flow recommends `--reindex-spotlight` while
# `mdutil -s /` reports indexing as disabled. Set this to false on a machine
# where indexing is switched off deliberately. Default: true
spotlight-indicator = true
[[extras-preflight]]
# Run before built-in tasks for this phase: update | cleanup | maintenance
phase = "update"
name = "capture-state"
cmd = "capture-state"
when = "command_exists:capture-state"
[[extras]]
# Run after built-in tasks for this phase: update | cleanup | maintenance
phase = "update"
name = "dcg"
cmd = "curl -fsSL 'https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)' | bash -s -- --easy-mode --quiet"
[[extras]]
phase = "maintenance"
name = "custom-health-check"
cmd = "~/bin/health-check"
when = "command_exists:health-check"
Extras hooks
[[extras-preflight]] and [[extras]] let you register commands for update,
cleanup, or maintenance. Preflight extras run in declaration order before
the built-in tasks; regular extras run in declaration order afterwards. Paired
hooks can persist any state they need in a file outside macmaint.
--dry-runprints commands from both hook positions without executing them.--no-extrasskips both hook positions for that invocation.- A false
when = "command_exists:foo"gate skips an extra cleanly. - A failed extra is logged without aborting later extras or built-in tasks.
For the current dcg workaround, put the update extra above into
~/.config/macmaint/config.toml and run macmaint update. macmaint will keep handling
brew/mas/uv/bun/npm first, then reinstall dcg via the upstream curl | bash flow.
Direct Sparkle applications
macmaint scans /Applications and ~/Applications for application bundles with
a static HTTPS SUFeedURL. Mac App Store receipts and Homebrew Cask artifacts are
excluded because those package managers already own their updates.
Every eligible application is probed before installation. A normal update is
installed with the official helper using --check-immediately. macmaint never
passes --interactive, --allow-major-upgrades, or
--grant-automatic-checks; updates requiring those capabilities are reported
and left untouched. Use --sparkle-probe-only for a guaranteed check-only run.
Allowlist and denylist entries are case-sensitive bundle-ID glob patterns. If the allowlist is non-empty, only matching applications are considered. The denylist always wins.
Direct electron-updater applications
Electron applications that ship their own updater declare it in
Contents/Resources/app-update.yml. macmaint scans /Applications and
~/Applications for those bundles and handles the github provider; other
providers (generic, s3, bitbucket, spaces) are reported and skipped.
Mac App Store, Setapp, and Homebrew Cask artifacts are excluded, because those
package managers already own their updates.
Each application is probed before anything is downloaded: macmaint reads the
release feed latest-mac.yml over HTTPS from github.com and compares its
version against the installed CFBundleShortVersionString. macmaint refuses to
install, without downloading, when
- the target is a major version bump,
- either version is a prerelease or is not a plain numeric release,
- the application is currently running, or
- the bundle or its parent directory is not writable.
Only after those checks does macmaint download the artifact matching this Mac's
architecture. The download is installed only if its sha512 matches the digest
published in the feed, and the extracted bundle must carry the same bundle
identifier as the installed application. If the installed application is signed
with a Team ID, the replacement must carry the same Team ID and pass
codesign --verify; many open-source Electron builds are ad-hoc signed, in
which case an ad-hoc replacement is accepted and the sha512 is the only trust
anchor. The installed bundle is moved aside and only removed once the
replacement is in place, so a failure at any step leaves the original app
working.
Use --electron-probe-only for a guaranteed check-only run and --no-electron
to skip the phase entirely. Allowlist and denylist entries are case-sensitive
bundle-ID glob patterns; if the allowlist is non-empty, only matching
applications are considered, and the denylist always wins.
Caveats
-
Privileged steps authorize sudo once per run.
macmaint --allvalidates sudo once for the whole run and keeps that authorization alive;updateandmaintenanceinvoked directly open their own session and re-entering an active one neither prompts again nor starts a second keepalive. Every privileged command usessudo -n, so a run without an active session fails immediately with its reason instead of blocking on an invisible password prompt. Before installing a direct Sparkle update, macmaint revalidates the session and prompts once more if a package-manager step invalidated it. A failed or cancelled prompt is reported once, then privileged direct updates are skipped without repeating the same password error for every app. The Sparkle and electron-updater steps print the failure per application and additionally report how many applications failed, so their failures set the exit code like any other step.cleanupneeds no administrator rights at all. -
macmaint appsuses the Screen Time database (knowledgeC.db) for last-used dates. This database is protected by macOS privacy controls. For accurate results, grant Full Disk Access to the terminal app running macmaint (System Settings → Privacy & Security → Full Disk Access). Without access, macmaint falls back to Spotlight metadata, which is less accurate. -
macmaint apps --sizeis slow for large app libraries. It runsduon each app bundle plus associated Library directories. Expect 30–120 seconds for 100+ apps. -
macOS-only. macmaint uses macOS-specific tools (
mdls,tmutil,dscacheutil,diskutil,sqlite3) and will not run on Linux or Windows. -
Sparkle updates depend on application metadata. Apps that set their feed dynamically instead of publishing
SUFeedURLcannot be discovered. Custom version comparators or vendor-specific updater delegates may also be incompatible with an external helper; add such bundle IDs to the denylist. -
electron-updater updates trust the release feed. The sha512 in
latest-mac.ymlis the integrity anchor, so an application whose GitHub releases are compromised would ship a matching digest. macmaint additionally pins the Team ID for signed applications, but ad-hoc signed builds cannot be verified beyond the digest. Add such bundle IDs to the[electron]denylist if that trust model does not suit you. -
electron-updater updates need the application to be quit. macmaint will not replace a bundle that has running processes; quit the app and run the update again.
-
Major upgrades are never installed automatically. Both direct update paths report a major version bump and leave the installed application untouched.
-
Bun owns the Home-level JavaScript dependency tree.
~/package.json,~/bun.lock, and~/node_modulesare maintained with Bun.macmaint updateupgrades those CLI packages across major versions and runsbun auditfor high-severity advisories. npm is used only for packages installed in its separate active global prefix; macmaint never runs npm prune against the Bun-owned Home tree.
License
MIT — see LICENSE.
Metadata
Release files for macmaint 2026.10.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| macmaint-2026.10.0.tar.gz | 77.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| macmaint-2026.10.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 129.5 kB
Release files / macmaint-2026.10.0.tar.gz
| Download URL | macmaint-2026.10.0.tar.gz |
|---|---|
| Size | 77.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
243120268095a2c559060a68c9d7cec6ecd2951e3087fe4ed5a5a2e779b30871
|
|
BLAKE2b-256 checksum How to use checksums |
4c48c5f374d67b3bf5d55763221f7a0fcf426a3224e9bcbbdee75bb9df37fd16
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / macmaint-2026.10.0-py3-none-any.whl
| Download URL | macmaint-2026.10.0-py3-none-any.whl |
|---|---|
| Size | 52.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a7cdc3811cb41246c6702a9350d30dc264a73c708dc251cbcdca4d71d99f61d
|
|
BLAKE2b-256 checksum How to use checksums |
1b665a2fa1a13ab235c79172335954510c5207c032205dfba5bc6e7760855e2c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|