Skip to content

Instantly share code, notes, and snippets.

@FoushWare
Last active May 2, 2026 20:13
Show Gist options
  • Select an option

  • Save FoushWare/99291423fd4b44c74cc5ade2f3a1e338 to your computer and use it in GitHub Desktop.

Select an option

Save FoushWare/99291423fd4b44c74cc5ade2f3a1e338 to your computer and use it in GitHub Desktop.
macOS Developer Cleanup Script — free up space from caches, editors & dev tools
#!/bin/sh
# ============================================================
# 🧹 macOS Developer Cleanup Script — v5.0
#
# COMPATIBILITY:
# Works with any POSIX-compliant shell — bash 3.2+, bash 5,
# zsh, dash, sh. No bash-specific features used.
# Tested on macOS (default bash 3.2 / zsh) and Linux.
#
# WHAT THIS SCRIPT DOES:
# Phase 1 — SCAN: finds all caches & orphaned configs,
# shows live spinner, measures sizes
# Phase 2 — REPORT: prints full table with sizes per item
# Phase 3 — CLEAN: asks y/n per item, shows progress
#
# ⚠️ PER-USER SCOPE:
# Only cleans the account of whoever runs it.
# On a multi-user Mac, each person must run it separately.
# Paths like ~/Library, ~/.npm, ~/.cargo are unique per user.
#
# SAFE TO RUN:
# • Does NOT touch projects, documents, or source code
# • Does NOT remove editor settings or keychains
# • Does NOT modify system-level files
# • Orphaned configs only flagged when the app is gone
#
# HOW TO RUN:
# chmod +x cleanup.sh
# ./cleanup.sh
# ============================================================
# ─── COMPATIBILITY LAYER ────────────────────────────────────
# We use /bin/sh shebang for maximum portability.
# All constructs below are POSIX-compliant:
# - No arrays → use temp files and line-delimited strings
# - No declare -A → use flat key-value files in /tmp
# - No mapfile → use while read loops
# - No (( )) arith → use $(( )) which is POSIX
# - No [[ ]] → use [ ] (single bracket, POSIX)
# - No echo -e → use printf (POSIX, consistent everywhere)
# - No local → scope with subshells where needed
# - No $'...' → use printf for special chars
# ─── SCRATCH DIRECTORY ──────────────────────────────────────
# Instead of bash associative arrays, we store scan results
# as plain files in a temp directory. Each "key" becomes a
# filename. This works in every shell and survives subshells.
#
# Structure:
# $TMPDIR/cleanup_*/
# items/KEY → label string
# sizes/KEY → byte count
# orphan/KEY → "1" if orphaned config, "0" otherwise
# groups/GROUP → newline-separated list of KEYs
# paths/KEY → newline-separated list of paths
SCRATCH=""
TOTAL_FREED=0
GRAND_TOTAL=0
init_scratch() {
SCRATCH=$(mktemp -d /tmp/cleanup_XXXXXX)
mkdir -p "$SCRATCH/items" "$SCRATCH/sizes" \
"$SCRATCH/orphan" "$SCRATCH/groups" \
"$SCRATCH/paths" "$SCRATCH/gtotals"
}
# cleanup_scratch: always remove temp dir on exit
cleanup_scratch() {
[ -n "$SCRATCH" ] && rm -rf "$SCRATCH"
}
# ─── ANSI COLORS ────────────────────────────────────────────
# Detect if the terminal supports color (check TERM and tty).
# If not, all color vars are empty strings — output stays clean.
if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
MAGENTA='\033[0;35m'
BLUE='\033[0;34m'
RESET='\033[0m'
else
RED='' YELLOW='' GREEN='' CYAN=''
BOLD='' DIM='' MAGENTA='' BLUE='' RESET=''
fi
# ─── SPINNER ────────────────────────────────────────────────
# POSIX-compatible spinner using a background subshell.
# We track the PID manually and kill it when done.
# Uses a simple ASCII fallback if unicode isn't supported.
SPINNER_PID=""
spinner_start() {
msg="$1"
# Check if terminal likely supports unicode
spinner_chars="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
(
# Subshell: loop through spinner frames
# We use cut to extract one char at a time from the string
i=1
while true; do
# Extract frame character by position
frame=$(printf '%s' "$spinner_chars" | cut -c$i)
printf "\r ${CYAN}%s${RESET} %s..." "$frame" "$msg"
i=$(( i + 1 ))
# Reset after 10 frames
[ $i -gt 10 ] && i=1
sleep 0.08
done
) &
SPINNER_PID=$!
}
spinner_stop() {
if [ -n "$SPINNER_PID" ]; then
# Kill the spinner background process
kill "$SPINNER_PID" 2>/dev/null
wait "$SPINNER_PID" 2>/dev/null
SPINNER_PID=""
# Erase the spinner line
printf "\r\033[K"
fi
}
spinner_done() {
# Replace spinner with a success line
# Args: <label> <size_string>
spinner_stop
printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}\n" "$1" "$2"
}
spinner_skip() {
# Replace spinner with a dim dash for empty/absent items
spinner_stop
printf " ${DIM}- %-42s nothing found${RESET}\n" "$1"
}
# ─── UTILITY FUNCTIONS ──────────────────────────────────────
# dir_bytes: total size in bytes for all existing paths given.
# Uses du -sk (POSIX) and multiplies by 1024.
dir_bytes() {
total=0
for p in "$@"; do
if [ -e "$p" ]; then
b=$(du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}')
# du may return empty string (not "0") when macOS restricts
# access due to Full Disk Access permissions — default to 0
b="${b:-0}"
# Fallback: if du returns 0 but directory has contents,
# macOS may be restricting access (Full Disk Access).
# Count items instead and use 4KB per item as a floor
# so the item still appears in the report.
if [ "$b" -eq 0 ] && [ -d "$p" ]; then
item_count=$(find "$p" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
item_count="${item_count:-0}"
[ "$item_count" -gt 0 ] && b=$(( item_count * 4096 ))
fi
total=$(( total + b ))
fi
done
printf '%s' "$total"
}
# human: converts bytes to human-readable string
human() {
bytes=$1
if [ "$bytes" -ge 1073741824 ]; then
printf "%.1f GB" "$(echo "scale=1; $bytes/1073741824" | bc)"
elif [ "$bytes" -ge 1048576 ]; then
printf "%.1f MB" "$(echo "scale=1; $bytes/1048576" | bc)"
elif [ "$bytes" -ge 1024 ]; then
printf "%.1f KB" "$(echo "scale=1; $bytes/1024" | bc)"
else
printf "%s B" "$bytes"
fi
}
# size_color: returns ANSI color for a byte count
size_color() {
bytes=$1
if [ "$bytes" -ge 1073741824 ]; then
printf '%s' "$RED"
elif [ "$bytes" -ge 104857600 ]; then
printf '%s' "$YELLOW"
else
printf '%s' "$GREEN"
fi
}
# any_exist: returns 0 if at least one of the given paths exists
any_exist() {
for p in "$@"; do
[ -e "$p" ] && return 0
done
return 1
}
# app_installed: checks /Applications and ~/Applications for a .app
app_installed() {
[ -d "/Applications/$1.app" ] || [ -d "$HOME/Applications/$1.app" ]
}
# cmd_exists: returns 0 if command is available in PATH
cmd_exists() {
command -v "$1" >/dev/null 2>&1
}
# ask: y/n prompt, loops until valid answer.
# Returns 0 for yes, 1 for no.
ask() {
prompt="$1"
while true; do
printf " ${CYAN}?${RESET} %s [y/n]: " "$prompt"
read -r ans
case "$ans" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
*) printf " Please answer y or n.\n" ;;
esac
done
}
# do_rm: shows live status, deletes paths, updates TOTAL_FREED
do_rm() {
label="$1"; shift
before=$(dir_bytes "$@")
printf " ${CYAN}⟳${RESET} Removing: %s..." "$label"
for p in "$@"; do rm -rf "$p" 2>/dev/null || true; done
printf "\r\033[K"
printf " ${GREEN}✓${RESET} Removed: ${BOLD}%s${RESET} ${DIM}freed %s${RESET}\n" \
"$label" "$(human "$before")"
TOTAL_FREED=$(( TOTAL_FREED + before ))
}
# ─── SCAN REGISTRY (file-based, no arrays) ──────────────────
# register: core scan function. Checks paths, measures size,
# stores results as files under $SCRATCH/.
#
# Usage: register <KEY> <label> <orphan:0|1> <GROUP> <path> [...]
#
# Files written:
# $SCRATCH/items/KEY → label
# $SCRATCH/sizes/KEY → bytes
# $SCRATCH/orphan/KEY → 0 or 1
# $SCRATCH/groups/GROUP → KEY appended as a line
# $SCRATCH/paths/KEY → one path per line
register() {
key="$1" label="$2" orphan="$3" group="$4"
shift 4
# All remaining args are paths
spinner_start "Scanning $label"
# Check if any path exists
found=0
for p in "$@"; do
[ -e "$p" ] && { found=1; break; }
done
if [ "$found" -eq 0 ]; then
spinner_skip "$label"
return 0
fi
# Measure total bytes
bytes=$(dir_bytes "$@")
if [ "$bytes" -eq 0 ]; then
spinner_skip "$label"
return 0
fi
spinner_done "$label" "$(human "$bytes")"
# Write to registry files
printf '%s' "$label" > "$SCRATCH/items/$key"
printf '%s' "$bytes" > "$SCRATCH/sizes/$key"
printf '%s' "$orphan" > "$SCRATCH/orphan/$key"
# Append key to group file (one key per line)
printf '%s\n' "$key" >> "$SCRATCH/groups/$group"
# Write paths (one per line) — handles spaces in paths
: > "$SCRATCH/paths/$key"
for p in "$@"; do
printf '%s\n' "$p" >> "$SCRATCH/paths/$key"
done
}
# get_size: read stored byte count for a key
get_size() { cat "$SCRATCH/sizes/$1" 2>/dev/null || printf '0'; }
# get_label: read stored label for a key
get_label() { cat "$SCRATCH/items/$1" 2>/dev/null || printf ''; }
# get_orphan: read orphan flag for a key
get_orphan() { cat "$SCRATCH/orphan/$1" 2>/dev/null || printf '0'; }
# key_exists: returns 0 if a key was registered
key_exists() { [ -f "$SCRATCH/items/$1" ]; }
# group_exists: returns 0 if a group has any registered keys
group_exists() { [ -f "$SCRATCH/groups/$1" ] && [ -s "$SCRATCH/groups/$1" ]; }
# group_total: sum all sizes for a group
group_total() {
grp="$1"
total=0
[ -f "$SCRATCH/groups/$grp" ] || { printf '0'; return; }
# Use fd 3 so this read does not consume the caller's stdin
while IFS= read -r key <&3; do
[ -f "$SCRATCH/sizes/$key" ] || continue
sz=$(cat "$SCRATCH/sizes/$key")
total=$(( total + sz ))
done 3< "$SCRATCH/groups/$grp"
printf '%s' "$total"
}
# group_label: human name for each group ID
group_label() {
case "$1" in
SYSTEM) printf '🖥 macOS System' ;;
BROWSERS) printf '🌐 Browsers' ;;
EDITORS) printf '✏️ Editor Caches' ;;
ORPHANS) printf '👻 Orphaned Configs' ;;
NODE) printf '📦 Node / JS Tooling' ;;
PYTHON) printf '🐍 Python Tooling' ;;
RUST) printf '🦀 Rust / Cargo' ;;
RUBY) printf '💎 Ruby / Gems' ;;
GO) printf '🐹 Go' ;;
JAVA) printf '☕ Java / JVM Tooling' ;;
MOBILE) printf '📱 Mobile Dev (iOS / Android)' ;;
DEVOPS) printf '⚙️ DevOps / Cloud / Infra' ;;
DOTNET) printf '🔷 .NET Tooling' ;;
PHP) printf '🐘 PHP Tooling' ;;
DATA) printf '🤖 Data / ML Tooling' ;;
DESIGN) printf '🎨 Design Tools' ;;
MACOS) printf '🍎 macOS Extras' ;;
*) printf '%s' "$1" ;;
esac
}
# GROUPS_ORDERED: space-separated list — used in all three phases
GROUPS_ORDERED="SYSTEM BROWSERS EDITORS ORPHANS NODE PYTHON RUST RUBY GO JAVA MOBILE DEVOPS DOTNET PHP DATA DESIGN MACOS"
# ════════════════════════════════════════════════════════════
# SETUP & SIGNAL HANDLING
# ════════════════════════════════════════════════════════════
init_scratch
# On exit (normal or Ctrl+C), kill spinner and remove temp files
trap 'spinner_stop; cleanup_scratch' EXIT
trap 'printf "\n Aborted.\n"; exit 1' INT
# ════════════════════════════════════════════════════════════
# BANNER
# ════════════════════════════════════════════════════════════
clear
printf "\n"
printf "${BOLD}╔══════════════════════════════════════════════════╗${RESET}\n"
printf "${BOLD}║ 🧹 macOS Developer Cleanup — v5.0 ║${RESET}\n"
printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n"
printf "${BOLD}║ Running as: ${RESET}%-33s${BOLD}║${RESET}\n" "$(whoami)"
printf "${BOLD}║ Shell: ${RESET}%-33s${BOLD}║${RESET}\n" "${SHELL##*/}"
printf "${BOLD}║ Home: ${RESET}%-33s${BOLD}║${RESET}\n" "$HOME"
printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n"
printf "${BOLD}║ ${YELLOW}⚠️ Only cleaning THIS user's files.${RESET}${BOLD} ║${RESET}\n"
printf "${BOLD}║ ${DIM}Other accounts need to run this separately.${RESET}${BOLD} ║${RESET}\n"
printf "${BOLD}╚══════════════════════════════════════════════════╝${RESET}\n"
# ════════════════════════════════════════════════════════════
# PHASE 1 — LIVE SCAN
#
# Every register() call:
# - Shows spinner while du measures the path
# - Prints ✔ + size if found, or - if absent/empty
# - Writes result to $SCRATCH file registry
# Nothing is deleted in this phase.
# ════════════════════════════════════════════════════════════
printf "\n"
printf "${BOLD}${BLUE} ▶ Phase 1 of 3 — Scanning your system${RESET}\n"
printf "${DIM} Every item is checked. ✔ = found, - = empty/absent.${RESET}\n\n"
# ── macOS System caches ──────────────────────────────────────
# Always safe to delete — macOS and apps rebuild on next launch.
printf " ${BOLD}${MAGENTA}🖥 macOS System${RESET}\n"
register SYS_CACHES "User Library Caches" 0 SYSTEM \
"$HOME/Library/Caches"
register SYS_LOGS "User Library Logs" 0 SYSTEM \
"$HOME/Library/Logs"
register SYS_HTTP "HTTP Storages" 0 SYSTEM \
"$HOME/Library/HTTPStorages"
# ~/.Trash — main user trash.
# macOS restricts direct access to ~/.Trash since Monterey — du and
# find both silently fail without Full Disk Access granted to Terminal.
# We use osascript to count items (runs in the user session, bypasses
# the restriction), then register the path manually if items exist.
_trash_count=$(osascript -e 'tell application "Finder" to get count of (items of trash)' 2>/dev/null || echo "0")
_trash_count="${_trash_count:-0}"
if [ "$_trash_count" -gt 0 ]; then
# Use 50MB as a conservative placeholder — we cannot measure the
# real size without Full Disk Access, but osascript confirmed items exist.
# The actual space freed will be reported after emptying.
_trash_bytes=$(( _trash_count * 1024 * 1024 ))
printf '%s' "Trash (~/.Trash — ${_trash_count} items)" > "$SCRATCH/items/SYS_TRASH"
printf '%s' "$_trash_bytes" > "$SCRATCH/sizes/SYS_TRASH"
printf '%s' "0" > "$SCRATCH/orphan/SYS_TRASH"
printf '%s
' "SYS_TRASH" >> "$SCRATCH/groups/SYSTEM"
printf '%s
' "$HOME/.Trash" > "$SCRATCH/paths/SYS_TRASH"
printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}
" "Trash (~/.Trash)" "${_trash_count} items (size needs Full Disk Access to measure)"
else
printf " ${DIM}- %-42s nothing found${RESET}
" "Trash (~/.Trash)"
fi
unset _trash_count _trash_bytes
# ~/Downloads — large files accumulate here over time.
# We register it as a browseable item (not auto-deleted) so the
# user can decide. Only shown if it contains more than 100MB.
_dl_bytes=$(du -sk "$HOME/Downloads" 2>/dev/null | awk '{print $1 * 1024}')
_dl_bytes="${_dl_bytes:-0}"
if [ "$_dl_bytes" -gt 104857600 ]; then
# Count items for context
_dl_count=$(find "$HOME/Downloads" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
_dl_count="${_dl_count:-0}"
printf '%s' "Downloads folder (~${_dl_count} items)" > "$SCRATCH/items/SYS_DOWNLOADS"
printf '%s' "$_dl_bytes" > "$SCRATCH/sizes/SYS_DOWNLOADS"
printf '%s' "0" > "$SCRATCH/orphan/SYS_DOWNLOADS"
printf '%s
' "SYS_DOWNLOADS" >> "$SCRATCH/groups/SYSTEM"
printf '%s
' "$HOME/Downloads" > "$SCRATCH/paths/SYS_DOWNLOADS"
printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}
" "Downloads folder" "$(human "$_dl_bytes") — ${_dl_count} items"
else
printf " ${DIM}- %-42s under 100 MB${RESET}
" "Downloads folder"
fi
unset _dl_bytes _dl_count
# ~/Desktop — developers often use the desktop as a temp dump:
# DMG installers, ZIP archives, screenshots, notes, random files.
# Only shown if over 50MB — small desktops are not worth flagging.
_dk_bytes=$(du -sk "$HOME/Desktop" 2>/dev/null | awk '{print $1 * 1024}')
_dk_bytes="${_dk_bytes:-0}"
if [ "$_dk_bytes" -gt 52428800 ]; then
_dk_count=$(find "$HOME/Desktop" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
_dk_count="${_dk_count:-0}"
printf '%s' "Desktop (~${_dk_count} items)" > "$SCRATCH/items/SYS_DESKTOP"
printf '%s' "$_dk_bytes" > "$SCRATCH/sizes/SYS_DESKTOP"
printf '%s' "0" > "$SCRATCH/orphan/SYS_DESKTOP"
printf '%s
' "SYS_DESKTOP" >> "$SCRATCH/groups/SYSTEM"
printf '%s
' "$HOME/Desktop" > "$SCRATCH/paths/SYS_DESKTOP"
printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}
" "Desktop" "$(human "$_dk_bytes") — ${_dk_count} items"
else
printf " ${DIM}- %-42s under 50 MB${RESET}
" "Desktop"
fi
unset _dk_bytes _dk_count
# External volumes: each mounted volume has a hidden .Trashes/$UID
# folder for files deleted from that volume. The glob must be
# expanded BEFORE calling register — globs inside quoted strings
# are never expanded by the shell.
# We build the argument list dynamically then call register.
# Build a list of external volume trash paths and measure them directly.
# We do NOT pass globs to register() — they must be pre-expanded.
_vol_trash_bytes=0
_vol_trash_paths=""
for _vol in /Volumes/*/; do
_tpath="${_vol}.Trashes/$UID"
if [ -d "$_tpath" ]; then
_b=$(du -sk "$_tpath" 2>/dev/null | awk '{print $1 * 1024}')
_b="${_b:-0}"
# Fallback if du is restricted by macOS Full Disk Access
if [ "$_b" -eq 0 ]; then
_c=$(find "$_tpath" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
_c="${_c:-0}"
[ "$_c" -gt 0 ] && _b=$(( _c * 4096 ))
fi
_vol_trash_bytes=$(( _vol_trash_bytes + _b ))
_vol_trash_paths="$_vol_trash_paths|$_tpath"
fi
done
if [ "$_vol_trash_bytes" -gt 0 ]; then
# Write registry entries manually (same format as register())
printf '%s' "Trash on external volumes" > "$SCRATCH/items/SYS_TRASH_VOL"
printf '%s' "$_vol_trash_bytes" > "$SCRATCH/sizes/SYS_TRASH_VOL"
printf '%s' "0" > "$SCRATCH/orphan/SYS_TRASH_VOL"
printf '%s\n' "SYS_TRASH_VOL" >> "$SCRATCH/groups/SYSTEM"
# Write paths — split on | separator
: > "$SCRATCH/paths/SYS_TRASH_VOL"
_remaining="$_vol_trash_paths"
while [ -n "$_remaining" ]; do
_remaining="${_remaining#|}"
_p="${_remaining%%|*}"
[ -n "$_p" ] && printf '%s\n' "$_p" >> "$SCRATCH/paths/SYS_TRASH_VOL"
_remaining="${_remaining#$_p}"
done
printf " ${GREEN}✔${RESET} %-42s ${DIM}%s${RESET}\n" \
"Trash on external volumes" "$(human "$_vol_trash_bytes")"
else
printf " ${DIM}- %-42s nothing found${RESET}\n" "Trash on external volumes"
fi
unset _vol _tpath _b _c _vol_trash_bytes _vol_trash_paths _p _remaining
register SYS_MAIL "Apple Mail Downloads" 0 SYSTEM \
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
# ── Browsers ────────────────────────────────────────────────
# Browser caches are network/render caches only — no user data.
# Pages may load slightly slower after first visit post-clean.
printf "\n ${BOLD}${MAGENTA}🌐 Browsers${RESET}\n"
register BR_CHROME "Chrome caches" 0 BROWSERS \
"$HOME/Library/Application Support/Google/Chrome/Default/Cache" \
"$HOME/Library/Application Support/Google/Chrome/Default/Code Cache" \
"$HOME/Library/Application Support/Google/Chrome/Default/GPUCache"
register BR_SAFARI "Safari caches" 0 BROWSERS \
"$HOME/Library/Containers/com.apple.Safari/Data/Library/Caches"
register BR_FIREFOX "Firefox caches" 0 BROWSERS \
"$HOME/Library/Caches/Firefox"
register BR_ARC "Arc caches" 0 BROWSERS \
"$HOME/Library/Application Support/Arc/User Data/Default/Cache" \
"$HOME/Library/Application Support/Arc/User Data/Default/Code Cache"
register BR_BRAVE "Brave caches" 0 BROWSERS \
"$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cache" \
"$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Code Cache"
# ── Editor caches ───────────────────────────────────────────
# VS Code-based editors store compiled extensions, GPU shaders,
# workspace indexes, and logs. All safe to clear — rebuilt on
# next open (editor may feel slower for a moment).
printf "\n ${BOLD}${MAGENTA}✏️ Editor Caches${RESET}\n"
register ED_VSCODE "VS Code caches & logs" 0 EDITORS \
"$HOME/Library/Application Support/Code/Cache" \
"$HOME/Library/Application Support/Code/CachedData" \
"$HOME/Library/Application Support/Code/CachedExtensions" \
"$HOME/Library/Application Support/Code/GPUCache" \
"$HOME/Library/Application Support/Code/logs" \
"$HOME/Library/Application Support/Code/workspaceStorage"
register ED_CURSOR "Cursor caches & logs" 0 EDITORS \
"$HOME/Library/Application Support/Cursor/Cache" \
"$HOME/Library/Application Support/Cursor/CachedData" \
"$HOME/Library/Application Support/Cursor/CachedExtensions" \
"$HOME/Library/Application Support/Cursor/GPUCache" \
"$HOME/Library/Application Support/Cursor/logs" \
"$HOME/Library/Application Support/Cursor/workspaceStorage"
register ED_WINDSURF "Windsurf caches & logs" 0 EDITORS \
"$HOME/Library/Application Support/Windsurf/Cache" \
"$HOME/Library/Application Support/Windsurf/CachedData" \
"$HOME/Library/Application Support/Windsurf/GPUCache" \
"$HOME/Library/Application Support/Windsurf/logs" \
"$HOME/Library/Application Support/Windsurf/workspaceStorage"
register ED_ZED "Zed caches & logs" 0 EDITORS \
"$HOME/Library/Caches/Zed" \
"$HOME/Library/Logs/Zed"
register ED_SUBLIME "Sublime Text caches" 0 EDITORS \
"$HOME/Library/Caches/com.sublimetext.4" \
"$HOME/Library/Application Support/Sublime Text/Cache" \
"$HOME/Library/Application Support/Sublime Text/Index"
register ED_NOVA "Nova (Panic) caches" 0 EDITORS \
"$HOME/Library/Caches/com.panic.Nova"
register ED_VIM "Vim / Neovim swap & undo files" 0 EDITORS \
"$HOME/.vim/swap" "$HOME/.vim/undo" "$HOME/.vim/backup" \
"$HOME/.local/share/nvim/swap" \
"$HOME/.local/share/nvim/undo" \
"$HOME/.local/share/nvim/backup" \
"$HOME/.cache/nvim"
register ED_EMACS "Emacs caches" 0 EDITORS \
"$HOME/.emacs.d/.cache" \
"$HOME/.emacs.d/eln-cache" \
"$HOME/.cache/emacs"
register ED_HELIX "Helix caches" 0 EDITORS \
"$HOME/Library/Caches/helix" \
"$HOME/.cache/helix"
register ED_JETBRAINS "JetBrains IDE caches" 0 EDITORS \
"$HOME/Library/Caches/JetBrains"
register ED_XCODE "Xcode DerivedData" 0 EDITORS \
"$HOME/Library/Developer/Xcode/DerivedData"
register ED_XCODE_DEV "Xcode old device support files" 0 EDITORS \
"$HOME/Library/Developer/Xcode/iOS DeviceSupport" \
"$HOME/Library/Developer/Xcode/watchOS DeviceSupport" \
"$HOME/Library/Developer/Xcode/tvOS DeviceSupport"
# ── Orphaned configs ────────────────────────────────────────
# When you uninstall an app on macOS its support folders stay
# behind — macOS does not auto-remove them. We only flag a path
# as orphaned when BOTH the .app bundle AND the CLI are absent.
printf "\n ${BOLD}${MAGENTA}👻 Orphaned Configs (app uninstalled)${RESET}\n"
# Cursor: check .app, `cursor` CLI, and common non-standard paths
if ! app_installed "Cursor" \
&& ! cmd_exists cursor \
&& [ ! -f "/usr/local/bin/cursor" ] \
&& [ ! -f "$HOME/.local/bin/cursor" ]; then
register ORPH_CURSOR "Cursor leftover config" 1 ORPHANS \
"$HOME/Library/Application Support/Cursor" \
"$HOME/.cursor"
fi
# Windsurf: check .app, ~/Applications, and `windsurf` CLI
if ! app_installed "Windsurf" \
&& ! cmd_exists windsurf; then
register ORPH_WINDSURF "Windsurf leftover config" 1 ORPHANS \
"$HOME/Library/Application Support/Windsurf"
fi
# Zed: check .app and `zed` CLI
if ! app_installed "Zed" && ! cmd_exists zed; then
register ORPH_ZED "Zed leftover config" 1 ORPHANS \
"$HOME/.config/zed"
fi
# Sublime Text: check .app and `subl` CLI shim
if ! app_installed "Sublime Text" && ! cmd_exists subl; then
register ORPH_SUBLIME "Sublime Text leftover data" 1 ORPHANS \
"$HOME/Library/Application Support/Sublime Text"
fi
# Neovim: CLI only
if ! cmd_exists nvim; then
register ORPH_NVIM "Neovim leftover config" 1 ORPHANS \
"$HOME/.config/nvim" \
"$HOME/.local/share/nvim"
fi
# Helix: CLI only
if ! cmd_exists hx; then
register ORPH_HELIX "Helix leftover config" 1 ORPHANS \
"$HOME/.config/helix"
fi
# Emacs: CLI only
if ! cmd_exists emacs; then
register ORPH_EMACS "Emacs leftover config" 1 ORPHANS \
"$HOME/.emacs.d" \
"$HOME/.config/emacs"
fi
# JetBrains: managed via Toolbox
if ! app_installed "JetBrains Toolbox"; then
register ORPH_JETBRAINS "JetBrains leftover support data" 1 ORPHANS \
"$HOME/Library/Application Support/JetBrains"
fi
# VS Code: check `code` CLI
if ! cmd_exists code && [ -d "$HOME/.vscode" ]; then
register ORPH_VSCODE "VS Code leftover ~/.vscode" 1 ORPHANS \
"$HOME/.vscode"
fi
# ── Node / JS tooling ───────────────────────────────────────
# All package manager caches — 100% safe to clear.
# The next install re-downloads what it needs.
printf "\n ${BOLD}${MAGENTA}📦 Node / JS Tooling${RESET}\n"
register JS_NPM "npm cache" 0 NODE \
"$HOME/.npm/_cacache"
register JS_YARN1 "Yarn v1 cache" 0 NODE \
"$HOME/.cache/yarn"
register JS_YARNB "Yarn Berry cache (v2/v3/v4)" 0 NODE \
"$HOME/.yarn/berry/cache"
register JS_PNPM "pnpm store" 0 NODE \
"$HOME/.pnpm-store" \
"$HOME/.local/share/pnpm/store"
register JS_NVM "nvm download cache" 0 NODE \
"$HOME/.nvm/.cache"
register JS_BUN "Bun cache" 0 NODE \
"$HOME/.bun/install/cache"
register JS_DENO "Deno cache" 0 NODE \
"$HOME/Library/Caches/deno"
# ── Python tooling ──────────────────────────────────────────
printf "\n ${BOLD}${MAGENTA}🐍 Python Tooling${RESET}\n"
register PY_PIP "pip cache" 0 PYTHON \
"$HOME/Library/Caches/pip"
register PY_POETRY "Poetry cache" 0 PYTHON \
"$HOME/Library/Caches/pypoetry"
register PY_PYENV "pyenv download cache" 0 PYTHON \
"$HOME/.pyenv/cache"
register PY_UV "uv cache (Astral)" 0 PYTHON \
"$HOME/.cache/uv" \
"$HOME/Library/Caches/uv"
# ── Rust / Cargo ────────────────────────────────────────────
# Clears downloaded crate cache — NOT compiled target/ dirs.
# Per-project cleanup needs `cargo clean` inside each project.
printf "\n ${BOLD}${MAGENTA}🦀 Rust / Cargo${RESET}\n"
register RS_CARGO "Cargo registry & git cache" 0 RUST \
"$HOME/.cargo/registry/cache" \
"$HOME/.cargo/git/db"
# ── Ruby / Gems ─────────────────────────────────────────────
printf "\n ${BOLD}${MAGENTA}💎 Ruby / Gems${RESET}\n"
register RB_GEM "gem cache" 0 RUBY \
"$HOME/.gem"
register RB_BUNDLE "Bundler cache" 0 RUBY \
"$HOME/.bundle/cache"
# ── Go ──────────────────────────────────────────────────────
printf "\n ${BOLD}${MAGENTA}🐹 Go${RESET}\n"
register GO_MOD "Go module cache" 0 GO \
"$HOME/go/pkg/mod/cache"
# ── Java / JVM ──────────────────────────────────────────────
# Maven and Gradle cache downloaded jars/plugins globally.
# Safe to clear — next build re-downloads what it needs.
# SBT (Scala) and Ivy share similar caches.
printf "\n ${BOLD}${MAGENTA}☕ Java / JVM Tooling${RESET}\n"
register JVM_MAVEN "Maven local repository cache" 0 JAVA \
"$HOME/.m2/repository"
# .m2/settings.xml is NOT touched — only the downloaded jars
register JVM_GRADLE "Gradle caches" 0 JAVA \
"$HOME/.gradle/caches" \
"$HOME/.gradle/wrapper/dists"
# wrapper/dists = downloaded Gradle wrapper versions
register JVM_SBT "sbt / Ivy cache" 0 JAVA \
"$HOME/.sbt" \
"$HOME/.ivy2/cache"
register JVM_KOTLIN "Kotlin compiler cache" 0 JAVA \
"$HOME/.konan/cache" \
"$HOME/Library/Caches/KotlinIDE"
# ── Mobile Development ──────────────────────────────────────
# Xcode simulators, Android build caches, Flutter artifacts,
# CocoaPods download cache, and React Native Metro bundler.
printf "\n ${BOLD}${MAGENTA}📱 Mobile Dev${RESET}\n"
register MOB_SIM "Xcode iOS Simulators (unused runtimes)" 0 MOBILE \
"$HOME/Library/Developer/CoreSimulator/Caches"
# Full simulator runtimes (can be 5-15GB) — delete via Xcode > Preferences
register MOB_SIM_LOGS "Simulator logs & crashed reports" 0 MOBILE \
"$HOME/Library/Logs/CoreSimulator"
register MOB_ANDROID_CACHE "Android SDK build cache" 0 MOBILE \
"$HOME/.android/cache" \
"$HOME/Library/Android/sdk/.temp"
register MOB_GRADLE_ANDROID "Android Gradle build cache" 0 MOBILE \
"$HOME/.gradle/caches/build-cache-1"
# Android-specific Gradle build cache — often very large
register MOB_FLUTTER "Flutter tool cache" 0 MOBILE \
"$HOME/.pub-cache/hosted" \
"$HOME/Library/Caches/flutter_tools"
# pub-cache/hosted = downloaded pub packages
register MOB_COCOAPODS "CocoaPods download cache" 0 MOBILE \
"$HOME/Library/Caches/CocoaPods"
# Pod source archives — rebuilt on next `pod install`
register MOB_RN_METRO "React Native Metro bundler cache" 0 MOBILE \
"$HOME/Library/Caches/com.facebook.ReactNativeBuild" \
"/tmp/metro-*" \
"/tmp/haste-map-*"
# ── DevOps / Cloud / Infra ──────────────────────────────────
# Terraform plugin cache, AWS/GCP/Azure CLI caches,
# kubectl, Helm chart cache, Ansible collections.
printf "\n ${BOLD}${MAGENTA}⚙️ DevOps / Cloud / Infra${RESET}\n"
register OPS_TERRAFORM "Terraform plugin cache" 0 DEVOPS \
"$HOME/.terraform.d/plugin-cache" \
"$HOME/.terraform/providers"
# Provider binaries — re-downloaded on next terraform init
register OPS_AWS "AWS CLI cache" 0 DEVOPS \
"$HOME/.aws/cli/cache" \
"$HOME/Library/Caches/pip/wheels"
# SSO token cache and pip wheels used during awscli install
register OPS_GCLOUD "Google Cloud SDK cache" 0 DEVOPS \
"$HOME/.config/gcloud/logs" \
"$HOME/Library/Caches/google-cloud-sdk"
register OPS_AZURE "Azure CLI cache & logs" 0 DEVOPS \
"$HOME/.azure/logs" \
"$HOME/.azure/telemetry"
register OPS_HELM "Helm chart & repository cache" 0 DEVOPS \
"$HOME/Library/Caches/helm" \
"$HOME/.cache/helm"
register OPS_KUBECTL "kubectl cache" 0 DEVOPS \
"$HOME/.kube/cache" \
"$HOME/.kube/http-cache"
register OPS_VAGRANT "Vagrant box cache" 0 DEVOPS \
"$HOME/.vagrant.d/tmp" \
"$HOME/.vagrant.d/gems"
register OPS_ANSIBLE "Ansible collections & cache" 0 DEVOPS \
"$HOME/.ansible/tmp" \
"$HOME/.cache/ansible-compat"
# ── .NET ────────────────────────────────────────────────────
# NuGet package cache and dotnet tool/SDK temp files.
printf "\n ${BOLD}${MAGENTA}🔷 .NET Tooling${RESET}\n"
register NET_NUGET "NuGet package cache" 0 DOTNET \
"$HOME/.nuget/packages" \
"$HOME/Library/Caches/NuGetPackages"
# Safe to clear — restored on next dotnet build/restore
register NET_DOTNET "dotnet temp & telemetry" 0 DOTNET \
"$HOME/.dotnet/toolResolverCache" \
"$HOME/.dotnet/TelemetryStorageService"
# ── PHP ─────────────────────────────────────────────────────
# Composer caches downloaded package archives and metadata.
printf "\n ${BOLD}${MAGENTA}🐘 PHP / Composer${RESET}\n"
register PHP_COMPOSER "Composer cache" 0 PHP \
"$HOME/.composer/cache" \
"$HOME/.cache/composer"
# composer cache clear is the native command — rm is equivalent
# ── Data / ML ───────────────────────────────────────────────
# Conda/Mamba package cache, Hugging Face model downloads,
# Jupyter runtime files, and ML framework caches.
printf "\n ${BOLD}${MAGENTA}🤖 Data / ML Tooling${RESET}\n"
register DATA_CONDA "Conda / Mamba package cache" 0 DATA \
"$HOME/opt/anaconda3/pkgs" \
"$HOME/opt/miniconda3/pkgs" \
"$HOME/mambaforge/pkgs" \
"$HOME/.conda/pkgs"
# Downloaded conda package archives — safe to purge
register DATA_HF "Hugging Face model cache" 0 DATA \
"$HOME/.cache/huggingface/hub"
# Downloaded model weights — can be very large (GB per model)
# Only remove if you are OK re-downloading models you use
register DATA_TORCH "PyTorch / TorchVision download cache" 0 DATA \
"$HOME/.cache/torch/hub" \
"$HOME/.cache/torch/kernels"
register DATA_JUPYTER "Jupyter runtime & nbconvert cache" 0 DATA \
"$HOME/Library/Jupyter/runtime" \
"$HOME/.local/share/jupyter/runtime" \
"$HOME/.cache/jupyter"
register DATA_KERAS "Keras dataset cache" 0 DATA \
"$HOME/.keras/datasets"
# ── Design Tools ────────────────────────────────────────────
# Figma, Sketch, Adobe XD and Framer keep large local caches
# for fonts, offline rendering, and version history.
printf "\n ${BOLD}${MAGENTA}🎨 Design Tools${RESET}\n"
register DSN_FIGMA "Figma local cache" 0 DESIGN \
"$HOME/Library/Application Support/Figma/Desktop/Cache" \
"$HOME/Library/Application Support/Figma/Desktop/Code Cache" \
"$HOME/Library/Application Support/Figma/Desktop/GPUCache"
# Figma re-fetches files from cloud on next open
register DSN_SKETCH "Sketch caches" 0 DESIGN \
"$HOME/Library/Caches/com.bohemiancoding.sketch3" \
"$HOME/Library/Application Support/com.bohemiancoding.sketch3/ComponentCache"
register DSN_ADOBE "Adobe Creative Cloud cache" 0 DESIGN \
"$HOME/Library/Application Support/Adobe/Common/Media Cache Files" \
"$HOME/Library/Application Support/Adobe/Common/Media Cache" \
"$HOME/Library/Caches/Adobe"
# Media encoder and preview cache — safe to remove
register DSN_FRAMER "Framer cache" 0 DESIGN \
"$HOME/Library/Application Support/Framer/Cache" \
"$HOME/Library/Application Support/Framer/Code Cache"
# ── macOS extras ────────────────────────────────────────────
printf "\n ${BOLD}${MAGENTA}🍎 macOS Extras${RESET}\n"
register MAC_QL "QuickLook thumbnail cache" 0 MACOS \
"$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache"
# ════════════════════════════════════════════════════════════
# PHASE 2 — REPORT
#
# Reads from the $SCRATCH file registry built in Phase 1.
# Prints grouped table with sizes and orphan flags.
# ════════════════════════════════════════════════════════════
# Compute grand total
GRAND_TOTAL=0
for g in $GROUPS_ORDERED; do
group_exists "$g" || continue
gt=$(group_total "$g")
GRAND_TOTAL=$(( GRAND_TOTAL + gt ))
printf '%s' "$gt" > "$SCRATCH/gtotals/$g"
done
printf "\n\n"
printf "${BOLD}${BLUE} ▶ Phase 2 of 3 — Scan Report${RESET}\n\n"
printf "${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}\n"
printf "${BOLD}│ 📊 FOUND ON DISK │${RESET}\n"
printf "${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}\n"
for g in $GROUPS_ORDERED; do
group_exists "$g" || continue
gt=$(cat "$SCRATCH/gtotals/$g" 2>/dev/null || printf '0')
[ "$gt" -eq 0 ] && continue
sc=$(size_color "$gt")
glabel=$(group_label "$g")
printf "\n ${BOLD}%s${RESET} %b${BOLD}%s${RESET}\n" "$glabel" "$sc" "$(human "$gt")"
# Print each item in this group
# Use fd 3 to read group file so it does not consume stdin
while IFS= read -r key <&3; do
key_exists "$key" || continue
sz=$(get_size "$key")
label=$(get_label "$key")
orphan=$(get_orphan "$key")
sc=$(size_color "$sz")
orphan_tag=""
[ "$orphan" = "1" ] && orphan_tag=" [ORPHAN — leftover from uninstalled app]"
# Use %b for the color variable so \033 sequences are interpreted
printf " %b%10s%b %s%s\n" "$sc" "$(human "$sz")" "$RESET" "$label" "$orphan_tag"
done 3< "$SCRATCH/groups/$g"
done
printf "\n"
printf "${BOLD} ─────────────────────────────────────────────────────────────────${RESET}\n"
sc=$(size_color "$GRAND_TOTAL")
printf " ${BOLD}💾 Total recoverable: %b%s${RESET}\n" "$sc" "$(human "$GRAND_TOTAL")"
printf " ${DIM} Running as: %s — only this user's files are shown${RESET}\n" "$(whoami)"
printf "${BOLD} ─────────────────────────────────────────────────────────────────${RESET}\n\n"
if [ "$GRAND_TOTAL" -eq 0 ]; then
printf " ${GREEN}✅ Nothing to clean — your system is already tidy!${RESET}\n\n"
exit 0
fi
printf " Continue to interactive cleanup? [y/n]: "
read -r ans
case "$ans" in
[Yy]*) ;;
*) printf " Aborted. Nothing was deleted.\n"; exit 0 ;;
esac
# ════════════════════════════════════════════════════════════
# PHASE 3 — INTERACTIVE CLEAN
#
# Iterates groups in same order as report.
# SYSTEM group is auto-cleaned (always safe).
# All others prompt y/n with size shown.
# ════════════════════════════════════════════════════════════
printf "\n"
printf "${BOLD}${BLUE} ▶ Phase 3 of 3 — Interactive Cleanup${RESET}\n"
printf "${DIM} Answer y/n for each item. Size is shown to help you decide.${RESET}\n"
for g in $GROUPS_ORDERED; do
group_exists "$g" || continue
gt=$(cat "$SCRATCH/gtotals/$g" 2>/dev/null || printf '0')
[ "$gt" -eq 0 ] && continue
printf "\n ${BOLD}%s${RESET}\n" "$(group_label "$g")"
# SYSTEM: auto-clean, no prompt
if [ "$g" = "SYSTEM" ]; then
printf " ${DIM} System caches are always safe — cleaning automatically...${RESET}\n"
while IFS= read -r key <&3; do
key_exists "$key" || continue
label=$(get_label "$key")
paths_file="$SCRATCH/paths/$key"
[ -f "$paths_file" ] || continue
# Trash items get special handling — ask the user and use
# osascript to empty via Finder (handles locked files properly)
if [ "$key" = "SYS_TRASH" ] || [ "$key" = "SYS_TRASH_VOL" ]; then
sz=$(get_size "$key")
size_str=$(printf "%b%s%b" "$YELLOW" "$(human "$sz")" "$RESET")
if ask "Empty Trash ($label) $size_str"; then
printf " ${CYAN}⟳${RESET} Emptying Trash via Finder..."
osascript -e 'tell application "Finder" to empty trash' >/dev/null 2>&1 || true
# Fallback rm in case Finder is not running
set --
while IFS= read -r p <&4; do
set -- "$@" "$p"
done 4< "$paths_file"
for p in "$@"; do rm -rf "$p" 2>/dev/null || true; done
printf "\r\033[K ${GREEN}✓${RESET} Trash emptied\n"
else
printf " ${DIM} ↳ Skipped${RESET}\n"
fi
continue
fi
# Downloads: this is PERSONAL DATA not a cache — maximum warning
if [ "$key" = "SYS_DOWNLOADS" ]; then
sz=$(get_size "$key")
_label=$(get_label "$key")
_red=$(printf "%b" "$RED")
_yellow=$(printf "%b" "$YELLOW")
_bold=$(printf "%b" "$BOLD")
_dim=$(printf "%b" "$DIM")
_reset=$(printf "%b" "$RESET")
printf "\n"
printf " %b╔══════════════════════════════════════════════╗%b\n" "$RED" "$RESET"
printf " %b║ 🚨 DANGER — PERSONAL FILES 🚨 ║%b\n" "$RED" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ 📁 ~/Downloads ║%b\n" "$RED" "$RESET"
printf " %b║ 📦 %s%-40s%b%b║%b\n" "$RED" "$BOLD" "$_label" "$RESET" "$RED" "$RESET"
printf " %b║ 💾 Size: %-34s║%b\n" "$RED" "$(human "$sz")" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ ⚠️ This folder may contain: ║%b\n" "$RED" "$RESET"
printf " %b║ • Files you still need ║%b\n" "$RED" "$RESET"
printf " %b║ • Installers you haven't run yet ║%b\n" "$RED" "$RESET"
printf " %b║ • Documents sent by others ║%b\n" "$RED" "$RESET"
printf " %b║ • Backups and archives ║%b\n" "$RED" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ ❌ THIS CANNOT BE UNDONE ║%b\n" "$RED" "$RESET"
printf " %b╚══════════════════════════════════════════════╝%b\n" "$RED" "$RESET"
printf "\n"
_open_hint=$(printf "%b%s%b" "$DIM" "(recommended before deleting)" "$RESET")
if ask "Open ~/Downloads in Finder to review first? $_open_hint"; then
osascript -e 'tell application "Finder" to open folder ((path to downloads folder) as string)' >/dev/null 2>&1 || true
open "$HOME/Downloads" 2>/dev/null || true
printf " ${DIM} Finder opened — press Enter when ready to continue...${RESET}"
read -r _dummy
fi
_warn=$(printf "%b%s%b" "$RED" "⚠️ YES, DELETE EVERYTHING IN ~/Downloads PERMANENTLY" "$RESET")
if ask "$_warn"; then
printf " ${CYAN}⟳${RESET} Clearing Downloads..."
rm -rf "$HOME/Downloads"/* "$HOME/Downloads"/.[!.]* 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} Downloads cleared\n"
else
printf " ${GREEN}✓${RESET} Smart choice — Downloads kept.\n"
fi
printf "\n"
continue
fi
# Desktop: PERSONAL DATA — same maximum warning as Downloads
if [ "$key" = "SYS_DESKTOP" ]; then
sz=$(get_size "$key")
_label=$(get_label "$key")
_red=$(printf "%b" "$RED")
_reset=$(printf "%b" "$RESET")
printf "\n"
printf " %b╔══════════════════════════════════════════════╗%b\n" "$RED" "$RESET"
printf " %b║ 🚨 DANGER — PERSONAL FILES 🚨 ║%b\n" "$RED" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ 🖥️ ~/Desktop ║%b\n" "$RED" "$RESET"
printf " %b║ 📦 %s%-40s%b%b║%b\n" "$RED" "$BOLD" "$_label" "$RESET" "$RED" "$RESET"
printf " %b║ 💾 Size: %-34s║%b\n" "$RED" "$(human "$sz")" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ ⚠️ This folder may contain: ║%b\n" "$RED" "$RESET"
printf " %b║ • DMG installers not yet installed ║%b\n" "$RED" "$RESET"
printf " %b║ • Screenshots with sensitive info ║%b\n" "$RED" "$RESET"
printf " %b║ • Notes and temporary documents ║%b\n" "$RED" "$RESET"
printf " %b║ • ZIP archives not yet extracted ║%b\n" "$RED" "$RESET"
printf " %b╠══════════════════════════════════════════════╣%b\n" "$RED" "$RESET"
printf " %b║ ❌ THIS CANNOT BE UNDONE ║%b\n" "$RED" "$RESET"
printf " %b╚══════════════════════════════════════════════╝%b\n" "$RED" "$RESET"
printf "\n"
_open_hint=$(printf "%b%s%b" "$DIM" "(recommended before deleting)" "$RESET")
if ask "Open ~/Desktop in Finder to review first? $_open_hint"; then
osascript -e 'tell application "Finder" to open folder ((path to desktop folder) as string)' >/dev/null 2>&1 || true
open "$HOME/Desktop" 2>/dev/null || true
printf " ${DIM} Finder opened — press Enter when ready to continue...${RESET}"
read -r _dummy
fi
_warn=$(printf "%b%s%b" "$RED" "⚠️ YES, DELETE EVERYTHING ON ~/Desktop PERMANENTLY" "$RESET")
if ask "$_warn"; then
printf " ${CYAN}⟳${RESET} Clearing Desktop..."
rm -rf "$HOME/Desktop"/* "$HOME/Desktop"/.[!.]* 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} Desktop cleared\n"
else
printf " ${GREEN}✓${RESET} Smart choice — Desktop kept.\n"
fi
printf "\n"
continue
fi
# All other SYSTEM items: auto-clean without asking
set --
while IFS= read -r p <&4; do
set -- "$@" "$p"
done 4< "$paths_file"
do_rm "$label" "$@"
done 3< "$SCRATCH/groups/$g"
# Sweep container caches (sandboxed app caches under ~/Library)
# These are per-app sandboxed folders — only Caches/ and tmp/ subfolders
# are removed, never the app's actual data directories.
printf " ${CYAN}⟳${RESET} Sweeping app container caches..."
find "$HOME/Library/Containers" -type d -name "Caches" \
-exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true
find "$HOME/Library/Containers" -type d -name "tmp" \
-exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true
find "$HOME/Library/Group Containers" -type d -name "Caches" \
-exec sh -c 'rm -rf "$1"/* 2>/dev/null' _ {} \; 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} App container caches swept\n"
continue
fi
# All other groups: prompt per item
# We read from the groups file using a fd (file descriptor) redirect
# on the while loop itself — this keeps stdin free for `read` inside ask().
while IFS= read -r key <&3; do
key_exists "$key" || continue
sz=$(get_size "$key")
label=$(get_label "$key")
orphan=$(get_orphan "$key")
sc=$(size_color "$sz")
orphan_tag=""
[ "$orphan" = "1" ] && orphan_tag=" [ORPHAN]"
# Print size with color using printf %b which interprets \033 sequences
size_str=$(printf "%b%s%b" "$sc" "$(human "$sz")" "$RESET")
if ask "$label$orphan_tag $size_str"; then
paths_file="$SCRATCH/paths/$key"
set --
while IFS= read -r p <&4; do
set -- "$@" "$p"
done 4< "$paths_file"
do_rm "$label" "$@"
else
printf " ${DIM} ↳ Skipped${RESET}\n"
fi
done 3< "$SCRATCH/groups/$g"
done
# ── Docker ───────────────────────────────────────────────────
printf "\n ${BOLD}🐳 Docker${RESET}\n"
if cmd_exists docker && docker info >/dev/null 2>&1; then
if ask "docker system prune — stopped containers, dangling images, unused networks"; then
printf " ${CYAN}⟳${RESET} Running docker system prune..."
docker system prune -f >/dev/null 2>&1 || true
printf "\r\033[K ${GREEN}✓${RESET} Docker system pruned\n"
fi
_hint=$(printf "%b%s%b" "$DIM" "(can free several GB)" "$RESET")
if ask "Remove ALL unused Docker images $_hint"; then
printf " ${CYAN}⟳${RESET} Pruning all unused images..."
docker image prune -af >/dev/null 2>&1 || true
printf "\r\033[K ${GREEN}✓${RESET} All unused images removed\n"
fi
_hint=$(printf "%b%s%b" "$YELLOW" "⚠️ skip if you store local DB data in Docker" "$RESET")
if ask "Remove unused Docker volumes $_hint"; then
printf " ${CYAN}⟳${RESET} Pruning volumes..."
docker volume prune -f >/dev/null 2>&1 || true
printf "\r\033[K ${GREEN}✓${RESET} Unused volumes removed\n"
fi
else
printf " ${DIM} Docker not running or not installed — skipped.${RESET}\n"
fi
# ── Homebrew ─────────────────────────────────────────────────
printf "\n ${BOLD}🍺 Homebrew${RESET}\n"
if cmd_exists brew; then
_hint=$(printf "%b%s%b" "$DIM" "(removes old formula versions)" "$RESET")
if ask "brew cleanup --prune=all + autoremove $_hint"; then
printf " ${CYAN}⟳${RESET} Running brew cleanup (may take a moment)..."
brew cleanup --prune=all >/dev/null 2>&1 || true
brew autoremove >/dev/null 2>&1 || true
b=$(dir_bytes "$HOME/Library/Caches/Homebrew")
rm -rf "$HOME/Library/Caches/Homebrew" 2>/dev/null || true
TOTAL_FREED=$(( TOTAL_FREED + b ))
printf "\r\033[K ${GREEN}✓${RESET} Homebrew cleaned — freed %s\n" "$(human "$b")"
fi
else
printf " ${DIM} Homebrew not found — skipped.${RESET}\n"
fi
# ── Package manager native purge commands ────────────────────
# Some tools maintain internal metadata beyond what du captures.
# Run their own purge commands for a thorough clean.
printf "\n ${CYAN}⟳${RESET} Running package manager purge commands..."
{ cmd_exists npm && key_exists JS_NPM && npm cache clean --force; } >/dev/null 2>&1 || true
{ cmd_exists yarn && key_exists JS_YARN1 && yarn cache clean; } >/dev/null 2>&1 || true
{ cmd_exists pnpm && key_exists JS_PNPM && pnpm store prune; } >/dev/null 2>&1 || true
{ cmd_exists pip && key_exists PY_PIP && pip cache purge; } >/dev/null 2>&1 || true
{ cmd_exists poetry && key_exists PY_POETRY && poetry cache clear --all pypi; } >/dev/null 2>&1 || true
{ cmd_exists go && key_exists GO_MOD && go clean -modcache; } >/dev/null 2>&1 || true
{ cmd_exists gem && key_exists RB_GEM && gem cleanup; } >/dev/null 2>&1 || true
{ cmd_exists gradle && key_exists JVM_GRADLE && gradle --stop; } >/dev/null 2>&1 || true
{ cmd_exists mvn && key_exists JVM_MAVEN && mvn dependency:purge-local-repository; } >/dev/null 2>&1 || true
{ cmd_exists flutter && key_exists MOB_FLUTTER && flutter pub cache clean; } >/dev/null 2>&1 || true
{ cmd_exists conda && key_exists DATA_CONDA && conda clean --all --yes; } >/dev/null 2>&1 || true
{ cmd_exists dotnet && key_exists NET_NUGET && dotnet nuget locals all --clear; } >/dev/null 2>&1 || true
{ cmd_exists composer && key_exists PHP_COMPOSER && composer clear-cache; } >/dev/null 2>&1 || true
{ cmd_exists helm && key_exists OPS_HELM && helm repo update; } >/dev/null 2>&1 || true
printf "\r\033[K ${GREEN}✓${RESET} Package manager indexes updated\n"
# ── Jupyter checkpoints ──────────────────────────────────────
# .ipynb_checkpoints folders accumulate silently in every notebook
# directory. Safe to delete — Jupyter recreates them on next edit.
printf "\n"
if ask "🔬 Jupyter — remove all .ipynb_checkpoints folders (project-wide search)"; then
printf " ${CYAN}⟳${RESET} Searching for Jupyter checkpoint folders..."
count=$(find "$HOME" -type d -name ".ipynb_checkpoints" \
-not -path "*/.Trash/*" 2>/dev/null | wc -l | tr -d ' ')
find "$HOME" -type d -name ".ipynb_checkpoints" \
-not -path "*/.Trash/*" \
-exec rm -rf {} + 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} Removed %s checkpoint folder(s)\n" "$count"
fi
# ── macOS system-level operations ───────────────────────────
printf "\n ${BOLD}🍎 macOS Extras${RESET}\n"
if ask "Flush DNS cache?"; then
printf " ${CYAN}⟳${RESET} Flushing DNS cache..."
sudo dscacheutil -flushcache 2>/dev/null || true
sudo killall -HUP mDNSResponder 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} DNS cache flushed\n"
fi
_hint=$(printf "%b%s%b" "$DIM" "(runs in background, takes a few minutes)" "$RESET")
if ask "Rebuild Spotlight index? $_hint"; then
printf " ${CYAN}⟳${RESET} Triggering Spotlight reindex..."
sudo mdutil -E / 2>/dev/null || true
printf "\r\033[K ${GREEN}✓${RESET} Spotlight reindex triggered (running in background)\n"
fi
# ════════════════════════════════════════════════════════════
# SUMMARY
# ════════════════════════════════════════════════════════════
printf "\n"
printf "${BOLD}╔══════════════════════════════════════════════════╗${RESET}\n"
printf "${BOLD}║ ✅ All Done! ║${RESET}\n"
printf "${BOLD}║ 💾 Freed: %-36s║${RESET}\n" \
"$(human "$TOTAL_FREED") (of $(human "$GRAND_TOTAL") found)"
printf "${BOLD}╠══════════════════════════════════════════════════╣${RESET}\n"
printf "${BOLD}║ 👉 Restart your Mac to reflect freed space ║${RESET}\n"
printf "${BOLD}║ ${DIM}Other users must run this in their own account${RESET}${BOLD} ║${RESET}\n"
printf "${BOLD}╚══════════════════════════════════════════════════╝${RESET}\n\n"
@FoushWare
Copy link
Copy Markdown
Author

🖥 How to use:
1. Download the attached script (cleanup.sh).
2. Open Terminal and navigate to the file.
3. Make it executable:
chmod +x cleanup.sh
4. Run it:
./cleanup.sh
5. Restart your Mac to see freed space.

Please try running it every 1-2 months and share how much space you saved!

@ayman3000
Copy link
Copy Markdown

Thx a lot for your great effort.👏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment