Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ hit something a script didn't classify or you need the precise command.
them. Reversibility is the whole point; never hard-delete a user's data.
- **When unsure, demote a tier.** A slower rebuild is trivial; deleting a
license, an unpushable Xcode archive, or someone's only local backup is not.
- **Every destructive run is logged** to `~/Library/Logs/mac-storage-cleaner/operations.log`.
- **Every destructive run is logged** to `~/Library/Logs/mac-storage-cleaner/operations.log` —
deletions refuse to run unlogged (exit 3) unless the log directory is writable, or
`MSC_ALLOW_UNLOGGED=1` explicitly opts into an unlogged run.

## Workflow

Expand Down Expand Up @@ -61,11 +63,37 @@ files, skips anything macOS protects (reporting rather than failing), runs
deletion, and prints what it reclaimed. If the user only wanted specific items,
delete those directly instead.

To preview first (recommended when the user hesitates or asks what will go): add
`--dry-run` — full preview with sizes, zero deletion, zero log writes. Guards and
whitelist run identically in both modes, so the preview always matches reality.

The safe tier now keeps the 2 newest DeviceSupport versions
(MSC_DEVICE_SUPPORT_KEEP), keeps the active + 1 previous version of
auto-updating AI CLIs (claude / cursor-agent / copilot, pinned via their
launcher symlink), and skips any path whose owning process is running (Xcode
family, Gradle daemon) — report skipped items to the user instead of retrying.

**Browser & Electron app caches** (Chrome/Arc/Slack/VS Code/…) are safe but live
inside app-data folders — clear only the `Cache`/`Code Cache`/`GPUCache`
subfolders the survey lists, ideally with the app quit, and **never** the whole
app folder. Exact paths: `references/cache-catalog.md`.

### User whitelist

`~/.config/mac-storage-cleaner/whitelist` — one path or glob per line, `#`
comments, `~/` expansion; protects the entry and everything under it, and
governs every tier `clean-safe.sh` touches (safe, keep-N, AI-agent, Handoff),
dry-run included, down to individual children inside a keep-N/AI-agent/Handoff
base directory — surfaced by the survey too. One limit: an entry BELOW a
**safe-tier** allowlist path (e.g. `~/.npm/some-package`) can't be honored,
because the safe tier removes those paths atomically (`rm -rf ~/.npm`) rather
than walking their children — whitelist the safe-tier path itself instead.
`find-extras.sh` and
`trash-items.sh` do **not** read it (they only ever act on paths the user
explicitly approves that session), so when the user says "always keep X", add
a line here **and** keep checking find-extras candidates against it yourself
before proposing removal — the script won't stop you.

### 3. Surface the "ask" tier — recommend, don't delete

Big but not free caches (Docker images, ML models, simulator devices, Xcode
Expand Down Expand Up @@ -95,12 +123,13 @@ D="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/mac-storage-cleaner}"; D="${
bash "$D/scripts/trash-items.sh" "/path/one" "/path/two"
```

**If trashing reports "could NOT trash (permissions/TCC?)"** for every item, the
controlling app hasn't been granted Automation control of Finder — a normal
first-run state. Tell the user to allow it in System Settings › Privacy &
Security › Automation (enable Finder for the terminal/app), then re-run; or move
the item to the Trash manually in Finder. Don't report space as freed when items
logged `trash-failed` — nothing was actually removed.
**Trash chain and refusals.** trash-items.sh now tries `/usr/bin/trash` first (no
TCC prompt, works headless), then Finder (needs the Automation grant — System
Settings › Privacy & Security › Automation), then a same-volume `mv` into
`~/.Trash`. The log records which method moved each item; refused entries mean
the path is on the tool's deny list (system/user roots) — never work around a
refusal. Don't report space as freed when items logged `trash-failed` — nothing
was actually removed.

**App leftovers need verification.** The scan lists containers whose owning app a
quick check couldn't confirm is installed — but Spotlight misses un-indexed apps,
Expand Down Expand Up @@ -141,3 +170,21 @@ Read `references/cache-catalog.md` for the full tiered inventory and gotchas. Th
(only `caches/`). `~/.npm` is pure cache so it's fine whole.
- **Continue past errors and verify** with `du`; `rm -rf` on multiple paths keeps
going after a failure, so never assume total success or total failure.
- **Handoff shared-pasteboard buffers are cleared only when untouched for 60+
minutes** — never delete fresher ones, an in-flight Universal Clipboard sync
may be using them.

## Environment variables

- `MSC_DRY_RUN` — set to `1` to force preview mode (same as `--dry-run`) on `clean-safe.sh`, and to make `trash-items.sh` preview instead of trashing.
- `MSC_WHITELIST_FILE` — override the whitelist path (default `~/.config/mac-storage-cleaner/whitelist`).
- `MSC_TRASH_BIN` — override the `trash` binary `trash_path` tries first (default `/usr/bin/trash`).
- `MSC_DEVICE_SUPPORT_KEEP` (default `2`) — how many newest Xcode DeviceSupport versions to keep per platform.
- `MSC_AI_AGENTS_KEEP` (default `1`) — how many newest non-active AI CLI versions to keep alongside the active (symlink-pinned) one.
- `MSC_ALLOW_UNLOGGED` — set to `1` to let a destructive run proceed even when the audit log can't be written (default: refuse, exit 3).

## Tests

`bats tests/` from the repo root (`brew install bats-core`). Every test runs
against a fake `$HOME`; the dangerous-path corpus in `tests/fixtures/` is a
floor — investigate a failure, never weaken the corpus.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ license file, an unpushable archive, or someone's only local backup is not.
| Location | What | Reclaim | Regenerates on |
|---|---|---|---|
| `~/Library/Developer/Xcode/DerivedData` | Xcode build products, indexes | `rm -rf` | next Xcode / `xcodebuild` |
| `~/Library/Developer/Xcode/{iOS,watchOS,tvOS} DeviceSupport` | symbols copied from attached devices | `rm -rf` | re-copies when a device on that OS build is re-attached (note: an OS build you no longer have a device for won't regenerate — minor, only affects symbolicating that old build) |
| `~/Library/Developer/Xcode/{iOS,watchOS,tvOS} DeviceSupport` | symbols copied from attached devices | `rm -rf` | re-copies when a device on that OS build is re-attached (note: an OS build you no longer have a device for won't regenerate — minor, only affects symbolicating that old build). clean-safe keeps the N newest versions (`MSC_DEVICE_SUPPORT_KEEP`, default 2) so your current devices' symbols survive; older versions re-download on next connect of such a device. |
| `~/Library/Developer/CoreSimulator/Caches` | simulator runtime caches | `rm -rf` | automatically |
| `~/Library/Caches/com.apple.dt.Xcode` | misc Xcode cache | `rm -rf` | automatically |
| `~/Library/Caches/org.swift.swiftpm` | SwiftPM download cache | `rm -rf` | next resolve |
Expand All @@ -35,15 +35,31 @@ license file, an unpushable archive, or someone's only local backup is not.
| `~/Library/Caches/{node-gyp,typescript,electron,electron-builder}` | tool download/build caches | `rm -rf` | next use |
| `~/.cache/uv`, `~/Library/Caches/uv` | uv wheel/source cache | `uv cache clean` or `rm -rf` | next `uv` |
| `~/.cache/pip`, `~/Library/Caches/pip` | pip wheel cache | `pip cache purge` or `rm -rf` | next `pip install` |
| `~/Library/Caches/composer`, `~/.composer/cache` | Composer (PHP) download cache | `rm -rf` | Composer re-downloads packages on next `install`/`update` |
| `~/.gem/ruby/*/cache` | downloaded `.gem` archives | `rm -rf` | next `gem install`/`bundle install` — installed gems live in `gems/` alongside, untouched |
| `~/Library/Caches/go-build` | Go compile cache | `go clean -cache` or `rm -rf` | next `go build` |
| `~/Library/Caches/Homebrew` + downloads | brew bottle/download cache | `brew cleanup -s --prune=all` | next `brew install` |
| `/private/tmp/{metro-*,haste-map-*,react-*}` | Metro/RN temp | `rm -rf` | next Metro start |
| `~/Library/Group Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard` | Handoff / Universal Clipboard transfer buffers; `useractivityd` is supposed to prune these itself but can leave many GB behind (mole #1178) | `rm -rf` **only items older than 60 minutes** (age-gated — an in-flight Universal Clipboard sync must never be cut) | automatically as new Handoff transfers occur |
| `/private/tmp/{metro-*,haste-map-*,react-native-packager-cache-*,react-packager-cache-*}` | Metro/RN temp | `rm -rf` | next Metro start (bare `react-*` is deliberately NOT safe — it would match user clones like `react-native-fork`) |
| `~/Library/Caches/org.carthage.CarthageKit` | Carthage (third iOS dependency manager, alongside CocoaPods/SwiftPM above) build/download cache | `rm -rf` | next `carthage bootstrap`/`carthage update` |
| `~/Library/Caches/pypoetry` | Poetry (Python) package/artifact cache | `rm -rf` or `poetry cache clear --all .` | next `poetry install` |
| `~/.cache/mise` | mise (asdf-style tool version manager) download/install cache | `rm -rf` | next `mise install` |
| `~/Library/Caches/mise` | mise's macOS cache dir (separate from `~/.cache/mise` above) | `rm -rf` | next `mise install` |
| `~/Library/Caches/Google/AndroidStudio*` | Android Studio IDE caches (versioned folder, hence the glob) | `rm -rf` | automatically (IDE reindexes on next launch) |
| `~/.android/cache` | Android SDK/build-tool cache | `rm -rf` | next Android build |
| `~/.android/build-cache` | Android Gradle plugin build cache | `rm -rf` | next Android build |
| `~/Library/Logs/DiagnosticReports` | crash reports (`.crash`/`.ips`); macOS/apps never prune these themselves | `rm -rf` (clean-safe.sh removes only items **older than 30 days**, age-gated like Handoff above) | new reports keep accumulating; a report younger than 30 days is kept in case it's still needed for a bug report |
| `~/Library/Caches/<app>` (generic, NOT on the list above) | most per-app caches | prefer `trash-items.sh` (reversible); `rm -rf` only once you've confirmed it's a pure cache | usually automatic — but some apps keep the only local copy of downloaded content or a token here, so verify before deleting |

**Browser & Electron app caches (safe, but quit the app first):** delete only the
cache *subfolders* — never the whole app-support folder, which holds real data.
- Electron apps (Slack, Discord, VS Code, Cursor, Windsurf, Teams, Notion, …):
`~/Library/Application Support/<App>/{Cache,Code Cache,GPUCache,DawnWebGPUCache}`.
`clean-safe.sh` now clears these four cache subfolders **automatically** for
every direct child of `~/Library/Application Support` (audit wave 2) —
guarded by a fail-closed `pgrep -x` check on the app-folder name (running or
unknown state ⇒ skip) so a live app's cache is never touched mid-write.
Browsers' profile caches (below) remain manual/unchanged.
- Chromium browsers (Chrome, Arc, Brave, Edge, Vivaldi):
`~/Library/Application Support/<Browser>/<Profile>/{Cache,Code Cache,Service Worker/CacheStorage}`.
Clearing `Service Worker/CacheStorage` drops sites' offline data (minor).
Expand All @@ -59,6 +75,8 @@ cache *subfolders* — never the whole app-support folder, which holds real data
| `~/Library/Containers/com.docker.docker` (`Docker.raw`) | VM disk with images/volumes — not a cache | Start Docker, run `docker system prune -a` (and `docker volume prune`). Never `rm` the .raw. |
| `~/.cache/huggingface`, `~/.ollama/models`, `~/.cache/torch`, `~/.lmstudio` | multi-GB model re-downloads | Confirm; if duplicate variants of one model exist (e.g. whisper in faster-whisper + MLX + turbo), point it out and delete the unused ones. |
| `~/Library/Developer/CoreSimulator/Devices` | simulator state + installed apps | `xcrun simctl delete unavailable` is safe (orphans only). Deleting active devices wipes their state — ask. |
| `~/miniconda3/pkgs`, `~/anaconda3/pkgs`, `~/opt/*conda*/pkgs` (or wherever conda is installed) | package cache is hardlinked into every live conda env — raw `rm` on `pkgs/` breaks them | **owner command only:** `conda clean -y --tarballs --index-cache --logfiles`. Never `rm` inside `pkgs/`. |
| Browser old-version framework folders inside `.app` bundles (Chrome/Edge/Brave `Contents/Frameworks/*/Versions/<old-version>`) | lives inside a `.app` bundle — TCC App Management can block deletion, and the browser must be quit first | **report-only** (`find-extras.sh` lists every version except the one `Current` points to); user picks, `trash-items.sh` removes; if TCC blocks it, use Finder |
| `~/Library/Developer/Xcode/Archives` | contains dSYMs + shippable builds | **Warn:** deleting loses crash symbolication and re-upload ability. Ask. |
| `~/Library/pnpm/store`, `~/.pnpm-store` | content store all projects hardlink from | `pnpm store prune` removes only unreferenced; safer than `rm -rf`. |
| `~/go/pkg/mod` | module cache, files are read-only | `go clean -modcache` (plain `rm -rf` fails on perms). Re-downloads. |
Expand All @@ -67,6 +85,9 @@ cache *subfolders* — never the whole app-support folder, which holds real data
| `~/.gradle/wrapper/dists` | downloaded Gradle distributions | re-download; ask. |
| `~/.m2/repository` | Maven local repo | cache-like but large; **never** `~/.m2` itself — `settings.xml` lives at its root. |
| `~/Library/Caches/JetBrains`, `~/Library/Application Support/JetBrains/*/caches` | IDE indexes | forces full reindex; recommend, don't auto. |
| `~/.android/avd` | emulator images + snapshots — deleting wipes that emulator's state | advise per-AVD review, then `avdmanager delete avd -n <name>` for ones no longer needed |
| `~/Library/Android/sdk/system-images` | old Android API-level system images | `sdkmanager --uninstall "system-images;android-XX;..."` for API levels no longer targeted |
| `~/.orbstack` | verify layout before advising — this data dir includes VM state, not just cache | prefer OrbStack's own prune commands (e.g. its CLI/GUI cleanup) over manual `rm` |
| project `node_modules` / `target/` / `build/` | per-project, huge in aggregate | see stale-project sweep below. |

## Never tier (warn only)
Expand All @@ -83,6 +104,12 @@ cache *subfolders* — never the whole app-support folder, which holds real data
- `/System`, `/Library/Caches`, `/private/var/folders`, dyld shared cache — system-owned
or SIP-protected. Leave them to macOS; don't reach for `sudo` to force it.

These are now mechanically refused by trash-items.sh, not just policy: `~/Library/Application
Support/MobileSync/Backup`, `~/Library/Keychains`, `~/Library/Mail`, `~/Library/Messages`,
`~/.ssh`, `~/.aws`, `~/.gnupg`, and `~/Pictures/*.photoslibrary` are subtree-denied in
`validate_target_path` — passing them (or anything underneath them) to `trash-items.sh`
is refused, not just discouraged.

## App leftovers (uninstalled apps)

Dragging an app to the Trash leaves its data scattered across the library. For an
Expand Down Expand Up @@ -139,12 +166,19 @@ rebuild via install/build) but can be surprising. List, sorted by size, without

```bash
find ~/Desktop ~/Documents ~/Developer ~/Projects ~/code -type d \
\( -name node_modules -o -name target -o -name .next -o -name build -o -name Pods \) \
\( -name node_modules -o -name target -o -name .next -o -name build -o -name Pods \
-o -name .expo -o -name .cxx -o -name .turbo -o -name coverage -o -name .venv -o -name __pycache__ \) \
-prune 2>/dev/null | while read -r d; do du -sh "$d"; done | sort -rh | head -30
```

`npx npkill` is the interactive equivalent if the user prefers a picker.

Any directory containing a valid `CACHEDIR.TAG` signature file (first line
`Signature: 8a477f597d28d172789f06886806bc55`, per the [Cache Directory
Tagging Specification](https://bford.info/cachedir/)) is cache **by
declaration** from the tool that created it — safe to include in sweeps like
this one even if its name isn't on the list above.

## Gotchas

- **TCC / App Management.** `.app` bundles cached under `~/Library/Caches` (some dev
Expand Down
Loading
Loading