Created
February 6, 2026 23:27
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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