Skip to content

Instantly share code, notes, and snippets.

@belyak
Created February 6, 2026 23:27
Show Gist options
  • Select an option

  • Save belyak/9302bf650aa6e79d5d7d1521b1a72187 to your computer and use it in GitHub Desktop.

Select an option

Save belyak/9302bf650aa6e79d5d7d1521b1a72187 to your computer and use it in GitHub Desktop.
mac-cleaner — Full-forensic macOS disk reclaimer. Zero-dependency Python 3 CLI with curses TUI. Scans APFS snapshots, Docker, VMs, Xcode, caches, AI/ML models, Brew, logs & more.
#!/usr/bin/env python3
"""mac-cleaner — Full-forensic macOS disk reclaimer."""
import argparse
import curses
import dataclasses
import enum
import glob
import os
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Optional
# ── Constants ────────────────────────────────────────────────────────────────
VERSION = "1.0"
HOME = Path.home()
class Category(enum.Enum):
SNAPSHOT = "Snapshot"
DOCKER = "Docker"
VM = "VM"
XCODE = "Xcode"
CACHE = "Cache"
AI_MODEL = "AI/ML"
BREW = "Brew"
APP = "App"
LOG = "Log"
MISC = "Misc"
VAGRANT = "Vagrant"
@dataclasses.dataclass
class Item:
category: Category
name: str
path: str
size: int # bytes
reason: str
action: str # shell command or description
selected: bool = False
needs_sudo: bool = False
warning: str = ""
@dataclasses.dataclass
class DiskInfo:
total: int = 0
used: int = 0
available: int = 0
purgeable: str = "N/A"
snapshot_count: int = 0
snapshot_est_size: str = "N/A"
# ── Utility Functions ────────────────────────────────────────────────────────
def format_size(nbytes: int) -> str:
if nbytes < 0:
return "???"
for unit in ("B", "K", "M", "G", "T"):
if abs(nbytes) < 1024.0:
if unit == "B":
return f"{nbytes}{unit}"
return f"{nbytes:.1f}{unit}"
nbytes /= 1024.0
return f"{nbytes:.1f}P"
def parse_size_str(s: str) -> int:
"""Parse human-readable size like '12.3G' to bytes."""
s = s.strip().upper()
multipliers = {"B": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
for suffix, mult in multipliers.items():
if s.endswith(suffix):
try:
return int(float(s[:-1]) * mult)
except ValueError:
return 0
# Try 'Ki', 'Gi' etc
for suffix, mult in {"KI": 1024, "MI": 1024**2, "GI": 1024**3, "TI": 1024**4}.items():
if s.endswith(suffix):
try:
return int(float(s[:-2]) * mult)
except ValueError:
return 0
try:
return int(s)
except ValueError:
return 0
def run_cmd(cmd: list[str], timeout: int = 30, sudo: bool = False) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr)."""
if sudo:
cmd = ["sudo"] + cmd
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {cmd[0]}"
except subprocess.TimeoutExpired:
return 124, "", "Command timed out"
except Exception as e:
return 1, "", str(e)
def dir_size(path: str) -> int:
"""Get total size of a directory tree."""
total = 0
p = Path(path)
if not p.exists():
return 0
if p.is_file() or p.is_symlink():
try:
return p.stat().st_size
except OSError:
return 0
try:
for entry in p.rglob("*"):
try:
if entry.is_file() and not entry.is_symlink():
total += entry.stat().st_size
except OSError:
continue
except PermissionError:
pass
return total
def dir_size_fast(path: str) -> int:
"""Get directory size using du for speed on large trees."""
rc, out, _ = run_cmd(["du", "-sk", path], timeout=60)
if rc == 0 and out.strip():
try:
return int(out.split()[0]) * 1024
except (ValueError, IndexError):
pass
return dir_size(path)
def is_running(process_name: str) -> bool:
rc, out, _ = run_cmd(["pgrep", "-x", process_name])
return rc == 0
# ── Scanners ─────────────────────────────────────────────────────────────────
def scan_disk_info() -> DiskInfo:
info = DiskInfo()
# df
rc, out, _ = run_cmd(["df", "-h", "/"])
if rc == 0:
lines = out.strip().split("\n")
if len(lines) >= 2:
parts = lines[1].split()
if len(parts) >= 4:
info.total = parse_size_str(parts[1])
info.used = parse_size_str(parts[2])
info.available = parse_size_str(parts[3])
# APFS purgeable
rc, out, _ = run_cmd(["diskutil", "apfs", "list"])
if rc == 0:
for line in out.split("\n"):
if "Purgeable" in line:
m = re.search(r"\((\d+[\.\d]*\s*[KMGT]B?)\)", line, re.IGNORECASE)
if m:
info.purgeable = m.group(1)
break
# Also try "FileVault" style
m = re.search(r"Purgeable:\s*([\d.]+\s*[KMGTP]i?B?)", line, re.IGNORECASE)
if m:
info.purgeable = m.group(1)
# Snapshots
rc, out, _ = run_cmd(["tmutil", "listlocalsnapshots", "/"])
if rc == 0:
snapshots = [l for l in out.strip().split("\n") if l.strip() and "com.apple" in l]
info.snapshot_count = len(snapshots)
if snapshots:
info.snapshot_est_size = f"~{len(snapshots) * 2}-{len(snapshots) * 8}G (est)"
return info
def scan_snapshots(use_sudo: bool) -> list[Item]:
items = []
rc, out, _ = run_cmd(["tmutil", "listlocalsnapshots", "/"])
if rc != 0:
return items
for line in out.strip().split("\n"):
line = line.strip()
if not line:
continue
# Format: com.apple.TimeMachine.2025-01-15-003245.local
m = re.search(r"(\d{4}-\d{2}-\d{2}-\d{6})", line)
if m:
date_str = m.group(1)
items.append(Item(
category=Category.SNAPSHOT,
name=date_str,
path=line,
size=0, # Can't know exact size per snapshot
reason="Time Machine local snapshot",
action=f"sudo tmutil deletelocalsnapshots {date_str}",
needs_sudo=True,
warning="Requires sudo. Safe to delete — remote TM backup unaffected.",
))
return items
def scan_docker() -> list[Item]:
items = []
docker_dirs = {
"com.docker.docker": HOME / "Library" / "Containers" / "com.docker.docker",
".docker": HOME / ".docker",
".colima": HOME / ".colima",
".lima": HOME / ".lima",
".orbstack": HOME / ".orbstack",
".rancher": HOME / ".rancher",
}
for name, path in docker_dirs.items():
if path.exists():
sz = dir_size_fast(str(path))
if sz > 50 * 1024 * 1024: # >50MB
action = f"rm -rf {path}"
if name == "com.docker.docker":
# Prefer docker prune if docker available
rc, _, _ = run_cmd(["which", "docker"])
if rc == 0:
action = "docker system prune -a --volumes"
items.append(Item(
category=Category.DOCKER,
name=name,
path=str(path),
size=sz,
reason="Container/VM data",
action=action,
))
return items
def scan_vms() -> list[Item]:
items = []
# Parallels
parallels_dir = HOME / "Parallels"
if parallels_dir.exists():
for entry in parallels_dir.iterdir():
if entry.suffix == ".pvm" and entry.is_dir():
sz = dir_size_fast(str(entry))
items.append(Item(
category=Category.VM,
name=entry.name,
path=str(entry),
size=sz,
reason="Parallels VM",
action=f'osascript -e \'tell app "Finder" to move POSIX file "{entry}" to trash\'',
))
# VirtualBox
vbox_dir = HOME / "VirtualBox VMs"
if vbox_dir.exists():
for entry in vbox_dir.iterdir():
if entry.is_dir():
sz = dir_size_fast(str(entry))
if sz > 100 * 1024 * 1024:
items.append(Item(
category=Category.VM,
name=entry.name,
path=str(entry),
size=sz,
reason="VirtualBox VM",
action=f'osascript -e \'tell app "Finder" to move POSIX file "{entry}" to trash\'',
))
# UTM
utm_dir = HOME / "Library" / "Containers" / "com.utmapp.UTM"
if utm_dir.exists():
sz = dir_size_fast(str(utm_dir))
if sz > 100 * 1024 * 1024:
items.append(Item(
category=Category.VM,
name="UTM VMs",
path=str(utm_dir),
size=sz,
reason="UTM virtual machines",
action=f'osascript -e \'tell app "Finder" to move POSIX file "{utm_dir}" to trash\'',
))
# Vagrant boxes
vagrant_dir = HOME / ".vagrant.d" / "boxes"
if vagrant_dir.exists():
for entry in vagrant_dir.iterdir():
if entry.is_dir():
sz = dir_size_fast(str(entry))
if sz > 50 * 1024 * 1024:
items.append(Item(
category=Category.VAGRANT,
name=entry.name,
path=str(entry),
size=sz,
reason="Vagrant box image",
action=f"rm -rf {entry}",
))
# qcow2 files
for pattern in [str(HOME / "*.qcow2"), str(HOME / "Documents" / "**" / "*.qcow2")]:
for f in glob.glob(pattern, recursive=True):
p = Path(f)
sz = p.stat().st_size
if sz > 50 * 1024 * 1024:
items.append(Item(
category=Category.VM,
name=p.name,
path=str(p),
size=sz,
reason="QEMU disk image",
action=f'osascript -e \'tell app "Finder" to move POSIX file "{p}" to trash\'',
))
return items
def scan_xcode() -> list[Item]:
items = []
xcode_paths = {
"DerivedData": HOME / "Library" / "Developer" / "Xcode" / "DerivedData",
"Archives": HOME / "Library" / "Developer" / "Xcode" / "Archives",
"iOS DeviceSupport": HOME / "Library" / "Developer" / "Xcode" / "iOS DeviceSupport",
"CoreSimulator": HOME / "Library" / "Developer" / "CoreSimulator" / "Devices",
"Xcode Cache": HOME / "Library" / "Caches" / "com.apple.dt.Xcode",
}
for name, path in xcode_paths.items():
if path.exists():
sz = dir_size_fast(str(path))
if sz > 50 * 1024 * 1024:
items.append(Item(
category=Category.XCODE,
name=name,
path=str(path),
size=sz,
reason="Xcode cache (rebuilds on demand)",
action=f"rm -rf {path}",
))
return items
def scan_caches() -> list[Item]:
items = []
cache_dir = HOME / "Library" / "Caches"
if not cache_dir.exists():
return items
# Known important caches
known = {
"com.google.Chrome": "Chrome browser cache",
"org.mozilla.firefox": "Firefox browser cache",
"company.thebrowser.Browser": "Arc browser cache",
"com.apple.Safari": "Safari browser cache",
"com.microsoft.VSCode": "VS Code cache",
"pip": "Python pip cache",
"uv": "Python uv cache",
"Homebrew": "Homebrew cache",
}
seen = set()
for entry in sorted(cache_dir.iterdir(), key=lambda e: -dir_size_fast(str(e)) if e.is_dir() else 0):
if not entry.is_dir():
continue
sz = dir_size_fast(str(entry))
if sz < 50 * 1024 * 1024: # Skip <50MB
continue
reason = known.get(entry.name, "Application cache")
# Check for JetBrains
if "JetBrains" in entry.name or "IntelliJ" in entry.name or "PyCharm" in entry.name:
reason = "JetBrains IDE cache"
items.append(Item(
category=Category.CACHE,
name=entry.name,
path=str(entry),
size=sz,
reason=reason,
action=f"rm -rf {entry}",
))
seen.add(entry.name)
if len(items) >= 20: # Cap to avoid overwhelming
break
# npm/yarn caches
extra = {
"npm cache": HOME / ".npm" / "_cacache",
"yarn cache": HOME / ".yarn" / "cache",
}
for name, path in extra.items():
if path.exists():
sz = dir_size_fast(str(path))
if sz > 50 * 1024 * 1024:
items.append(Item(
category=Category.CACHE,
name=name,
path=str(path),
size=sz,
reason="Package manager cache",
action=f"rm -rf {path}",
))
return items
def scan_ai_models() -> list[Item]:
items = []
ai_paths = {
"Ollama models": HOME / ".ollama" / "models",
"LM Studio models": HOME / ".lmstudio" / "models",
"LM Studio (alt)": HOME / ".cache" / "lm-studio",
"HuggingFace cache": HOME / ".cache" / "huggingface",
"miniconda3": HOME / "miniconda3",
"anaconda3": HOME / "anaconda3",
}
for name, path in ai_paths.items():
if path.exists():
sz = dir_size_fast(str(path))
if sz > 100 * 1024 * 1024:
items.append(Item(
category=Category.AI_MODEL,
name=name,
path=str(path),
size=sz,
reason="AI/ML models or environment",
action=f"rm -rf {path}",
warning="Removing will require re-download of models." if "model" in name.lower() else "",
))
return items
def scan_apps_and_brew() -> list[Item]:
items = []
# Brew formulas — check for unused
rc, out, _ = run_cmd(["brew", "list", "--formula"])
if rc == 0:
formulas = [f.strip() for f in out.strip().split("\n") if f.strip()]
# Get leaves (not depended on by others)
rc2, leaves_out, _ = run_cmd(["brew", "leaves"])
leaves = set()
if rc2 == 0:
leaves = {l.strip() for l in leaves_out.strip().split("\n") if l.strip()}
# Check shell history for usage
history_text = ""
for hist_file in [HOME / ".zsh_history", HOME / ".bash_history"]:
if hist_file.exists():
try:
history_text += hist_file.read_text(errors="ignore")
except Exception:
pass
for formula in formulas:
if formula not in leaves:
continue # Skip non-leaves (they're dependencies)
# Check if used in shell history
if formula in history_text:
continue
# Get size
rc3, info_out, _ = run_cmd(["brew", "info", "--json=v2", formula])
sz = 0
if rc3 == 0:
# Rough estimate from cellar
cellar_path = f"/usr/local/Cellar/{formula}"
alt_cellar = f"/opt/homebrew/Cellar/{formula}"
for cp in [cellar_path, alt_cellar]:
if os.path.exists(cp):
sz = dir_size_fast(cp)
break
if sz > 10 * 1024 * 1024: # >10MB
items.append(Item(
category=Category.BREW,
name=formula,
path=formula,
size=sz,
reason="Brew formula, not found in shell history",
action=f"brew uninstall {formula}",
warning="Check if needed by scripts not in history.",
))
# Brew casks
rc, out, _ = run_cmd(["brew", "list", "--cask"])
if rc == 0:
casks = [c.strip() for c in out.strip().split("\n") if c.strip()]
for cask in casks:
# Get cask info for app path
rc2, info_out, _ = run_cmd(["brew", "info", "--cask", cask])
if rc2 == 0 and "Not installed" not in info_out:
# Check if app exists in /Applications
# Simple heuristic: cask name → app name
app_name = cask.replace("-", " ").title()
app_path = Path(f"/Applications/{app_name}.app")
if not app_path.exists():
# Try exact cask name
for p in Path("/Applications").iterdir():
if cask.lower() in p.name.lower():
app_path = p
break
# /Applications — large apps
apps_dir = Path("/Applications")
if apps_dir.exists():
for entry in apps_dir.iterdir():
if entry.suffix == ".app" and entry.is_dir():
sz = dir_size_fast(str(entry))
if sz > 500 * 1024 * 1024: # >500MB
# Check last used via mdls
rc, out, _ = run_cmd(["mdls", "-name", "kMDItemLastUsedDate", str(entry)])
last_used = ""
if rc == 0 and "null" not in out.lower():
m = re.search(r"(\d{4}-\d{2}-\d{2})", out)
if m:
last_used = m.group(1)
reason = "Large application"
if last_used:
reason += f" (last used: {last_used})"
elif "null" in (out or "").lower():
reason += " (never opened)"
items.append(Item(
category=Category.APP,
name=entry.name,
path=str(entry),
size=sz,
reason=reason,
action=f'osascript -e \'tell app "Finder" to move POSIX file "{entry}" to trash\'',
))
return items
def scan_logs(use_sudo: bool) -> list[Item]:
items = []
log_dirs = [
(HOME / "Library" / "Logs", False),
(HOME / "Library" / "Logs" / "DiagnosticReports", False),
(Path("/Library/Logs/DiagnosticReports"), True),
]
if use_sudo:
log_dirs.append((Path("/private/var/log"), True))
for path, needs_sudo in log_dirs:
if path.exists():
sz = dir_size_fast(str(path))
if sz > 10 * 1024 * 1024: # >10MB
items.append(Item(
category=Category.LOG,
name=path.name,
path=str(path),
size=sz,
reason="Log files / crash reports",
action=f"rm -rf {path}/*",
needs_sudo=needs_sudo,
warning="System logs — safe to clear but lose diagnostic history." if needs_sudo else "",
))
return items
def scan_misc() -> list[Item]:
items = []
# Downloads older than 30 days
downloads = HOME / "Downloads"
if downloads.exists():
import time
cutoff = time.time() - 30 * 86400
old_total = 0
old_count = 0
for entry in downloads.iterdir():
try:
if entry.stat().st_mtime < cutoff:
if entry.is_file():
old_total += entry.stat().st_size
elif entry.is_dir():
old_total += dir_size_fast(str(entry))
old_count += 1
except OSError:
continue
if old_total > 100 * 1024 * 1024:
items.append(Item(
category=Category.MISC,
name=f"Downloads (>{30}d old)",
path=str(downloads),
size=old_total,
reason=f"{old_count} files/dirs older than 30 days",
action="manual — review and trash old downloads",
warning="Review before deleting — may contain important files.",
))
# Old Python framework installs
for pydir in Path("/Applications").glob("Python 3.*"):
if pydir.is_dir():
sz = dir_size_fast(str(pydir))
items.append(Item(
category=Category.MISC,
name=pydir.name,
path=str(pydir),
size=sz,
reason="Old Python framework install",
action=f'osascript -e \'tell app "Finder" to move POSIX file "{pydir}" to trash\'',
))
# Orphaned Application Support dirs
app_support = HOME / "Library" / "Application Support"
if app_support.exists():
installed_apps = set()
for p in Path("/Applications").iterdir():
installed_apps.add(p.stem.lower())
for entry in app_support.iterdir():
if not entry.is_dir():
continue
# Heuristic: if entry name doesn't match any installed app
name_lower = entry.name.lower().replace(" ", "").replace("-", "")
found = False
for app in installed_apps:
app_clean = app.lower().replace(" ", "").replace("-", "")
if name_lower in app_clean or app_clean in name_lower:
found = True
break
if not found:
sz = dir_size_fast(str(entry))
if sz > 200 * 1024 * 1024: # >200MB orphaned
items.append(Item(
category=Category.MISC,
name=entry.name,
path=str(entry),
size=sz,
reason="App Support — app possibly uninstalled",
action=f"rm -rf {entry}",
warning="Verify app is truly uninstalled before removing.",
))
return items
# ── Analysis Orchestrator ────────────────────────────────────────────────────
def analyze(use_sudo: bool = False, progress_cb=None) -> tuple[DiskInfo, list[Item]]:
"""Run all scanners, return disk info + sorted items."""
def notify(msg):
if progress_cb:
progress_cb(msg)
notify("Scanning disk info...")
disk_info = scan_disk_info()
all_items: list[Item] = []
scanners = [
("APFS snapshots", lambda: scan_snapshots(use_sudo)),
("Docker & containers", scan_docker),
("Virtual machines", scan_vms),
("Xcode & dev tools", scan_xcode),
("User caches", scan_caches),
("AI/ML models", scan_ai_models),
("Applications & Brew", scan_apps_and_brew),
("Logs & crash reports", lambda: scan_logs(use_sudo)),
("Miscellaneous", scan_misc),
]
for label, scanner in scanners:
notify(f"Scanning {label}...")
try:
all_items.extend(scanner())
except Exception as e:
sys.stderr.write(f"Warning: {label} scan failed: {e}\n")
# Sort by size descending
all_items.sort(key=lambda i: -i.size)
return disk_info, all_items
# ── Text-mode report (--scan-only) ──────────────────────────────────────────
def print_report(disk_info: DiskInfo, items: list[Item]):
print(f"\n{'=' * 60}")
print(f" mac-cleaner v{VERSION} — Disk Forensic Report")
print(f"{'=' * 60}")
print(f" DISK: {format_size(disk_info.used)} used / {format_size(disk_info.total)} total")
print(f" Available: {format_size(disk_info.available)} Purgeable: {disk_info.purgeable}")
print(f" APFS snapshots: {disk_info.snapshot_count} ({disk_info.snapshot_est_size})")
print(f"{'─' * 60}")
if not items:
print(" No significant reclaimable space found.")
return
total = sum(i.size for i in items)
print(f" {len(items)} items found | {format_size(total)} potentially reclaimable")
print(f"{'─' * 60}")
print(f" {'Size':>8s} {'Category':<10s} {'Name':<28s} Reason")
print(f" {'─' * 56}")
for item in items:
sz = format_size(item.size) if item.size > 0 else "??? "
sudo_mark = " *" if item.needs_sudo else " "
print(f" {sz:>8s}{sudo_mark}{item.category.value:<10s} {item.name:<28s} {item.reason}")
print(f"\n * = requires sudo")
print(f" Total reclaimable: {format_size(total)}")
print()
# ── Curses TUI ───────────────────────────────────────────────────────────────
class TUI:
def __init__(self, disk_info: DiskInfo, items: list[Item], dry_run: bool):
self.disk_info = disk_info
self.items = items
self.dry_run = dry_run
self.cursor = 0
self.scroll_offset = 0
self.show_detail = False
self.confirmed = False
def selected_size(self) -> int:
return sum(i.size for i in self.items if i.selected)
def selected_count(self) -> int:
return sum(1 for i in self.items if i.selected)
def run(self, stdscr) -> list[Item]:
curses.curs_set(0)
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1) # selected
curses.init_pair(2, curses.COLOR_CYAN, -1) # cursor
curses.init_pair(3, curses.COLOR_RED, -1) # warning
curses.init_pair(4, curses.COLOR_YELLOW, -1) # header
curses.init_pair(5, curses.COLOR_WHITE, -1) # normal
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) # cursor highlight
curses.init_pair(7, curses.COLOR_MAGENTA, -1) # detail
stdscr.timeout(100)
while True:
stdscr.erase()
h, w = stdscr.getmaxyx()
# Header
header_lines = self._draw_header(stdscr, w)
# Item list area
list_start = header_lines
list_height = h - header_lines - 5 # Reserve bottom for detail + status
if list_height < 3:
list_height = 3
# Adjust scroll
if self.cursor < self.scroll_offset:
self.scroll_offset = self.cursor
if self.cursor >= self.scroll_offset + list_height:
self.scroll_offset = self.cursor - list_height + 1
# Column header
col_hdr = f" {'Sel':<4s} {'Size':>8s} {'Category':<10s} {'Name':<24s} Reason"
self._addstr(stdscr, list_start, 0, col_hdr[:w], curses.A_BOLD | curses.color_pair(4))
self._addstr(stdscr, list_start + 1, 0, "─" * min(w - 1, 70), curses.color_pair(4))
# Items
for idx in range(self.scroll_offset, min(len(self.items), self.scroll_offset + list_height - 2)):
item = self.items[idx]
y = list_start + 2 + (idx - self.scroll_offset)
if y >= h - 4:
break
is_cursor = idx == self.cursor
self._draw_item(stdscr, y, w, item, is_cursor)
# Detail panel
detail_y = h - 4
if self.show_detail and 0 <= self.cursor < len(self.items):
item = self.items[self.cursor]
self._addstr(stdscr, detail_y, 0, "─" * min(w - 1, 70), curses.color_pair(4))
detail = f" {item.name}: {item.path}"
self._addstr(stdscr, detail_y + 1, 0, detail[:w - 1], curses.color_pair(7))
act = f" Action: {item.action}"
self._addstr(stdscr, detail_y + 2, 0, act[:w - 1], curses.color_pair(7))
if item.warning:
self._addstr(stdscr, detail_y + 3, 0, f" !! {item.warning}"[:w - 1], curses.color_pair(3))
# Status bar
status_y = h - 1
sel_count = self.selected_count()
sel_size = format_size(self.selected_size())
mode = " [DRY RUN]" if self.dry_run else ""
status = f" {sel_count} selected | ~{sel_size} to free{mode} | Space:toggle a:all n:none Enter:go d:detail q:quit"
self._addstr(stdscr, status_y, 0, status[:w - 1], curses.A_REVERSE)
stdscr.refresh()
# Input
try:
key = stdscr.getch()
except curses.error:
continue
if key == -1:
continue
elif key in (ord("q"), ord("Q"), 27): # q or ESC
return []
elif key in (ord("j"), curses.KEY_DOWN):
if self.cursor < len(self.items) - 1:
self.cursor += 1
elif key in (ord("k"), curses.KEY_UP):
if self.cursor > 0:
self.cursor -= 1
elif key == ord(" "):
if 0 <= self.cursor < len(self.items):
self.items[self.cursor].selected = not self.items[self.cursor].selected
if self.cursor < len(self.items) - 1:
self.cursor += 1
elif key == ord("a"):
for i in self.items:
i.selected = True
elif key == ord("n"):
for i in self.items:
i.selected = False
elif key == ord("d"):
self.show_detail = not self.show_detail
elif key == ord("\t"):
# Jump to next category
if self.items:
current_cat = self.items[self.cursor].category
for idx in range(self.cursor + 1, len(self.items)):
if self.items[idx].category != current_cat:
self.cursor = idx
break
elif key == curses.KEY_NPAGE:
self.cursor = min(len(self.items) - 1, self.cursor + list_height)
elif key == curses.KEY_PPAGE:
self.cursor = max(0, self.cursor - list_height)
elif key in (ord("\n"), curses.KEY_ENTER, 10):
selected = [i for i in self.items if i.selected]
if selected:
return selected
return []
def _draw_header(self, stdscr, w) -> int:
y = 0
title = f"mac-cleaner v{VERSION} — Disk Forensic"
self._addstr(stdscr, y, 0, f" {title:^{min(w-2, 58)}} ", curses.A_BOLD | curses.color_pair(4))
y += 1
self._addstr(stdscr, y, 0, "─" * min(w - 1, 70), curses.color_pair(4))
y += 1
di = self.disk_info
line1 = f" DISK: {format_size(di.used)} used / {format_size(di.total)} total Purgeable: {di.purgeable}"
self._addstr(stdscr, y, 0, line1[:w - 1], curses.color_pair(4))
y += 1
line2 = f" APFS snapshots: {di.snapshot_count} ({di.snapshot_est_size}) Available: {format_size(di.available)}"
self._addstr(stdscr, y, 0, line2[:w - 1], curses.color_pair(4))
y += 1
self._addstr(stdscr, y, 0, "─" * min(w - 1, 70), curses.color_pair(4))
y += 1
return y
def _draw_item(self, stdscr, y, w, item: Item, is_cursor: bool):
check = "[x]" if item.selected else "[ ]"
sz = format_size(item.size) if item.size > 0 else "??? "
prefix = " \u25b8" if is_cursor else " "
line = f"{prefix}{check} {sz:>8s} {item.category.value:<10s} {item.name:<24s} {item.reason}"
line = line[:w - 1]
if is_cursor:
attr = curses.color_pair(6) | curses.A_BOLD
elif item.selected:
attr = curses.color_pair(1)
elif item.needs_sudo or item.warning:
attr = curses.color_pair(3) | curses.A_DIM
else:
attr = curses.color_pair(5)
self._addstr(stdscr, y, 0, line, attr)
def _addstr(self, stdscr, y, x, text, attr=0):
h, w = stdscr.getmaxyx()
if y < 0 or y >= h:
return
try:
stdscr.addnstr(y, x, text, w - x - 1, attr)
except curses.error:
pass
# ── Removal Executor ─────────────────────────────────────────────────────────
def execute_removals(selected: list[Item], dry_run: bool):
print(f"\n{'=' * 60}")
if dry_run:
print(" DRY RUN — no changes will be made")
print(f" Removal Plan: {len(selected)} items")
print(f"{'─' * 60}")
total_size = 0
for item in selected:
sz = format_size(item.size) if item.size > 0 else "???"
sudo_tag = " [sudo]" if item.needs_sudo else ""
print(f" {sz:>8s} {item.category.value:<10s} {item.name}{sudo_tag}")
print(f" Action: {item.action}")
total_size += item.size
print(f"{'─' * 60}")
print(f" Estimated space to free: {format_size(total_size)}")
print()
if dry_run:
print(" (dry run — nothing executed)")
return
answer = input(" Proceed? [y/N]: ").strip().lower()
if answer != "y":
print(" Aborted.")
return
freed = {}
errors = []
for item in selected:
cat = item.category.value
freed.setdefault(cat, 0)
print(f" → {item.category.value}: {item.name}...", end=" ", flush=True)
try:
if item.category == Category.SNAPSHOT:
# Extract date from action
m = re.search(r"deletelocalsnapshots\s+(\S+)", item.action)
if m:
date = m.group(1)
rc, out, err = run_cmd(["tmutil", "deletelocalsnapshots", date], sudo=True, timeout=60)
if rc == 0:
print("OK")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
elif item.action.startswith("docker system prune"):
rc, out, err = run_cmd(["docker", "system", "prune", "-a", "--volumes", "-f"], timeout=120)
if rc == 0:
print("OK")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
elif item.action.startswith("brew uninstall"):
parts = item.action.split()
rc, out, err = run_cmd(parts, timeout=60)
if rc == 0:
print("OK")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
elif item.action.startswith("osascript"):
# Move to Trash via Finder
rc, out, err = run_cmd(
["osascript", "-e",
f'tell application "Finder" to move POSIX file "{item.path}" to trash'],
timeout=30,
)
if rc == 0:
print("OK (moved to Trash)")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
elif item.action.startswith("rm -rf"):
target = item.action.replace("rm -rf ", "").strip()
if item.needs_sudo:
rc, out, err = run_cmd(["rm", "-rf", target], sudo=True, timeout=120)
else:
rc, out, err = run_cmd(["rm", "-rf", target], timeout=120)
if rc == 0:
print("OK")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
elif item.action.startswith("manual"):
print("SKIP (manual review required)")
else:
# Generic command
rc, out, err = run_cmd(item.action.split(), timeout=120)
if rc == 0:
print("OK")
freed[cat] += item.size
else:
print(f"FAIL: {err.strip()}")
errors.append(f"{item.name}: {err.strip()}")
except Exception as e:
print(f"ERROR: {e}")
errors.append(f"{item.name}: {e}")
# brew cleanup at end
print("\n → Running brew cleanup...", end=" ", flush=True)
rc, _, _ = run_cmd(["brew", "cleanup", "--prune=all"], timeout=120)
print("OK" if rc == 0 else "FAIL")
# Final report
print(f"\n{'=' * 60}")
print(" Removal Report")
print(f"{'─' * 60}")
total_freed = 0
for cat, sz in sorted(freed.items(), key=lambda x: -x[1]):
if sz > 0:
print(f" {cat:<12s} {format_size(sz):>10s}")
total_freed += sz
print(f"{'─' * 60}")
print(f" {'TOTAL':<12s} {format_size(total_freed):>10s}")
if errors:
print(f"\n Errors ({len(errors)}):")
for e in errors:
print(f" - {e}")
print(f"\n Remember to empty Trash for full reclaim!")
print()
# ── Entry Point ──────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
prog="mac-cleaner",
description=f"mac-cleaner v{VERSION} — Full-forensic macOS disk reclaimer",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
mac-cleaner Full scan + interactive TUI
mac-cleaner --scan-only Print report to stdout (no TUI)
mac-cleaner --dry-run Interactive TUI, no actual removal
mac-cleaner --sudo Include sudo-requiring operations
mac-cleaner --sudo --dry-run Full scan including snapshots, dry run
"""),
)
parser.add_argument("--dry-run", action="store_true", help="Scan + TUI but no actual removal")
parser.add_argument("--scan-only", action="store_true", help="Scan and print report (no TUI)")
parser.add_argument("--sudo", action="store_true", help="Enable sudo-requiring operations")
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
args = parser.parse_args()
if args.scan_only:
print(f" mac-cleaner v{VERSION} — scanning...\n")
disk_info, items = analyze(use_sudo=args.sudo, progress_cb=lambda m: print(f" {m}"))
print_report(disk_info, items)
return
# Scan with progress
print(f" mac-cleaner v{VERSION} — scanning...\n")
disk_info, items = analyze(use_sudo=args.sudo, progress_cb=lambda m: print(f" {m}"))
if not items:
print("\n No significant reclaimable space found. Disk is clean!")
return
# Launch TUI
try:
selected = curses.wrapper(lambda stdscr: TUI(disk_info, items, args.dry_run).run(stdscr))
except curses.error as e:
print(f"\n TUI error (terminal too small?): {e}")
print(" Falling back to --scan-only mode.\n")
print_report(disk_info, items)
return
if not selected:
print("\n No items selected. Exiting.")
return
execute_removals(selected, dry_run=args.dry_run)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment