macOS maintenance from the command line, written in Rust. 🦀 Like CleanMyMac, but open-source, scriptable, and no BS.
detox info # what the machine holds, and what can be reclaimed
detox ram # what is eating memory, grouped by application
detox files dev # build residue sleeping in your projects
detox orphans # what uninstalled applications left behind
detox clean all -n # what would be deleted, touching nothing
detox clean all # go, into the trash
detox undo # actually, no| Command | Purpose |
|---|---|
info |
Machine summary (macOS, CPU, RAM, swap, disk, uptime), reclaimable space, startup agents |
scan [TARGET…] |
Measure reclaimable space, never deleting anything |
clean <TARGET…> |
Clean one or more targets (all for everything) |
apps |
Installed applications, largest first |
files large |
Large files under a directory |
files dev |
Build residue of local projects (target, node_modules…) |
files clean |
Remove that residue, past a given age |
ram |
Processes grouped by parent application, sorted by memory |
inspect <target> |
Process tree of one application, with CPU, age and arguments |
kill <target> |
Stop every process of one application (by name or number) |
orphans |
Leftovers of applications that are no longer installed |
history |
What was removed, when, and whether it can come back |
undo [ID] |
Put back what a run moved to the trash |
agents list|disable|enable|remove |
Startup agents and daemons (launchd) |
sys dns|spotlight|memory|snapshots|updates |
One-off system operations |
completions <shell> |
bash / zsh / fish / elvish / powershell completions |
| Target | Contents | Losing it costs |
|---|---|---|
cache |
~/Library/Caches |
nothing |
container-cache |
Data/Library/Caches of every sandboxed app |
nothing |
logs |
~/Library/Logs |
nothing |
ds-store |
.DS_Store files in the home directory |
nothing |
homebrew |
Homebrew download cache (brew cleanup --prune=all -s) |
nothing |
pkg-cache |
Global caches of npm, yarn, pnpm, bun, cargo, go, gradle, maven, pip, uv, SwiftPM, CocoaPods, composer, NuGet, pub | a download |
docker |
Unused containers, images and build caches — never volumes | a download |
xcode |
DerivedData, Archives, DeviceSupport (iOS/watchOS/tvOS), simulator caches | a build |
trash |
~/.Trash |
your data |
trash-all |
~/.Trash plus your own trash on every mounted volume |
your data |
simulators |
iOS simulator devices — left out of all |
a download |
ios-backups |
Local iPhone and iPad backups — left out of all |
your data |
all |
Every target above except simulators and ios-backups |
The last column is not decoration: scan sorts by it, so the free wins come
first, and it decides how many times clean asks before acting.
container-cache and ios-backups are invisible without Full Disk Access.
Rather than report them as empty, the tool says it cannot read them.
Docker is only cleaned when it is installed and the daemon answers; otherwise the target is skipped with the reason. Same for Homebrew and Xcode.
git clone https://github.com/Sn0wAlice/detox-mac.git
cd detox-mac
cargo install --path .Or, without cargo install:
cargo build --release
sudo cp target/release/detox /usr/local/bin/Requires macOS and Rust 1.85+ (2024 edition).
The crate is
detox-mac; the command you type isdetox. Its files live under~/.config/detox-macand~/.local/state/detox-mac.
| Option | Effect |
|---|---|
-n, --dry-run |
Measure and print what would happen, change nothing |
-y, --yes |
Answer yes to every confirmation (scripts, cron) |
--purge |
Delete for good instead of moving to the trash |
-x, --exclude <GLOB> |
Never touch paths matching this glob (repeatable) |
--no-config |
Ignore the configuration file |
-q, --quiet |
Print warnings and errors only |
--json |
JSON on stdout, for scripts |
--color <auto|always|never> |
Colour (also honours NO_COLOR) |
# Quick diagnosis
detox info
# Full measurement, slow targets included (.DS_Store, Docker…)
detox scan all
# Clean only the harmless things, no confirmation
detox clean cache logs trash --yes
# The package manager caches, usually the biggest free win
detox scan pkg-cache
detox clean pkg-cache
# Protect something for the length of one command
detox clean all -x '~/Documents/archives/**'
# Leftovers of applications that are gone
detox orphans
detox orphans --clean
# What did I remove yesterday, and can I have it back?
detox history
detox undo
# See what a full cleanup would free
detox clean all --dry-run
# The 30 largest applications
detox apps --top 30
# Files over 2 GB in a specific directory
detox files large --min 2G --path ~/Movies
# Build residue untouched for more than a week
detox files dev
detox files clean --older-than 30
# What is holding memory, then stop it
detox ram --detail --top 5
detox inspect discord
detox kill discord
# Third-party startup agents, then disable one
detox agents list --third-party
detox agents disable com.docker.helper
# Purgeable space and updates
detox sys snapshots
detox sys updatesSizes are the blocks the files occupy, not their apparent length — the same
thing du reports, and the same thing you get back. That matters more than it
sounds:
- a sparse file is counted for what it takes, not for the hole it declares;
- a transparently compressed file is counted compressed;
- a file reachable through several hard links is counted once, which is the difference between a plausible number and a wild one on a pnpm or npm store.
APFS clones are the one case that cannot be detected: two files sharing their blocks after a copy-on-write copy each report their full size, and no per-file API says otherwise. That is a known overstatement, not a silent one.
A directory that cannot be read is never counted as empty. It is reported, and the totals say how many were missed:
Sandboxed app caches — unreadable — grant Full Disk Access to your terminal
! 470 directories could not be read — grant Full Disk Access to your terminal
Long operations draw a progress bar or a spinner on stderr, so stdout stays
pipeable and --json stays parseable:
Measuring ████████████░░░░░░░░░░░░ 12/24 ~/Documents/mlab.other/nav-ext
Looking for projects ⠹ 3481 ~/Downloads/card
It is disabled with --quiet, with --json, and whenever stderr is not a
terminal. --color always forces it back on.
detox completions zsh > ~/.zsh/completions/_detoxEvery document carries a schema number, bumped when a field changes meaning.
detox scan all --json | jq '.total'
detox scan all --json | jq '.targets[] | select(.risk == "cache")'
detox clean cache --json --yes | jq '{freed, trashed, disposal}'
detox orphans --json | jq '.leftovers[0]'
detox history --json | jq '.runs[] | select(.disposal == "trash") | .id'
detox files dev --json | jq '[.residue[].bytes] | add'
detox ram --json | jq '.groups[] | select(.autostart) | .name'
detox inspect spotify --json | jq '.group.processes[].args'
detox agents list --json | jq '.agents[] | select(.apple == false) | .label'detox files dev looks for directories the toolchain knows how to rebuild
— target, node_modules, .gradle, vendor… — and only reports those that
have not moved for a while.
detox files dev # untouched for 7 days
detox files dev --older-than 30 # only the really dormant ones
detox files dev --lang rust node # one ecosystem in particular
detox files clean --older-than 30 # remove them, after confirmation
detox files clean --path ~/dev -n # dry run on a specific directoryBuild residue in ~ — untouched for 10 day(s) (58 directories, 106.40 GB)
26.17 GB target ~/Documents/mlab.sh/vuln.mlab.sh rust · 12d
16.35 GB target ~/Documents/mlab.sh/news.mlab.sh rust · 11d
8.48 GB target ~/Documents/mlab.other/mlab-cloudflare rust · 12d
Never by its name alone. A target is only picked up when it sits next to a
Cargo.toml (or a pom.xml), a build only when the matching project file is
there, and a CMake build only when it actually contains a CMakeCache.txt. A
lone target with no manifest is left alone.
| Language | Marker | Directories | --native command |
|---|---|---|---|
rust |
Cargo.toml |
target |
cargo clean --offline |
node |
package.json |
node_modules, .next, .nuxt, .turbo, .parcel-cache, .svelte-kit, .angular, .astro |
— |
python |
pyproject.toml, setup.py, requirements.txt, Pipfile |
__pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .tox |
— |
gradle |
build.gradle(.kts), settings.gradle |
build, .gradle |
./gradlew clean --offline |
maven |
pom.xml |
target |
mvn -o -q clean |
swift |
Package.swift |
.build |
swift package clean |
dotnet |
*.csproj, *.fsproj, *.vbproj, *.sln |
bin, obj |
dotnet clean |
php |
composer.json |
vendor |
— |
elixir |
mix.exs |
_build, deps |
mix clean |
dart |
pubspec.yaml |
build, .dart_tool |
flutter clean |
zig |
build.zig |
zig-cache, .zig-cache, zig-out |
— |
go |
go.mod |
vendor |
— |
cmake |
CMakeLists.txt + CMakeCache.txt |
build, cmake-build-debug, cmake-build-release |
— |
- Age is the time since the directory or its top-level content last
changed: a
targetyou compiled into this morning is never offered, however old the project is. - Python virtualenvs (
.venv), Docker volumes and hand-writtendistdirectories are not touched: too ambiguous. - The walk never descends into a directory already identified as regenerable,
nor into hidden directories,
Library,Applications,Pictures,Movies.
By default, files clean removes the directory. For cargo as for npm
the result is identical: cargo clean wipes target/ entirely, and npm has
no clean command — removing node_modules is the official method, npm ci
puts it back.
--native hands over to the language's tool where one exists:
detox files clean --native --older-than 30- The tool is only used when it is installed (
gradlewis looked up in the project, everything else inPATH); otherwise the directory is removed, with no failure and no question. - Commands run offline (
--offline,-o) and non-interactively: a cleanup must never trigger a dependency download. - When the tool leaves things behind —
mix cleandoes not touchdeps/,dotnet cleanonly removes what MSBuild tracked — the directory is removed afterwards and the line saysthen removed the leftovers. - It is slower: a
gradlew cleanboots a JVM. Hence plain removal by default.
detox ram does what Activity Monitor does, more readably: it groups
processes by parent application, so an Electron app with fifteen helpers is
a single line.
detox ram # top 15 applications outside macOS
detox ram --detail # with the processes of each group
detox ram --all # include macOS and Apple applications
detox ram --min 100M --top 0 # everything above 100 MBMemory
Physical 16.00 GB — 9.64 GB used, 358.6 MB free, 5.37 GB inactive
Swap 2.44 GB used of 4.00 GB
Pressure 71% of memory free
Processes outside macOS (21 group(s) — 7.36 GB)
1. 1.64 GB Visual Studio Code 14 proc.
2. 1.60 GB Claude 27 proc.
3. 796.0 MB Spotify 6 proc.
4. 92.0 MB Little Snitch starts at login · 1 proc.
5. 19.0 MB WiFiman Desktop starts at login · 1 proc.
- A process with no bundle (
node,rust-analyzer,zsh…) is attached to the application that launched it — but never to a system parent, otherwise everything would end up underlaunchd. starts at loginmarks an application launched by alaunchdagent: that is where updaters and background daemons hide. Stop one withdetox agents disable <label>.- The figure shown is the real footprint (
top), the one Activity Monitor reports;psis the fallback when it is unavailable.
detox inspect 2 # the 2nd group of the last detox ram
detox inspect spotify # by name
detox inspect claude # `claude` (CLI) and `Claude` (app) stay distinct
detox inspect spotify -s # without command linesSpotify
Bundle /Applications/Spotify.app
Memory 855.0 MB — 6 processes
CPU 0.7% (average since launch)
266.0 MB Spotify [939] 0.6% · 3d 0h
├─ 339.0 MB Spotify Helper (Renderer) [1288] 0.0% · 3d 0h
│ --type=renderer --user-data-dir=/Users/…
└─ 20.0 MB Spotify Helper [1248] 0.0% · 3d 0h
--type=utility --utility-sub-type=storage.mojom.StorageService
The tree follows the real parent/child chain, and the arguments
(--type=renderer, --type=gpu-process…) say what each helper is for. When
the application is started by a launchd agent, its label is printed together
with the command that disables it.
The % is the average CPU since the process started (what ps reports), not an
instant sample: that is what exposes a background process grinding away.
The numbers in the left column are shortcuts for kill:
detox kill 3 # the 3rd group of the last detox ram
detox kill discord # by name
detox kill vs code # `vs code` finds `Visual Studio Code`
detox kill spotify --force # SIGKILL, when SIGTERM was not enough
detox kill -n discord # dry run: lists the targeted processeskill sends SIGTERM to every process of the group, roots first so that
helpers shut down cleanly, then checks who survived.
- A number is only a shortcut to a name: the target is resolved again and printed before the confirmation, so a ranking that moved between two commands can never kill the wrong application.
- An ambiguous name is never guessed: the candidates are listed.
- macOS components are refused without
--system. detox-macnever kills itself; when the target holds the terminal running the command, it says so before asking.
detox-mac deletes files. It is built so that never happens by surprise, and
so that a mistake is not the end of the story.
By default nothing is destroyed: entries are moved to ~/.Trash, which
makes every cleanup reversible. The output says so, because "freed" would be a
lie — the blocks are still allocated until the trash is emptied:
Moved to trash: 4.20 GB
Still on disk until the trash is emptied — detox clean trash
Changed your mind? detox undo
--purge deletes for good instead. The trash targets themselves always purge:
emptying the trash into the trash is not a thing.
Files on another volume cannot be moved to the user's trash; rather than
silently deleting them, the tool says so and points at --purge.
A confirmation before any destructive operation, as before — and a second, typed confirmation whenever the run cannot be undone:
? Clean: cache? [y/N] y
! This cannot be undone — nothing goes to the trash: cache.
? Type delete forever to confirm, anything else to cancel:
A stray y cannot get through that one. The second gate is asked when
--purge is in effect, when a target always purges, and when a target holds
your own data rather than cache. It is skipped by --yes — which prints a
warning line instead — and can be turned off in the configuration.
Every run that removes something writes a file under
~/.local/state/detox-mac/journal:
detox history # what was removed, when, and whether it can come back
detox undo # put the last run back
detox undo 20260918-230512undo never overwrites: an original path that is occupied again is left alone
and reported, as is anything no longer in the trash.
- Dry run (
-n): exact measurement of what would go, zero modification. - Apple agents are never disabled nor removed, even with
--all-third-party. - Docker volumes are never pruned: they hold your data.
- iOS simulators and iOS backups are excluded from
all. trash-allonly touches your own trash on each volume, never the other accounts' on that machine.- Symlinks are never followed, when walking or when deleting.
Nothing matching an exclusion is removed, listed or counted — it never reaches the confirmation in the first place:
detox clean all -x '~/Documents/archives/**' -x '**/.venv'
detox files clean -x '~/dev/current-project/**'Command-line exclusions add to the configured ones; one never replaces the
other, so -x can only ever protect more.
~/.config/detox-mac/config.toml (or $DETOX_MAC_CONFIG), all optional:
# Paths that must never be touched, whatever the command.
# `*` stays inside a path segment, `**` crosses directories, a bare name
# matches any single component.
exclude = [
"~/Documents/archives/**",
"**/.venv",
".env",
]
# What a deletion does: "trash" (default) or "purge".
disposal = "trash"
# Targets used by `clean` and `scan` when none is given.
default_targets = ["cache", "logs", "pkg-cache"]
# Ask a second time before anything irreversible. Default: true.
confirm_twice = trueA line the parser cannot make sense of is reported and ignored, never
guessed at — a misspelled disposal leaves the safe default in place rather
than silently doing something else. --no-config skips the file entirely.
Dragging an application to the trash takes the bundle and leaves everything else behind.
detox orphans # list them
detox orphans --clean # remove them, after two confirmations
detox orphans --clean com.acme.appLeftovers of uninstalled applications (35 — 2.2 MB)
1. 1.3 MB com.xamarin.fontconfig cache
2. 404 KB com.nootch.Nootch container
3. 252 KB dev.grimoire.poc support
Matching is deliberately conservative, because guessing here means eating someone's data:
- A directory is only attributed when its name is a bundle identifier
(
com.acme.app, two dots at least). A hand-named directory —Application Support/Sublime Text— is never claimed, so the list is shorter than what is really there, by design. - An identifier related to an installed one counts as installed: helpers, frameworks and extensions belong to their application.
- Installed means more than
/Applications: preference panes count, and so do the applications that live insideApplication Support— updaters, embedded CLIs — which is what keeps Chrome's updater off the list. - Group containers are matched under their real identifier, team prefix and
group.marker removed. - Apple identifiers are never listed.
Places searched: Application Support, Containers, Group Containers,
Caches, Logs, HTTPStorages, WebKit, Preferences,
Saved Application State, Cookies, LaunchAgents.
src/
├── main.rs # Entry point, exit code
├── cli.rs # Command-line definition (clap)
├── app.rs # Command dispatch and rendering (text / JSON)
├── config.rs # Exclusions, disposal mode, defaults
├── format.rs # Human-readable sizes, durations, dates, paths
├── ui.rs # Colours, progress bars, confirmations
├── sys/ # System access
│ ├── cmd.rs # Running external commands
│ ├── fsx.rs # Measuring, walking, trashing and removing files
│ └── machine.rs # Machine info (sw_vers, sysctl, vm_stat, df)
└── task/ # Business logic, independent of display
├── clean.rs # Cleaning targets: measure and remove
├── dev.rs # Build residue, per ecosystem
├── docker.rs # Docker detection and prunes
├── orphans.rs # Leftovers of uninstalled applications
├── journal.rs # What was removed, and how to put it back
├── agents.rs # launchd startup agents
├── maintenance.rs # DNS, Spotlight, memory, snapshots, updates
├── ram.rs # Memory snapshot, grouping, inspection, shutdown
└── scan.rs # Applications and large files
Tasks return serialisable structures; app.rs alone decides how they look.
That is what makes --json work on every command without duplicating a single
line of logic, and what lets the slow loops report progress.
Dependencies: clap, clap_complete, serde, serde_json.
cargo test # unit tests
cargo clippy # lints
cargo fmt # formattingBecause we love our Macs, but not bloated cleanup apps.
GPL-3.0-or-later — see LICENSE.
