Skip to main content

macmaint

PyPI version Python 3.13+ License: MIT

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 with cache-allowlist and log-retention-days no 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-run prints commands from both hook positions without executing them.
  • --no-extras skips 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 --all validates sudo once for the whole run and keeps that authorization alive; update and maintenance invoked directly open their own session and re-entering an active one neither prompts again nor starts a second keepalive. Every privileged command uses sudo -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. cleanup needs no administrator rights at all.

  • macmaint apps uses 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 --size is slow for large app libraries. It runs du on 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 SUFeedURL cannot 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.yml is 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_modules are maintained with Bun. macmaint update upgrades those CLI packages across major versions and runs bun audit for 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)

Source distribution for macmaint 2026.10.0
File Size Uploaded
macmaint-2026.10.0.tar.gz 77.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for macmaint 2026.10.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

2026.10.0 This release

2 release files

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