diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7586ea..fb0802e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,8 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: [macos-14, macos-latest] exclude: - # Python 3.9 not available on macos-14 (arm64 runner) - os: macos-14 python-version: "3.9" - steps: - name: Checkout uses: actions/checkout@v4 @@ -108,3 +106,280 @@ jobs: path: dist/ retention-days: 7 + version-check: + name: Version Gate (PyPI vs local) + needs: build-check + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: macos-latest + timeout-minutes: 10 + outputs: + should_publish: ${{ steps.compare.outputs.should_publish }} + local_version: ${{ steps.compare.outputs.local_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Compare versions + id: compare + run: | + python - <<'EOF' + import json, sys, urllib.request + + try: + import tomllib + except ImportError: + import tomli as tomllib + + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + + local = data["project"]["version"] + print(f"Local version : {local}") + + package = "mac-deep-cleaner" + url = f"https://pypi.org/pypi/{package}/json" + try: + with urllib.request.urlopen(url, timeout=15) as r: + pypi = json.load(r)["info"]["version"] + except urllib.error.HTTPError as e: + if e.code == 404: + pypi = "0.0.0" + print("Package not found on PyPI โ€” treating as new release.") + else: + raise + + print(f"PyPI version : {pypi}") + + import os + out = os.environ["GITHUB_OUTPUT"] + publish = "true" if local != pypi else "false" + with open(out, "a") as f: + f.write(f"should_publish={publish}\n") + f.write(f"local_version={local}\n") + + if publish == "true": + print(f"โœ… New version detected ({pypi} โ†’ {local}). Will publish.") + else: + print(f"โญ Version {local} already on PyPI. Skipping publish.") + EOF + + publish: + name: Publish to PyPI (${{ needs.version-check.outputs.local_version }}) + needs: version-check + if: needs.version-check.outputs.should_publish == 'true' + runs-on: macos-latest + timeout-minutes: 20 + environment: + name: pypi + url: https://pypi.org/project/mac-deep-cleaner/ + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install build tools + run: pip install --upgrade pip && pip install build twine + + - name: Build distributions + run: python -m build + + - name: Verify distributions + run: twine check dist/* + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* + + - name: Summary + run: | + echo "### ๐Ÿš€ Published mac-deep-cleaner ${{ needs.version-check.outputs.local_version }} to PyPI" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ“ฆ https://pypi.org/project/mac-deep-cleaner/${{ needs.version-check.outputs.local_version }}/" >> $GITHUB_STEP_SUMMARY + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Creates a GitHub Release tagged v with the matching CHANGELOG entry. + # Runs after a successful publish. + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + github-release: + name: Create GitHub Release (v${{ needs.version-check.outputs.local_version }}) + needs: [version-check, publish] + if: needs.version-check.outputs.should_publish == 'true' + runs-on: macos-latest + timeout-minutes: 10 + permissions: + contents: write # needed to create tags + releases + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so the tag can be pushed + persist-credentials: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Extract changelog section for this version + id: changelog + run: | + python - <<'EOF' + import re, os, sys + + version = "${{ needs.version-check.outputs.local_version }}" + tag = f"v{version}" + + try: + with open("CHANGELOG.md", "r") as f: + content = f.read() + except FileNotFoundError: + print("CHANGELOG.md not found โ€” using empty release notes.") + body = f"Release {tag}" + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + out.write(f"tag={tag}\n") + with open("release_notes.md", "w") as rn: + rn.write(body) + sys.exit(0) + + # Match the section that starts with "## v" up to the next "## v" heading + pattern = rf"(## {re.escape(tag)}.*?)(?=\n## v|\Z)" + match = re.search(pattern, content, re.DOTALL) + + if match: + body = match.group(1).strip() + print(f"โœ… Found changelog section for {tag}") + else: + body = f"Release {tag}\n\nNo changelog entry found for this version." + print(f"โš ๏ธ No changelog section found for {tag} โ€” using fallback.") + + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + out.write(f"tag={tag}\n") + + with open("release_notes.md", "w") as rn: + rn.write(body) + EOF + + - name: Create and push tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.changelog.outputs.tag }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Only create the tag if it doesn't already exist + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists โ€” skipping tag creation." + else + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "โœ… Pushed tag $TAG" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.changelog.outputs.tag }} + name: "mac-deep-cleaner ${{ steps.changelog.outputs.tag }}" + body_path: release_notes.md + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Auto-bumps the patch version on `develop` after a successful main publish + # so the branch is always ahead of what's on PyPI. + # Commits directly to develop via the default GITHUB_TOKEN. + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + auto-version-bump: + name: Auto-bump patch version on develop + needs: [version-check, publish] + if: needs.version-check.outputs.should_publish == 'true' + runs-on: macos-latest + timeout-minutes: 10 + permissions: + contents: write # needed to push the commit + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 1 + persist-credentials: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Bump patch version in pyproject.toml + id: bump + run: | + python - <<'EOF' + import re, os + + published = "${{ needs.version-check.outputs.local_version }}" + major, minor, patch = map(int, published.split(".")) + next_version = f"{major}.{minor}.{patch + 1}" + + with open("pyproject.toml", "r") as f: + content = f.read() + + # Replace the version field โ€” matches: version = "x.y.z" + updated = re.sub( + r'^(version\s*=\s*")[^"]+(")', + rf'\g<1>{next_version}\g<2>', + content, + flags=re.MULTILINE, + ) + + if updated == content: + print(f"โš ๏ธ version field not found or already bumped โ€” no change written.") + else: + with open("pyproject.toml", "w") as f: + f.write(updated) + print(f"โœ… Bumped {published} โ†’ {next_version} in pyproject.toml") + + with open(os.environ["GITHUB_OUTPUT"], "a") as out: + out.write(f"next_version={next_version}\n") + EOF + + - name: Commit and push version bump + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEXT="${{ steps.bump.outputs.next_version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + # Only commit if there's an actual change + if git diff --cached --quiet; then + echo "Nothing to commit โ€” develop is already at $NEXT or bump failed." + else + git commit -m "chore: bump version to $NEXT [skip ci]" + git push origin develop + echo "โœ… Pushed version bump to develop: $NEXT" + fi \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b71b04c..df0074b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +2,67 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. -## Unreleased +## v2.0.0 (2026-05-14) + +### Added + +## Cross-cutting Work +- Add new CLI subcommands and options in src/cli.py +- Extend config schema in src/config/config.py for new features +- Add logging and safe-path validation in src/core/safety.py where needed +- Expand reporting exports in src/reporting for new outputs +- Add tests for parsers and non-destructive scanners in tests/ + +## External Dependencies (tentative) +- textual or prompt_toolkit for interactive TUI picker +- rumps for menu bar companion +- requests or urllib for HIBP API (prefer urllib to avoid new deps) +- pandas/pyarrow NOT planned (keep lightweight) + +## Safety Gates +- All destructive operations honor --dry-run and undo staging. +- Time Machine guard before bulk deletes. +- APFS snapshot support behind explicit flags. +- Restore checksum verification for staged files. + +## Open Decisions (needs confirmation) +- Preferred TUI library (textual vs prompt_toolkit) +- Whether to add optional dependencies vs strict core only +- Handling sudo-required operations (auto prompt vs printed instructions) +- HIBP API key provisioning and storage +- Minimum supported macOS version for system commands +- CI mode via `mdc uninstall-cli` ti uninstall this package + +##DOUCMENTATIONS +-Added the documentations for the command references and architecture + + +### Changed +- CLI wiring for new feature commands +- Added reporting utilities +- Updated Readme for new features +- Version bump to v2.0.0 across docs and UI +- Updated the ci/cd pipelining to publish the newer version to pypi directly +- Updated the readme.md file for the references too ## v1.5.0 (2026-05-12) ### Added -#### P0 (baseline UX and safety) -- [x] Global --dry-run flag (src/core/dry_run.py) -- [x] Shell completion command (src/core/completions.py) -- [x] Full app uninstaller (src/core/uninstaller.py) - -#### P1 (highest demand data and visibility) -- [x] Browser data cleaner (src/scanners/browser_data.py) -- [x] Visual disk space map (src/scanners/space_map.py) -- [x] Photo library analyzer (src/scanners/photos_analyzer.py) -- [x] iOS simulator deep cleaner (src/scanners/simulators.py) - -#### P2 (system utilities and maintenance) -- [x] Memory pressure reliever (src/core/memory_pressure.py) -- [x] Homebrew deep manager (src/core/brew_manager.py) -- [x] Storage trend tracker (src/reporting/storage_trend.py) -- [x] Recent files and activity cleaner (src/scanners/recent_activity.py) - -#### P3 (advanced and higher risk features) -- [x] Permissions auditor (src/core/permissions_auditor.py) -- [x] APFS snapshot guard (src/core/apfs_snapshots.py) -- [x] Menu bar companion (src/core/menubar.py) -- [x] Data breach monitor (src/core/breach_monitor.py) -- [x] Cloud storage junk scanner (src/scanners/cloud_junk.py) +- Global --dry-run flag (src/core/dry_run.py) +- Shell completion command (src/core/completions.py) +- Full app uninstaller (src/core/uninstaller.py) +- Browser data cleaner (src/scanners/browser_data.py) +- Visual disk space map (src/scanners/space_map.py) +- Photo library analyzer (src/scanners/photos_analyzer.py) +- iOS simulator deep cleaner (src/scanners/simulators.py) +- Memory pressure reliever (src/core/memory_pressure.py) +- Homebrew deep manager (src/core/brew_manager.py) +- Storage trend tracker (src/reporting/storage_trend.py) +- Recent files and activity cleaner (src/scanners/recent_activity.py) +- Permissions auditor (src/core/permissions_auditor.py) +- APFS snapshot guard (src/core/apfs_snapshots.py) +- Menu bar companion (src/core/menubar.py) +- Data breach monitor (src/core/breach_monitor.py) +- Cloud storage junk scanner (src/scanners/cloud_junk.py) ### Changed - CLI wiring for new P0/P1 commands and dry-run behavior @@ -47,7 +81,7 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ### Changed - Live dashboard now shows top findings and dev junk totals - Scan history schema extended with developer junk totals -- Version bump to v1.5.0 across docs and UI +- Version bump to v1.2.0 across docs and UI ## v1.0.0 (2026-05-10) ### Added diff --git a/README.md b/README.md index 9529b10..75e6d48 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,49 @@
- -# Mac Deep Cleaner v1.5.0 -**Professional macOS cleanup tool โ€” Smart App Orphan Detector** +# Mac Deep Cleaner -![GitHub license](https://img.shields.io/github/license/NK2552003/Mac-Cleaner?style=flat-square) -![GitHub last commit](https://img.shields.io/github/last-commit/NK2552003/Mac-Cleaner?style=flat-square) -![GitHub repo size](https://img.shields.io/github/repo-size/NK2552003/Mac-Cleaner?style=flat-square) -![Platform](https://img.shields.io/badge/platform-macOS-lightgrey?style=flat-square&logo=apple) -[![PyPI Downloads](https://static.pepy.tech/personalized-badge/mac-deep-cleaner?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/mac-deep-cleaner) +**Reclaim gigabytes of disk space with confidence โ€” the safest, smartest macOS cleanup tool ever built.** ---- +[![PyPI Version](https://img.shields.io/pypi/v/mac-deep-cleaner.svg)](https://pypi.org/project/mac-deep-cleaner/) +[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) +[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE) +[![macOS 10.15+](https://img.shields.io/badge/macOS-10.15+-silver.svg)](https://www.apple.com/macos/) +[![PyPI Downloads](https://static.pepy.tech/personalized-badge/mac-deep-cleaner?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/mac-deep-cleaner)
-Detects and removes leftover files from uninstalled apps, stale caches, crash reports, logs, and other system junk โ€” safely, with multiple layers of protection and full undo support. +**Mac Deep Cleaner** is a professional-grade macOS utility that intelligently detects and removes leftover files from uninstalled apps, stale caches, crash reports, logs, and system junk โ€” with **multiple layers of protection** and **full undo support**. -Use either command name: ```bash -mac-cleaner scan -mdc scan +# Two commands, same powerful tool +mac-cleaner scan # Full-featured +mdc scan # Quick alias ``` -`mdc` is a shorter alias for every command, for example `mdc dashboard`, -`mdc clean`, and `mdc scan --ci --threshold-mb 500`. --- -## Features (All) - -- **Smart orphan detection** โ€” finds leftover app data after uninstalling apps -- **General junk scan** โ€” caches, logs, crash reports, Trash, `.DS_Store`, Xcode artefacts, package manager caches -- **Developer junk scan** โ€” `node_modules`, `venv`, build outputs, coverage dirs (opt-in) -- **Global dev caches** โ€” `~/.npm`, `~/.gradle`, `~/.m2`, `~/.cargo`, `~/.nuget` (opt-in) -- **Duplicate finder** โ€” SHA-256 content hashing, two-phase (head + full), sorted by wasted space -- **Large file scanner** โ€” finds files โ‰ฅ100 MB (configurable), categorised by type -- **Broken symlink detector** โ€” walks `/usr/local`, `/opt/homebrew`, `~/bin`, etc. -- **Browser data cleaner** โ€” cache, cookies, history, sessions (opt-in delete) -- **Space map** โ€” disk usage overview by folder tree -- **Photos analyzer** โ€” summaries of Photos libraries and originals -- **iOS simulator cleaner** โ€” shows simulator sizes and can purge -- **Full app uninstaller** โ€” remove app bundle plus known data -- **Shell completions** โ€” bash, zsh, fish -- **iOS backup finder** โ€” parses `MobileSync/Backup` manifests, shows device/age/size -- **Language pack stripper** โ€” detects removable `.lproj` dirs in every installed app -- **Universal binary thinner** โ€” uses `ditto --arch` safely; creates `.fat_backup` by default -- **Undo / restore** โ€” files staged in `~/.mac_cleaner_trash/` instead of permanent delete -- **Config file** โ€” `~/.config/mac-cleaner/config.yaml` with profile support -- **Scan history** โ€” JSON records in `~/.config/mac-cleaner/history/` -- **Diff** โ€” compare any two scans to see what's new or resolved -- **HTML report** โ€” self-contained with Chart.js doughnut + collapsible sections -- **System inspector** โ€” LaunchAgents, LaunchDaemons, login items, SIP status -- **Memory pressure reliever** โ€” reports pressure, optional cache purge -- **Homebrew manager** โ€” cache sizes, outdated list, cleanup and autoremove -- **Storage trend tracker** โ€” snapshots disk usage over time -- **Recent activity cleaner** โ€” scans recent-items files (safe clear) -- **Permissions auditor** โ€” TCC privacy access audit (read-only) -- **APFS snapshot guard** โ€” list and prune local snapshots -- **Menu bar companion** โ€” SwiftBar/xbar plugin for last scan summary -- **Data breach monitor** โ€” checks emails via HIBP API (opt-in) -- **Cloud storage junk** โ€” scans Dropbox/Drive/OneDrive/Box caches -- **Scheduler** โ€” installs a LaunchAgent for weekly auto-scans -- **macOS notifications** โ€” via `osascript`, no dependencies -- **CI mode** โ€” JSON-only scan summary with threshold-based exit code -- **Live TUI dashboard** โ€” Rich Live/Layout summary while a scan is running -- **Custom roots** โ€” scan project folders or external directories via config or `--root` -- **Distribution helpers** โ€” wheel/sdist, Homebrew formula scaffold, and unsigned local `.pkg` builder -- **Self-update** โ€” checks PyPI, upgrades via pip -- **Safety first** โ€” system files (`com.apple.*`) are *never* touched; running apps are protected +## Why Mac Deep Cleaner? + +| **Safety First** | **Lightning Fast** | **Smart Detection** | +|---------------------|----------------------|------------------------| +| Never touches system files (`com.apple.*`) | Multi-threaded scanning | Bundle ID matching technology | +| Running apps are protected | Progressively displays results | Team ID validation for containers | +| Full undo/restore capability | Optimized hashing algorithms | Cross-references 45+ locations | +| Audit logging for every action | Minimal memory footprint | AI-powered orphan detection | --- ## Installation -### From PyPI (recommended) +### Quick Install (Recommended) ```bash pip install mac-deep-cleaner ``` -### From source (venv) +### From Source ```bash git clone https://github.com/NK2552003/Mac-Cleaner.git -cd mac_deep_cleaner +cd Mac-Cleaner python3 -m venv .venv source .venv/bin/activate pip install -e . @@ -88,287 +51,407 @@ pip install -e . --- -## Usage +## Quick Start -### Core scan & clean +### Scan Your System (Safe Preview) ```bash -# Preview scan (safe โ€” never deletes) mac-cleaner scan -mdc scan +``` -# Live dashboard scan +### Review the Dashboard +```bash mac-cleaner dashboard - -# Developer junk scan -mac-cleaner scan --dev-junk - -# Developer junk + global caches -mac-cleaner scan --dev-junk --dev-junk-global - -# Interactive cleanup (files staged for undo by default) -mac-cleaner clean - -# Auto-delete everything detected -mac-cleaner clean --auto - -# Force preview mode (no deletes anywhere) -mac-cleaner --dry-run clean - -# Permanently delete (skip undo staging) -mac-cleaner clean --no-undo - -# Skip general junk; orphans only -mac-cleaner scan --skip-junk - -# Export results to JSON / YAML / HTML -mac-cleaner scan --export results.json -mac-cleaner scan --export results.html - -# Protect a path from deletion -mac-cleaner clean --whitelist ~/Library/Application\ Support/MyApp - -# Show all discovered apps -mac-cleaner scan --show-apps - -# Use a profile -mac-cleaner scan --profile developer -mac-cleaner scan --profile minimal - -# Add a custom scan root -mac-cleaner scan --root ~/Projects - -# CI / automation mode: JSON to stdout, exit 1 when over threshold -mac-cleaner scan --ci --threshold-mb 500 - -# Post macOS notification when done -mac-cleaner scan --notify ``` -### Logging +### Clean Up (with Undo Support) ```bash -# Enable debug logging (writes to ~/.config/mac-cleaner/mac-cleaner.log) -mac-cleaner scan --verbose - -# Use a custom log file -mac-cleaner scan --log-file ~/mac-cleaner.log +mac-cleaner clean # Interactive mode +mac-cleaner clean --auto # Auto-delete detected items ``` -### New scanners +### Need to Restore? ```bash -# Shell completions -mac-cleaner completions --shell zsh --instructions +mac-cleaner undo # Restore last cleanup +``` -# Full app uninstall -mac-cleaner uninstall "Slack" +--- -# Browser data cleanup -mac-cleaner browser-data -mac-cleaner browser-data --browser chrome --category cache --clean +## Documentation -# Disk usage map -mac-cleaner space-map --depth 2 --limit 12 +Comprehensive guides for every use case: -# Photos library analyzer -mac-cleaner photos --details +| Document | Description | +|----------|-------------| +| **[Features Guide](docs/FEATURES.md)** | Complete list of all 60+ features with examples | +| **[Command Reference](docs/COMMAND_REFERENCE.md)** | Detailed CLI documentation for every command | +| **[Architecture Guide](docs/ARCHITECTURE.md)** | Internal structure, modules, and extension points | +| **[PyPI Publishing](docs/PYPI_PUBLISHING.md)** | Build and publish instructions | -# iOS simulator cleaner -mac-cleaner simulators -mac-cleaner simulators --purge-unavailable --yes +--- -# Find duplicate files (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures) -mac-cleaner duplicates -mac-cleaner duplicates --path ~/Movies --min-size 500 +## Core Features + +### Smart Scanning +- **Orphan Detection** โ€” Finds leftover data from uninstalled apps using bundle ID matching +- **General Junk** โ€” Caches, logs, crash reports, `.DS_Store`, Xcode artifacts, package manager caches +- **Developer Junk** โ€” `node_modules`, `venv`, build outputs, coverage dirs (opt-in) +- **Global Dev Caches** โ€” `~/.npm`, `~/.gradle`, `~/.m2`, `~/.cargo`, `~/.nuget` (opt-in) +- **Duplicate Finder** โ€” SHA-256 hashing with two-phase optimization +- **Large File Scanner** โ€” Find files โ‰ฅ100 MB, categorized by type +- **Broken Symlink Detector** โ€” Scans Homebrew, `/usr/local`, `~/bin`, and more + +### Advanced Cleaning +- **Browser Data Cleaner** โ€” Cache, cookies, history, sessions (Chrome, Firefox, Safari, Edge, Brave) +- **iOS Simulator Cleaner** โ€” Manage simulator data and caches +- **Photos Library Analyzer** โ€” Breakdown of Photos library storage +- **iOS Backup Finder** โ€” Parse MobileSync backups with device info +- **Language Pack Stripper** โ€” Remove unused `.lproj` directories +- **Universal Binary Thinner** โ€” Safely thin fat binaries with `ditto --arch` +- **Full App Uninstaller** โ€” Remove app bundles plus all associated data + +### Reporting & Monitoring +- **Space Map** โ€” Visual disk usage tree +- **HTML Reports** โ€” Self-contained reports with interactive charts +- **Scan History** โ€” JSON records with diff comparison +- **Weekly Digest** โ€” Aggregated weekly summaries +- **Impact Score** โ€” Measure cleaning effectiveness (0-100) +- **Storage Trends** โ€” Track disk usage over time + +### System Utilities +- **System Inspector** โ€” LaunchAgents, LaunchDaemons, login items, SIP status +- **Memory Pressure Reliever** โ€” Report and purge memory caches +- **Homebrew Manager** โ€” Cache cleanup, outdated list, autoremove +- **DNS Cache Flush** โ€” Refresh name resolution +- **Font Cache Rebuild** โ€” Safely rebuild ATS caches +- **Spotlight Re-index** โ€” Rebuild metadata index +- **Power Optimizer** โ€” Apply recommended `pmset` settings +- **App Update Checker** โ€” System, Homebrew, and App Store updates +- **PKG Receipt Manager** โ€” List and forget pkgutil receipts +- **Permissions Auditor** โ€” TCC privacy access audit (read-only) +- **Time Machine Guard** โ€” Status, age checks, local snapshots +- **APFS Snapshot Guard** โ€” List and prune local snapshots + +### User Experience +- **Live TUI Dashboard** โ€” Real-time Rich layout during scans +- **Interactive TUI Picker** โ€” Keyboard-driven app selection +- **Shell Completions** โ€” Bash, zsh, fish support +- **macOS Notifications** โ€” Native notifications via `osascript` +- **Menu Bar Companion** โ€” SwiftBar/xbar plugin integration +- **CI Mode** โ€” JSON output with threshold-based exit codes +- **Undo/Restore** โ€” Staged deletions with checksum verification + +### Cloud & Security +- **Cloud Storage Junk** โ€” Dropbox, Google Drive, OneDrive, Box caches +- **Data Breach Monitor** โ€” HIBP API integration for email checks +- **Multi-Mac Config Sync** โ€” Export/import configurations +- **Scheduler** โ€” LaunchAgent for automated weekly scans -# Find large files (โ‰ฅ100 MB by default) -mac-cleaner large-files -mac-cleaner large-files --min-mb 50 --export large.json +--- -# Find broken symlinks -mac-cleaner symlinks -mac-cleaner symlinks --delete +## Safety Guarantees -# iOS backups -mac-cleaner extras --ios-backups -mac-cleaner extras --ios-backups --delete-backups +Mac Deep Cleaner implements **7 layers of protection**: -# Language packs -mac-cleaner extras --language-packs -mac-cleaner extras --language-packs --strip-languages +| Layer | Protection | +|-------|------------| +| **System Protection** | `com.apple.*` files are **NEVER** deleted | +| **Running App Guard** | Active applications are automatically protected | +| **Group Container Validation** | Team IDs verified against vendor database | +| **System Cache Isolation** | OS-owned caches skipped by default | +| **Preview by Default** | `scan` command never modifies filesystem | +| **Undo/Restore** | Files staged in `~/.mac_cleaner_trash/` for recovery | +| **Audit Logging** | Every deletion logged to `~/.mac_cleaner_deleted.log` | +| **Final Safety Gate** | Path validation immediately before deletion | +| **Binary Backup** | Fat binaries backed up as `.fat_backup` before thinning | -# All extras -mac-cleaner extras --all +--- -# Universal binary thinner -mac-cleaner binary -mac-cleaner binary --thin -mac-cleaner binary --thin --arch arm64 -``` +## Common Workflows -### History & diff +### Daily Maintenance ```bash -# Show past scans -mac-cleaner history +# Quick scan with live dashboard +mdc dashboard -# Compare two most recent scans -mac-cleaner diff +# Clean up detected items interactively +mdc clean -# Compare specific scans (by ID prefix from history) -mac-cleaner diff abc12345 def67890 +# Check system health +mdc system --health ``` -### Undo +### Developer Cleanup ```bash -# List staged deletion sessions -mac-cleaner undo --list +# Scan with developer junk detection +mdc scan --dev-junk --dev-junk-global -# Restore the latest session -mac-cleaner undo +# Clean Xcode derived data +mdc xcode-cleaner --delete --yes -# Restore a specific session -mac-cleaner undo --session abc12345 - -# Purge old staged files -mac-cleaner undo --purge +# Find large project files +mdc large-files --min-mb 50 --root ~/Projects ``` -### System inspection +### iOS Developer Tools ```bash -mac-cleaner system --all -mac-cleaner system --launch-items -mac-cleaner system --login-items -mac-cleaner system --health -``` - -### P2/P3 system utilities -```bash -# Memory pressure -mac-cleaner memory-pressure -mac-cleaner memory-pressure --relieve - -# Homebrew manager -mac-cleaner brew --outdated -mac-cleaner brew --cleanup --yes +# List simulators and their sizes +mdc simulators -# Storage trend snapshots -mac-cleaner storage-trend --record -mac-cleaner storage-trend --days 7 +# Purge unavailable simulators +mdc simulators --purge-unavailable --yes -# Recent activity cleanup (Recent Items folder only) -mac-cleaner recent-activity -mac-cleaner recent-activity --clear - -# Permissions audit (TCC) -mac-cleaner permissions -mac-cleaner permissions --system --export tcc.json - -# APFS snapshots -mac-cleaner snapshots -mac-cleaner snapshots --delete-older-than 14 --yes +# Manage iOS backups +mdc extras --ios-backups +``` -# Menu bar companion -mac-cleaner menubar install --interval 15 -mac-cleaner menubar status --format swiftbar +### Targeted Cleaning +```bash +# Uninstall an app completely +mdc uninstall "Slack" -# Breach monitor (HIBP) -mac-cleaner breach --email you@example.com --api-key $HIBP_API_KEY +# Clean specific browser data +mdc browser-data --browser chrome --category cache --clean -# Cloud storage junk -mac-cleaner cloud-junk -mac-cleaner cloud-junk --provider dropbox --clean +# Find and remove old installers +mdc installer-hunter --min-age-days 30 --delete --yes ``` -### Scheduler +### Reporting & Automation ```bash -mac-cleaner schedule install -mac-cleaner schedule install --no-notify -mac-cleaner schedule status -mac-cleaner schedule remove -``` +# Export scan results +mdc scan --export report.html +# CI/CD integration +mdc scan --ci --threshold-mb 500 -### Self-update -```bash -mac-cleaner update # check and prompt -mac-cleaner update --yes # upgrade without prompting -mac-cleaner update --check # check only, no upgrade -``` +# Weekly digest +mdc weekly-digest --days 7 -### Config -```bash -mac-cleaner config --init # create default config file -mac-cleaner config --show # print resolved settings +# Compare scans +mdc diff ``` --- -## Configuration (`~/.config/mac-cleaner/config.yaml`) +## Configuration + +Create a config file at `~/.config/mac-cleaner/config.yaml`: ```yaml +# Active profile +profile: developer + +# Protected paths whitelist: - - ~/Library/Application Support/Slack - - ~/Library/Caches/MyApp + - ~/Library/Application Support/ImportantApp + - ~/Projects/critical-folder +# Categories to skip skip_categories: - "System Cache" - "Log File" +# Custom scan locations custom_scan_roots: - - ~/Projects + - ~/Development + - /Volumes/ExternalDrive/Projects +# Scan behavior scan_orphans: true scan_junk: true undo_mode: true retention_days: 30 -notify_after_scan: false +# Thresholds large_file_threshold_mb: 100 duplicate_min_size_kb: 4 -profile: developer # active profile - -scan_dev_junk: false +# Developer options +scan_dev_junk: true scan_dev_junk_global: false dev_junk_roots: - ~/Projects dev_junk_max_depth: 6 +# Custom profiles profiles: minimal: skip_categories: - Xcode Junk - npm Cache - - Cargo Cache - developer: + aggressive: skip_categories: [] large_file_threshold_mb: 50 + scan_dev_junk_global: true ``` +### Profile Types + +| Profile | Best For | Settings | +|---------|----------|----------| +| `beginner` | First-time users | Maximum safety, conservative defaults | +| `developer` | Software engineers | Dev junk enabled, lower thresholds | +| `professional` | Power users | Aggressive cleaning, all features | +| `designer` | Creative professionals | Large file focus, media optimization | +| `minimal` | School/shared devices | Maximum protection, limited scope | + --- -## Safety Guarantees +## Advanced Commands + +### History & Comparison +```bash +# View scan history +mdc history + +# Compare last two scans +mdc diff + +# Compare specific scans +mdc diff abc12345 def67890 +``` + +### Undo Operations +```bash +# List all undo sessions +mdc undo --list + +# Restore latest session +mdc undo + +# Restore specific session +mdc undo --session abc12345 + +# Verify checksums during restore +mdc undo --verify + +# Clean old sessions +mdc undo --purge +``` + +### System Management +```bash +# Full system inspection +mdc system --all + +# Memory management +mdc memory-pressure --relieve + +# Purgeable space +mdc purgeable --thin-gb 10 --yes + +# Storage trends +mdc storage-trend --record +mdc storage-trend --days 7 +``` -| Feature | Description | -|---|---| -| System Protection | `com.apple.*` files are NEVER deleted | -| Running App Guard | Files of currently-running apps are protected | -| Group Container Validation | Team IDs resolved against known vendor DB | -| System Cache Isolation | OS-owned caches skipped automatically | -| Preview by Default | `scan` never modifies the filesystem | -| Undo / Restore | Files staged in `~/.mac_cleaner_trash/` by default | -| Audit Logging | All deletions logged to `~/.mac_cleaner_deleted.log` | -| Final Safety Gate | Every path validated immediately before deletion | -| Binary Backup | Fat binaries backed up as `.fat_backup` before thinning | +### Scheduling & Automation +```bash +# Install weekly scan scheduler +mdc schedule install + +# Check scheduler status +mdc schedule status + +# Remove scheduler +mdc schedule remove +``` + +### Self-Update +```bash +# Check for updates +mdc update --check + +# Update automatically +mdc update --yes +``` + +### Config Management +```bash +# Initialize config +mdc config --init + +# Show current config +mdc config --show + +# Sync across Macs +mdc config-sync export +mdc config-sync import +``` + +--- + +## Example Output + +### Scan Summary +``` +โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— +โ•‘ Mac Deep Cleaner - Scan Results โ•‘ +โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ +โ•‘ Orphaned Apps: 23 items (1.2 GB) โ•‘ +โ•‘ General Junk: 156 items (3.4 GB) โ•‘ +โ•‘ Developer Junk: 42 items (2.1 GB) โ•‘ +โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ +โ•‘ Total Reclaimable: 6.7 GB โ•‘ +โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +Safe to clean: 6.5 GB +Review recommended: 0.2 GB +``` + +### Space Map +``` +/Users (256 GB) +โ”œโ”€โ”€ Library (45 GB) +โ”‚ โ”œโ”€โ”€ Caches (12 GB) +โ”‚ โ”œโ”€โ”€ Application Support (18 GB) +โ”‚ โ””โ”€โ”€ Logs (2.3 GB) +โ”œโ”€โ”€ Documents (89 GB) +โ””โ”€โ”€ Downloads (34 GB) +``` --- ## Requirements -- macOS 10.15+ -- Python 3.9+ -- `rich`, `click`, `pyyaml` (auto-installed) +- **macOS:** 10.15 (Catalina) or later +- **Python:** 3.9 or higher +- **Dependencies:** `rich`, `click`, `pyyaml` (auto-installed) + +--- + +## Contributing + +We welcome contributions! Please read our contributing guidelines before submitting PRs. + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `pytest tests/` +5. Submit a pull request --- ## License -Apache 2.0 +Copyright ยฉ 2024 Mac Deep Cleaner Contributors + +Licensed under the [Apache License 2.0](LICENSE). + +--- + +## Support + +- **Documentation:** [docs/](docs/) +- **Issues:** [GitHub Issues](https://github.com/NK2552003/Mac-Cleaner/issues) +--- + +
+ +**Made with โค๏ธ for the macOS community** + +[โญ Star this repo](https://github.com/NK2552003/Mac-Cleaner) +[Read the COMMAND REFERENCE](docs/COMMAND_REFERENCE.md) +[Report an issue](https://github.com/NK2552003/Mac-Cleaner/issues) + +
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b4d2024 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,1066 @@ +# Mac Deep Cleaner โ€” Architecture & Implementation Guide + +**Version:** 2.0.0 + +This document provides a comprehensive overview of the internal architecture, module structure, and implementation details of Mac Deep Cleaner. + +--- + +## Table of Contents + +1. [Project Structure](#project-structure) +2. [Core Modules](#core-modules) +3. [Scanner Modules](#scanner-modules) +4. [Reporting Modules](#reporting-modules) +5. [Configuration System](#configuration-system) +6. [Safety Mechanisms](#safety-mechanisms) +7. [Data Flow](#data-flow) +8. [Extension Points](#extension-points) + +--- + +## Project Structure + +``` +mac-deep-cleaner/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ __init__.py # Package initialization, version +โ”‚ โ”œโ”€โ”€ cli.py # Main CLI entry point (Click-based) +โ”‚ โ”œโ”€โ”€ constants.py # Global constants, paths, defaults +โ”‚ โ”œโ”€โ”€ utils.py # Utility functions (logging, bytes formatting) +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ config.py # Configuration loading, profiles +โ”‚ โ”‚ โ”œโ”€โ”€ history.py # Scan history management +โ”‚ โ”‚ โ””โ”€โ”€ models.py # Data models (JunkEntry, etc.) +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ core/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ scanner.py # Orphan and junk scanning logic +โ”‚ โ”‚ โ”œโ”€โ”€ cleaner.py # Cleanup execution engine +โ”‚ โ”‚ โ”œโ”€โ”€ safety.py # Safety checks, path validation +โ”‚ โ”‚ โ”œโ”€โ”€ dry_run.py # Dry-run mode handling +โ”‚ โ”‚ โ”œโ”€โ”€ undo.py # Undo/restore session management +โ”‚ โ”‚ โ”œโ”€โ”€ uninstaller.py # App uninstallation logic +โ”‚ โ”‚ โ”œโ”€โ”€ system_inspector.py # Launch items, SIP checks +โ”‚ โ”‚ โ”œโ”€โ”€ memory_pressure.py # Memory stats, purge +โ”‚ โ”‚ โ”œโ”€โ”€ brew_manager.py # Homebrew integration +โ”‚ โ”‚ โ”œโ”€โ”€ completions.py # Shell completion generation +โ”‚ โ”‚ โ”œโ”€โ”€ scheduler.py # LaunchAgent scheduling +โ”‚ โ”‚ โ”œโ”€โ”€ updater.py # PyPI update checking +โ”‚ โ”‚ โ”œโ”€โ”€ menubar.py # SwiftBar/xbar plugin +โ”‚ โ”‚ โ”œโ”€โ”€ config_sync.py # Multi-Mac config sync +โ”‚ โ”‚ โ”œโ”€โ”€ permissions_auditor.py # TCC database audit +โ”‚ โ”‚ โ”œโ”€โ”€ apfs_snapshots.py # APFS snapshot management +โ”‚ โ”‚ โ”œโ”€โ”€ breach_monitor.py # HIBP API integration +โ”‚ โ”‚ โ”œโ”€โ”€ dns_cache.py # DNS flush operations +โ”‚ โ”‚ โ”œโ”€โ”€ font_cache.py # Font cache rebuild +โ”‚ โ”‚ โ”œโ”€โ”€ spotlight.py # Spotlight index management +โ”‚ โ”‚ โ”œโ”€โ”€ power_optimizer.py # Power settings management +โ”‚ โ”‚ โ”œโ”€โ”€ update_checker.py # App update detection +โ”‚ โ”‚ โ”œโ”€โ”€ pkg_receipts.py # PKG receipt management +โ”‚ โ”‚ โ””โ”€โ”€ time_machine_guard.py # Time Machine status +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ scanners/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ discovery.py # App discovery (bundle IDs) +โ”‚ โ”‚ โ”œโ”€โ”€ matching.py # Bundle ID matching logic +โ”‚ โ”‚ โ”œโ”€โ”€ dev_junk.py # Developer junk detection +โ”‚ โ”‚ โ”œโ”€โ”€ duplicates.py # Duplicate file finder +โ”‚ โ”‚ โ”œโ”€โ”€ large_files.py # Large file scanner +โ”‚ โ”‚ โ”œโ”€โ”€ symlinks.py # Broken symlink detector +โ”‚ โ”‚ โ”œโ”€โ”€ space_map.py # Disk usage tree builder +โ”‚ โ”‚ โ”œโ”€โ”€ photos_analyzer.py # Photos library analyzer +โ”‚ โ”‚ โ”œโ”€โ”€ simulators.py # iOS simulator data scanner +โ”‚ โ”‚ โ”œโ”€โ”€ extras.py # iOS backups, language packs +โ”‚ โ”‚ โ”œโ”€โ”€ binary_thinner.py # Fat binary detector +โ”‚ โ”‚ โ”œโ”€โ”€ browser_data.py # Browser cache scanner +โ”‚ โ”‚ โ”œโ”€โ”€ cloud_junk.py # Cloud storage caches +โ”‚ โ”‚ โ”œโ”€โ”€ installer_hunter.py # Installer file finder +โ”‚ โ”‚ โ”œโ”€โ”€ xcode_cleaner.py # Xcode derived data +โ”‚ โ”‚ โ”œโ”€โ”€ purgeable.py # Purgeable space analysis +โ”‚ โ”‚ โ””โ”€โ”€ recent_activity.py # Recent items scanner +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ reporting/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ reporter.py # Console report formatting +โ”‚ โ”œโ”€โ”€ exporter.py # JSON/YAML export +โ”‚ โ”œโ”€โ”€ html_report.py # HTML report generation +โ”‚ โ”œโ”€โ”€ weekly_digest.py # Weekly digest reports +โ”‚ โ”œโ”€โ”€ impact_score.py # Impact score calculation +โ”‚ โ””โ”€โ”€ storage_trend.py # Storage trend tracking +โ”‚ +โ”œโ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ test_scanner.py +โ”‚ โ”œโ”€โ”€ test_features_p0_p1.py +โ”‚ โ””โ”€โ”€ test_features_p2_p3.py +โ”‚ +โ”œโ”€โ”€ docs/ +โ”‚ โ”œโ”€โ”€ COMMAND_REFERENCE.md +โ”‚ โ”œโ”€โ”€ FEATURES.md +โ”‚ โ”œโ”€โ”€ ARCHITECTURE.md (this file) +โ”‚ โ””โ”€โ”€ PYPI_PUBLISHING.md +โ”‚ +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ build.sh +โ”‚ โ””โ”€โ”€ build_pkg.sh +โ”‚ +โ”œโ”€โ”€ Formula/ +โ”‚ โ””โ”€โ”€ mac-deep-cleaner.rb +โ”‚ +โ”œโ”€โ”€ pyproject.toml +โ”œโ”€โ”€ setup.py +โ”œโ”€โ”€ requirements.txt +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ CHANGELOG.md +โ”œโ”€โ”€ LICENSE +โ””โ”€โ”€ SECURITY.md +``` + +--- + +## Core Modules + +### `cli.py` โ€” Command-Line Interface + +**Purpose:** Main entry point for all CLI commands using Click framework. + +**Key Components:** +- `@main` group: Root command group with global options +- Subcommands: 45+ individual commands organized by function +- Shared helpers: `_progress()`, `_ensure_first_run_profile()`, `_run()` + +**Global Options:** +```python +--verbose # Enable debug logging +--log-file # Custom log file path +--dry-run # Preview mode (no modifications) +--version # Show version info +``` + +**Command Categories:** +1. **Core:** scan, clean, dashboard, info +2. **Scanning:** duplicates, large-files, symlinks, space-map, photos, simulators, extras, binary +3. **Cleaning:** uninstall, browser-data, developer, xcode-cleaner, installer-hunter, purgeable, cloud-junk +4. **System:** system, memory-pressure, brew, permissions, snapshots, time-machine +5. **Maintenance:** dns-cache, font-cache, spotlight, power-optimizer, app-updates, pkg-receipts +6. **Monitoring:** history, diff, storage-trend, weekly-digest, impact-score, breach, recent-activity +7. **Configuration:** config, config-sync, schedule, update, completions +8. **Utilities:** tui-picker, menubar, undo + +**The `_run()` Function:** +Shared engine for scan/clean operations: +- Step 1: Discover installed applications +- Step 2: Check running processes +- Step 3: Detect orphaned leftovers +- Step 4: Scan general junk +- Step 5: (Optional) Scan developer junk +- Generates reports, exports, saves history +- Handles undo staging or direct deletion + +--- + +### `core/scanner.py` โ€” Scanning Engine + +**Purpose:** Core scanning logic for orphans and junk. + +**Functions:** + +#### `scan_orphans(apps, whitelist_set, running_bids, roots, enabled)` +Detects leftover data from uninstalled apps. + +**Algorithm:** +1. Collect all known bundle IDs from installed apps +2. Scan standard macOS locations: + - `~/Library/Application Support/` + - `~/Library/Caches/` + - `~/Library/Preferences/` + - `~/Library/Logs/` + - `~/Library/Saved Application State/` + - `~/Library/Group Containers/` +3. For each found item, check if bundle ID matches any installed app +4. Exclude running apps (protected) +5. Apply whitelist filters +6. Return grouped entries by app name + +#### `scan_junk(whitelist_set, apps, roots, skip_categories, enabled)` +Finds general junk files across categories. + +**Categories:** +- User caches (`~/Library/Caches/*`) +- Logs (`~/Library/Logs/*`) +- Crash reports (`~/Library/Logs/DiagnosticReports/*`) +- Trash leftovers +- `.DS_Store` files +- Xcode derived data +- Package manager caches (npm, pip, yarn, pnpm, gradle, maven, cargo, go, cocoapods) +- Browser caches (Chrome, Firefox) + +**Safety Checks:** +- Excludes system-owned items +- Respects skip_categories from config +- Applies whitelist filters + +--- + +### `core/cleaner.py` โ€” Cleanup Engine + +**Purpose:** Execute cleanup operations safely. + +**Functions:** + +#### `do_cleanup(orphans, junk, auto=False)` +Main cleanup function called by `clean` command. + +**Process:** +1. If `auto=True`: Delete all without prompting +2. If `auto=False`: Interactive confirmation per item +3. For each item: + - Validate path via `validate_path_for_deletion()` + - Stage in `~/.mac_cleaner_trash/` (if undo_mode enabled) + - Or delete directly via `safe_remove()` +4. Write deletion log +5. Return total freed bytes + +#### `write_deletion_log(deleted_items)` +Records all deletions to audit log file. + +--- + +### `core/safety.py` โ€” Safety Mechanisms + +**Purpose:** Protect system integrity during cleanup. + +**Key Functions:** + +#### `validate_path_for_deletion(path)` +Final safety gate before any deletion. + +**Checks:** +- Path exists and is accessible +- Not under `/System`, `/Library`, `/usr` +- Not owned by `com.apple.*` +- Not currently in use by running process +- Group container team ID validated + +#### `running_bundle_ids()` +Returns set of bundle IDs for currently running applications. + +**Implementation:** +- Uses `psutil` or `subprocess` to query running processes +- Extracts bundle IDs from app paths +- Cached for performance + +#### `resolve_team_id(bundle_id)` +Resolves Apple Team ID for group container validation. + +--- + +### `core/undo.py` โ€” Undo/Restore System + +**Purpose:** Manage staged deletions for recovery. + +**Data Structures:** + +#### `UndoSession` +```python +class UndoSession: + session_id: str # UUID prefix + created_at: str # ISO timestamp + files: List[StagedFile] + total_size: int + + def save(self) -> None + def restore(self) -> RestoreResult +``` + +#### `StagedFile` +```python +class StagedFile: + original_path: Path + staged_path: Path + size: int + category: str # "Orphan", "Junk", "Dev Junk" + checksum: str # SHA256 for verification +``` + +**Functions:** + +#### `new_session()` +Creates new undo session with unique ID. + +#### `stage_file(path, session, category)` +Moves file to staging area with metadata. + +#### `restore_session(session)` +Restores all files from session to original locations. + +#### `purge_old_sessions(days=30)` +Removes sessions older than retention period. + +--- + +### `core/uninstaller.py` โ€” App Uninstaller + +**Purpose:** Complete app removal with data cleanup. + +**Functions:** + +#### `find_app_candidates(query, apps)` +Finds installed apps matching search query. + +**Matching Strategy:** +- Case-insensitive substring match on app name +- Bundle ID match +- Fuzzy matching for typos + +#### `build_uninstall_plan(app, whitelist_set, keep_preferences=False)` +Creates detailed uninstall plan. + +**Plan Includes:** +- Application bundle path +- All associated data: + - Application Support + - Caches + - Preferences (optional) + - Saved State + - Group Containers + - Logs +- Protected items (excluded by safety checks) + +#### `execute_uninstall(plan, session=None)` +Executes uninstall plan. + +**Process:** +1. Display plan to user +2. Confirm deletion +3. Stage or delete each item +4. Update launch services database +5. Return result summary + +--- + +## Scanner Modules + +### `scanners/discovery.py` โ€” App Discovery + +**Purpose:** Discover all installed macOS applications. + +**Scan Locations:** +- `/Applications` +- `~/Applications` +- `/Users/*/Applications` +- Custom roots from config + +**Output:** Dictionary mapping bundle IDs to `AppInfo` objects. + +```python +class AppInfo: + name: str + bundle_id: str + path: Path + version: str + team_id: Optional[str] +``` + +--- + +### `scanners/dev_junk.py` โ€” Developer Junk + +**Purpose:** Find project-specific build artifacts and dependencies. + +**Patterns Detected:** + +| Pattern | Type | Typical Size | +|---------|------|--------------| +| `node_modules` | Dependencies | 100MB - 2GB | +| `venv`, `.venv` | Python env | 50MB - 500MB | +| `__pycache__` | Python cache | 10MB - 100MB | +| `target` | Rust build | 100MB - 1GB | +| `bin`, `obj` | C#/Unity | 50MB - 500MB | +| `dist`, `build` | Build output | 10MB - 200MB | +| `coverage` | Test coverage | 5MB - 50MB | + +**Global Caches (Optional):** +- `~/.npm` +- `~/.gradle` +- `~/.m2` +- `~/.cargo` +- `~/.nuget` +- `~/.ivy2` +- `~/.sbt` + +**Function:** `find_dev_junk(roots, max_depth, limit, include_global)` + +--- + +### `scanners/duplicates.py` โ€” Duplicate Finder + +**Purpose:** Find byte-identical files using hashing. + +**Algorithm:** + +**Phase 1: Quick Filter** +- Group files by size +- Skip unique sizes +- Only hash files with matching sizes + +**Phase 2: Hash Comparison** +- Compute SHA256 for candidates +- Group by hash value +- Report groups with 2+ files + +**Optimization:** +- Minimum size threshold (default: 100KB) +- Progress callback for UI updates +- Skip system directories + +**Output:** List of `DuplicateGroup` objects. + +```python +class DuplicateGroup: + hash: str + size: int + paths: List[Path] + + @property + def wasted_bytes(self) -> int: + return self.size * (len(self.paths) - 1) +``` + +--- + +### `scanners/large_files.py` โ€” Large File Scanner + +**Purpose:** Find files exceeding size threshold. + +**Categories:** +- Videos (.mp4, .mov, .avi, .mkv) +- Archives (.zip, .tar, .gz, .rar, .7z) +- Disk Images (.dmg, .iso, .cdr) +- Applications (.app bundles) +- Documents (.pdf, .psd, .ai, .sketch) +- Other + +**Function:** `find_large_files(roots, min_bytes, limit)` + +--- + +### `scanners/symlinks.py` โ€” Symlink Checker + +**Purpose:** Find broken symbolic links. + +**Scan Roots:** +- Developer directories +- Homebrew prefixes +- Custom paths + +**Output:** List of `BrokenSymlink` objects. + +```python +class BrokenSymlink: + path: Path # Link location + target: str # Missing target path + location: str # Parent directory +``` + +--- + +### `scanners/space_map.py` โ€” Disk Usage Map + +**Purpose:** Build tree visualization of disk usage. + +**Algorithm:** +1. Walk directory tree up to max_depth +2. Aggregate sizes per folder +3. Sort children by size descending +4. Filter by minimum size threshold + +**Output:** `UsageNode` tree structure. + +```python +class UsageNode: + path: Path + size: int + children: List[UsageNode] + + def render(limit=12) -> str: + # Returns ASCII tree visualization +``` + +--- + +### `scanners/photos_analyzer.py` โ€” Photos Library Analyzer + +**Purpose:** Analyze Photos library storage usage. + +**Analysis:** +- Library bundle size +- Originals folder (masters) +- Previews folder +- Database file +- File type breakdown + +**Function:** `analyze_photo_library(library_path)` + +**Output:** `PhotosReport` object. + +```python +class PhotosReport: + name: str + path: Path + size: int + originals_size: int + previews_size: int + database_size: int + originals_count: int + + def top_extensions(n=8) -> List[Tuple[str, int, int]]: + # Returns [(ext, count, size), ...] +``` + +--- + +### `scanners/simulators.py` โ€” Simulator Cleaner + +**Purpose:** Manage iOS Simulator data. + +**Components:** + +#### Device Data +- Runtime support files +- App installations +- User data + +#### Caches +- CoreSimulator caches +- Device logs +- Temporary files + +**Functions:** +- `find_simulator_devices()` โ€” List available simulators +- `find_simulator_caches()` โ€” Find cache directories +- `purge_simulator_devices(devices)` โ€” Delete device data +- `purge_simulator_caches(caches)` โ€” Delete caches + +--- + +### `scanners/extras.py` โ€” Extra Scanners + +**iOS Backups:** +- Location: `~/Library/Application Support/MobileSync/Backup/` +- Info extracted: device name, iOS version, backup date, size + +**Language Packs:** +- Scan `/Applications/*.app/Contents/Resources/*.lproj` +- Identify removable language directories +- Calculate potential savings + +--- + +### `scanners/binary_thinner.py` โ€” Binary Thinner + +**Purpose:** Detect and thin universal binaries. + +**Detection:** +- Use `lipo -info` to check architectures +- Identify fat binaries (arm64 + x86_64) + +**Thinning:** +- Use `ditto --arch ` (Apple-recommended) +- Create backup before modification +- Calculate estimated savings + +--- + +### `scanners/browser_data.py` โ€” Browser Data Scanner + +**Supported Browsers:** +- Safari +- Chrome +- Firefox +- Edge +- Brave + +**Data Categories:** +- Cache files +- Cookies (SQLite databases) +- History (SQLite databases) +- Downloads history +- Site data (LocalStorage, IndexedDB) +- Session data + +**Safety:** +- Browser must be closed for safe deletion +- Some files may be locked + +--- + +### `scanners/cloud_junk.py` โ€” Cloud Storage Junk + +**Providers:** +- Dropbox (`~/Library/Application Support/Dropbox/`) +- Google Drive (`~/Library/Application Support/Google/Drive/`) +- OneDrive (`~/Library/Containers/com.microsoft.OneDrive-mac/`) +- Box (`~/Library/Application Support/Box/`) + +**Data Types:** +- Cache files +- Log files +- Temporary downloads + +--- + +### `scanners/installer_hunter.py` โ€” Installer Hunter + +**File Types:** +- `.pkg` โ€” Package installer +- `.dmg` โ€” Disk image +- `.mpkg` โ€” Meta-package + +**Optional:** +- `.zip`, `.tar`, `.gz` โ€” Archives + +**Filters:** +- Minimum age (days) +- Minimum size (MB) +- Scan roots (Downloads, Desktop, Documents) + +--- + +### `scanners/xcode_cleaner.py` โ€” Xcode Cleaner + +**Categories:** +- DerivedData (`~/Library/Developer/Xcode/DerivedData/`) +- DeviceSupport (`~/Library/Developer/Xcode/iOS DeviceSupport/`) +- Archives (`~/Library/Developer/Xcode/Archives/`) +- Caches (`~/Library/Caches/com.apple.dt.Xcode/`) +- Documentation + +--- + +### `scanners/purgeable.py` โ€” Purgeable Space + +**Purpose:** Analyze and reclaim purgeable space. + +**Sources:** +- Local Time Machine snapshots +- Optimized Storage files +- iCloud cached data + +**Actions:** +- `tmutil thinlocalsnapshots` โ€” Reclaim space +- Delete old snapshots by policy + +--- + +### `scanners/recent_activity.py` โ€” Recent Activity + +**Location:** `~/Library/Recent Items/` + +**Categories:** +- Recent documents +- Recent applications +- Recent servers +- Recent volumes + +--- + +## Reporting Modules + +### `reporting/reporter.py` โ€” Console Reports + +**Purpose:** Format and display results in terminal. + +**Functions:** +- `print_banner()` โ€” Show tool header +- `print_orphan_report(orphans)` โ€” Format orphan findings +- `print_junk_report(junk)` โ€” Format junk findings +- `print_dev_junk_report(entries)` โ€” Format dev junk +- `print_summary(...)` โ€” Show totals and hints +- `print_instructions()` โ€” Next steps guidance + +--- + +### `reporting/exporter.py` โ€” Data Export + +**Formats:** +- JSON (`.json`) +- YAML (`.yaml`, `.yml`) +- HTML (`.html`) + +**Functions:** +- `export_json(orphans, junk, dev_junk, path)` +- `export_yaml(orphans, junk, dev_junk, path)` +- `export_html(orphans, junk, dev_junk, path)` + +--- + +### `reporting/html_report.py` โ€” HTML Reports + +**Features:** +- Self-contained HTML file +- Chart.js via CDN for visualizations +- Interactive tables +- Responsive design + +**Sections:** +- Summary dashboard +- Orphan details +- Junk breakdown +- Dev junk list +- Charts (pie, bar) + +--- + +### `reporting/weekly_digest.py` โ€” Weekly Digest + +**Purpose:** Generate periodic summary reports. + +**Metrics:** +- Scan count in period +- Total reclaimable +- Average per scan +- Top orphaned apps +- Top junk categories +- Trends vs previous period + +--- + +### `reporting/impact_score.py` โ€” Impact Score + +**Purpose:** Calculate cleaning effectiveness score. + +**Factors:** +- Total bytes reclaimed +- Item counts +- Category diversity +- Historical comparison + +**Score Range:** 0-100 + +**Labels:** +- 0-20: Minimal +- 21-40: Low +- 41-60: Moderate +- 61-80: High +- 81-100: Excellent + +--- + +### `reporting/storage_trend.py` โ€” Storage Trends + +**Purpose:** Track disk usage over time. + +**Storage:** +- Snapshots saved to `~/.config/mac-cleaner/storage_trends.json` + +**Metrics:** +- Used space +- Free space +- Delta between snapshots +- Trend direction + +--- + +## Configuration System + +### `config/config.py` โ€” Configuration Management + +**Config File:** `~/.config/mac-cleaner/config.yaml` + +**Structure:** +```yaml +profile: beginner + +whitelist: + - ~/important-folder + +skip_categories: + - browser-cache + +custom_scan_roots: + - ~/Projects + +dev_junk_roots: + - ~/Development + +scan_orphans: true +scan_junk: true +scan_dev_junk: false +scan_dev_junk_global: false + +undo_mode: true +retention_days: 30 + +large_file_threshold_mb: 100 +dev_junk_max_depth: 5 +``` + +**Profiles:** +- `beginner` โ€” Safe defaults +- `developer` โ€” Dev junk enabled +- `professional` โ€” Aggressive thresholds +- `designer` โ€” Large file focus +- `student` โ€” School device safe +- `children` โ€” Maximum safety + +--- + +### `config/history.py` โ€” Scan History + +**Storage:** `~/.config/mac-cleaner/history/*.json` + +**Record Structure:** +```python +class ScanRecord: + scan_id: str # UUID + scanned_at: datetime + profile: str + orphan_bytes: int + junk_bytes: int + dev_junk_bytes: int + summary: dict + + def save(self) -> None + def load(scan_id) -> ScanRecord +``` + +**Functions:** +- `list_history(limit=10)` โ€” Get recent records +- `diff_scans(older, newer)` โ€” Compare two scans +- `build_scan_record(orphans, junk, dev_junk, profile)` โ€” Create record + +--- + +### `config/models.py` โ€” Data Models + +**Key Classes:** + +#### `JunkEntry` +```python +class JunkEntry: + path: Path + size: int + category: str + is_system: bool + app_name: Optional[str] + + def to_dict() -> dict +``` + +#### `OrphanEntry` +```python +class OrphanEntry: + path: Path + size: int + bundle_id: str + category: str # "Application Support", "Caches", etc. +``` + +--- + +## Safety Mechanisms + +### Protection Layers + +1. **Preview First:** `scan` never modifies filesystem +2. **System Protection:** `com.apple.*` items excluded +3. **Running App Guard:** Active apps protected +4. **Group Container Validation:** Team ID verification +5. **System Cache Isolation:** OS caches require explicit flag +6. **Path Validation:** Every path checked before deletion +7. **Undo Support:** Staging area for recovery +8. **Audit Logging:** All deletions recorded + +### Dry-Run Mode + +```python +from core.dry_run import dry_run_enabled, skip_if_dry_run + +if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; action skipped.[/yellow]") + return + +if skip_if_dry_run(ctx, console, "operation name"): + return +``` + +--- + +## Data Flow + +### Scan Flow + +``` +User runs: mac-cleaner scan + โ†“ +cli.main() โ†’ ctx.invoke(scan) + โ†“ +_ensure_first_run_profile() โ†’ load_config() + โ†“ +discover_installed_apps() + โ†“ +running_bundle_ids() + โ†“ +scan_orphans() โ†’ validate against running apps + โ†“ +scan_junk() โ†’ filter by categories + โ†“ +(Optional) find_dev_junk() + โ†“ +Generate reports (console, export) + โ†“ +Save to history + โ†“ +Display summary +``` + +### Clean Flow + +``` +User runs: mac-cleaner clean + โ†“ +Same scan steps as above + โ†“ +For each item: + โ†“ +validate_path_for_deletion() + โ†“ +If undo_mode: + stage_file() โ†’ ~/.mac_cleaner_trash/ +Else: + safe_remove() + โ†“ +write_deletion_log() + โ†“ +Display result +``` + +### Undo Flow + +``` +User runs: mac-cleaner undo --session ABCD + โ†“ +list_sessions() โ†’ find matching session + โ†“ +Confirm restoration + โ†“ +For each staged file: + โ†“ +Verify checksum (if --verify) + โ†“ +Move back to original path + โ†“ +Update session status + โ†“ +Display result +``` + +--- + +## Extension Points + +### Adding New Scanners + +1. Create new module in `src/scanners/` +2. Implement discovery function returning list of entries +3. Add CLI command in `cli.py`: + ```python + @main.command("new-scanner") + def cmd_new_scanner(...): + from scanners.new_scanner import find_items + items = find_items(...) + # Display and handle results + ``` + +### Adding New Cleaners + +1. Implement cleanup logic in `src/core/` or `src/scanners/` +2. Add safety checks via `core/safety.py` +3. Integrate with undo system if needed +4. Add CLI command with `--delete` flag + +### Adding New Export Formats + +1. Create exporter in `src/reporting/` +2. Implement export function +3. Add to `cli.py` export logic: + ```python + if export_path.endswith(".newformat"): + from reporting.new_exporter import export_newformat + export_newformat(orphans, junk, dev_junk, export_path) + ``` + +### Adding New Profiles + +1. Edit `src/config/config.py` +2. Add profile to `DEFAULT_PROFILES` dict +3. Define settings for new profile + +--- + +## Testing + +### Test Structure + +``` +tests/ +โ”œโ”€โ”€ test_scanner.py # Unit tests for scanner logic +โ”œโ”€โ”€ test_features_p0_p1.py # Priority 0/1 feature tests +โ””โ”€โ”€ test_features_p2_p3.py # Priority 2/3 feature tests +``` + +### Running Tests + +```bash +pytest tests/ +pytest tests/test_scanner.py -v +pytest tests/test_features_p0_p1.py -k "test_orphan_detection" +``` + +--- + +## Performance Considerations + +### Optimization Strategies + +1. **Two-Phase Hashing:** Size pre-filter before hashing +2. **Parallel Scanning:** Thread pools for I/O-bound operations +3. **Caching:** Bundle ID resolution cached +4. **Progressive Display:** Live updates during long scans +5. **Size Thresholds:** Skip tiny files in duplicate/large-file scans +6. **Depth Limits:** Configurable recursion limits + +### Memory Management + +- Stream file walks instead of loading all paths +- Limit result sets with `--limit` flags +- Generator patterns for large datasets + +--- + +## Security Considerations + +### Input Validation + +- All paths resolved and validated +- Symlinks followed safely +- No shell injection in subprocess calls + +### Privilege Escalation + +- No sudo/admin privileges required +- Limited to user-accessible paths +- SIP-protected areas excluded + +### Data Privacy + +- No telemetry by default +- Optional breach monitoring uses official HIBP API +- Local-only operation unless explicitly configured + +--- + +*Documentation generated for Mac Deep Cleaner v2.0.0* diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md new file mode 100644 index 0000000..c3b4db1 --- /dev/null +++ b/docs/COMMAND_REFERENCE.md @@ -0,0 +1,1152 @@ +# Mac Deep Cleaner โ€” Command Reference + +**Version:** 2.0.0 +**CLI Commands:** `mac-cleaner` or `mdc` + +This document provides comprehensive reference documentation for all available commands in Mac Deep Cleaner. + +--- + +## Table of Contents + +1. [Core Commands](#core-commands) +2. [Scanning Commands](#scanning-commands) +3. [Cleaning Commands](#cleaning-commands) +4. [System Commands](#system-commands) +5. [Developer Tools](#developer-tools) +6. [Maintenance Commands](#maintenance-commands) +7. [Monitoring & Reporting](#monitoring--reporting) +8. [Configuration](#configuration) + +--- + +## Core Commands + +### `scan` + +Preview scan for orphaned app leftovers and junk (safe, read-only). + +```bash +mac-cleaner scan [OPTIONS] +``` + +**Options:** +- `--skip-junk` โ€” Skip general junk scanning +- `--export PATH` โ€” Export results to JSON/YAML/HTML (by extension) +- `--whitelist PATH` โ€” Add paths to whitelist (can be repeated) +- `--show-apps` โ€” Show installed applications list +- `--profile NAME` โ€” Use specified config profile +- `--dev-junk` โ€” Scan developer junk (node_modules, venv, build dirs) +- `--dev-junk-global` โ€” Include global caches (~/.npm, ~/.gradle, etc.) +- `--dev-root PATH` โ€” Additional developer roots to scan +- `--root PATH` โ€” Additional directories to scan +- `--notify` โ€” Post macOS notification when scan completes +- `--dry-run` โ€” Explicit alias for scan behavior (never deletes) +- `--save-history` โ€” Save scan result to history (default: on) +- `--ci` โ€” Emit machine-readable JSON summary +- `--threshold-mb N` โ€” CI threshold; exit 1 when reclaimable exceeds N MB + +**Examples:** +```bash +mac-cleaner scan +mac-cleaner scan --dev-junk --export report.html +mac-cleaner scan --ci --threshold-mb 500 +mac-cleaner scan --profile developer --notify +``` + +--- + +### `clean` + +Interactively clean orphaned app leftovers and junk. + +```bash +mac-cleaner clean [OPTIONS] +``` + +**Options:** +- `--auto` โ€” Auto-delete all detected items without prompting +- `--skip-junk` โ€” Skip general junk cleaning +- `--whitelist PATH` โ€” Add paths to whitelist +- `--export PATH` โ€” Export results before cleaning +- `--profile NAME` โ€” Use specified config profile +- `--dev-junk` โ€” Clean developer junk +- `--dev-junk-global` โ€” Include global caches +- `--dev-root PATH` โ€” Additional developer roots +- `--root PATH` โ€” Additional directories to scan +- `--notify` โ€” Post notification when complete +- `--no-undo` โ€” Permanently delete instead of staging for undo + +**Behavior:** +- By default, deleted files are staged in `~/.mac_cleaner_trash/` +- Staged files can be restored with `mac-cleaner undo` +- Pass `--no-undo` for permanent deletion (faster, no recovery) + +**Examples:** +```bash +mac-cleaner clean --auto +mac-cleaner clean --dev-junk --no-undo +mac-cleaner clean --profile aggressive +``` + +--- + +### `dashboard` + +Run a live Rich dashboard showing scan progress in real-time. + +```bash +mac-cleaner dashboard [OPTIONS] +``` + +**Options:** +- `--profile NAME` โ€” Config profile to use +- `--dev-junk` โ€” Scan developer junk +- `--dev-junk-global` โ€” Include global caches +- `--dev-root PATH` โ€” Additional developer roots +- `--root PATH` โ€” Additional directories to scan + +**Display:** +- Installed apps count +- Running apps (protected) +- Orphan groups and size +- General junk items and size +- Dev junk items and size +- Total reclaimable space +- Top findings by category + +--- + +### `info` + +Show tool information and safety guarantees. + +```bash +mac-cleaner info +``` + +**Safety Guarantees:** +- **System Protection:** com.apple.* files are NEVER deleted +- **Running App Guard:** Files of currently-running apps are protected +- **Group Container Validation:** Team IDs resolved against known vendor DB +- **System Cache Isolation:** OS-owned caches require explicit flag +- **Preview by Default:** 'scan' never modifies the filesystem +- **Undo / Restore:** Deletions staged in ~/.mac_cleaner_trash/ by default +- **Audit Logging:** All deletions logged +- **Final Safety Gate:** Every path validated before deletion + +--- + +## Scanning Commands + +### `duplicates` + +Find duplicate files by content hash. + +```bash +mac-cleaner duplicates [OPTIONS] +``` + +**Options:** +- `--path PATH` โ€” Directories to scan (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures) +- `--min-size KB` โ€” Minimum file size in KB to consider (default: 100) +- `--export PATH` โ€” Export results to JSON +- `--delete` โ€” Interactively delete duplicates (keeps first copy) + +**Algorithm:** +- Two-phase hashing for speed and accuracy +- Only scans user directories (never /System) +- Groups identical files by hash + +**Examples:** +```bash +mac-cleaner duplicates +mac-cleaner duplicates --path ~/Photos --min-size 1024 +mac-cleaner duplicates --delete --export dupes.json +``` + +--- + +### `large-files` + +Find large files anywhere on disk, sorted by size. + +```bash +mac-cleaner large-files [OPTIONS] +``` + +**Options:** +- `--path PATH` โ€” Directories to scan +- `--min-mb MB` โ€” Minimum file size in MB (default: 100) +- `--limit N` โ€” Maximum results to show (default: 100) +- `--export PATH` โ€” Export results to JSON + +**Categories:** +- Videos +- Archives +- Disk Images +- Applications +- Documents +- Other + +**Examples:** +```bash +mac-cleaner large-files +mac-cleaner large-files --min-mb 500 --limit 50 +mac-cleaner large-files --path /Volumes/External --export large.json +``` + +--- + +### `symlinks` + +Find broken (dangling) symbolic links in developer directories. + +```bash +mac-cleaner symlinks [OPTIONS] +``` + +**Options:** +- `--path PATH` โ€” Directories to scan (default: common dev paths) +- `--delete` โ€” Delete broken symlinks after confirmation + +**Default Scan Roots:** +- ~/Projects +- ~/Development +- ~/Code +- /usr/local +- Homebrew prefixes + +**Examples:** +```bash +mac-cleaner symlinks +mac-cleaner symlinks --path ~/projects --delete +``` + +--- + +### `space-map` + +Visual disk space map showing folder usage. + +```bash +mac-cleaner space-map [OPTIONS] +``` + +**Options:** +- `--root PATH` โ€” Root directories to map (default: HOME) +- `--depth N` โ€” Folder depth to include (default: 2) +- `--limit N` โ€” Maximum child entries per directory (default: 12) +- `--min-mb MB` โ€” Minimum size per entry in MB (default: 1) +- `--export PATH` โ€” Export map to JSON + +**Output:** +- Tree visualization of disk usage +- Size annotations per folder +- Configurable depth and filtering + +--- + +### `photos` + +Analyze Photos libraries and storage usage. + +```bash +mac-cleaner photos [OPTIONS] +``` + +**Options:** +- `--root PATH` โ€” Search roots for Photos libraries (default: ~/Pictures) +- `--anywhere` โ€” Search recursively under roots or home folder +- `--depth N` โ€” Max recursion depth for --anywhere (default: 6) +- `--details` โ€” Show file type breakdown for originals +- `--export PATH` โ€” Export analysis to JSON + +**Analysis Includes:** +- Library total size +- Originals size and count +- Previews size +- Database size +- File type breakdown (HEIC, JPEG, PNG, etc.) + +--- + +### `simulators` + +Inspect and clean iOS Simulator data. + +```bash +mac-cleaner simulators [OPTIONS] +``` + +**Options:** +- `--purge-unavailable` โ€” Delete data for unavailable simulators only +- `--purge-all` โ€” Delete data for all simulators (destructive) +- `--purge-caches` โ€” Delete CoreSimulator caches and logs +- `--yes` โ€” Skip confirmation prompts + +**Data Types:** +- Simulator device data +- Runtime support files +- CoreSimulator caches +- Device logs + +--- + +### `extras` + +Scan for iOS backups and removable language packs. + +```bash +mac-cleaner extras [OPTIONS] +``` + +**Options:** +- `--ios-backups` โ€” Scan for old iOS/iPhone backups +- `--language-packs` โ€” Scan for removable language packs in /Applications +- `--all` โ€” Run all extra scans +- `--delete-backups` โ€” Interactively delete old iOS backups +- `--strip-languages` โ€” Interactively strip unused language packs + +**iOS Backup Info:** +- Device name +- iOS version +- Age (days) +- Size + +**Language Packs:** +- Identifies removable .lproj directories +- Shows potential space savings per app + +--- + +### `binary` + +Detect universal (fat) binaries and optionally thin them. + +```bash +mac-cleaner binary [OPTIONS] +``` + +**Options:** +- `--path PATH` โ€” Directories to scan +- `--arch ARCH` โ€” Target arch: arm64 or x86_64 (defaults to current CPU) +- `--thin` โ€” Interactively thin fat binaries +- `--no-backup` โ€” Skip .fat_backup copy (irreversible!) + +**How It Works:** +- Detects binaries containing both arm64 and x86_64 slices +- Uses `ditto --arch` (Apple-recommended method) +- Creates backup before thinning (unless --no-backup) + +--- + +## Cleaning Commands + +### `uninstall` + +Remove an app and its data (full uninstall). + +```bash +mac-cleaner uninstall APP_QUERY [OPTIONS] +``` + +**Options:** +- `--yes` โ€” Skip confirmation and uninstall immediately +- `--no-undo` โ€” Permanently delete instead of staging +- `--keep-preferences` โ€” Keep Preferences and Saved State data +- `--force` โ€” Allow uninstall even if app appears running + +**Uninstall Plan Includes:** +- Application bundle +- Application Support leftovers +- Caches +- Logs +- Group Container data (validated) + +**Examples:** +```bash +mac-cleaner uninstall "Google Chrome" +mac-cleaner uninstall Safari --keep-preferences +mac-cleaner uninstall Xcode --yes --no-undo +``` + +--- + +### `browser-data` + +Analyze and optionally clean browser data. + +```bash +mac-cleaner browser-data [OPTIONS] +``` + +**Options:** +- `--browser NAME` โ€” Limit to specific browsers (safari, chrome, firefox, edge, brave) +- `--category TYPE` โ€” Limit to categories (cache, cookies, history, downloads, site-data, sessions) +- `--clean` โ€” Delete selected data +- `--all` โ€” Delete all supported categories for selected browsers +- `--yes` โ€” Skip confirmation + +**Supported Browsers:** +- Safari +- Chrome +- Firefox +- Edge +- Brave + +**Data Categories:** +- Cache files +- Cookies +- Browsing history +- Download history +- Site data (localStorage, IndexedDB) +- Session data + +--- + +### `developer` + +Scan and optionally clean developer junk. + +```bash +mac-cleaner developer [OPTIONS] +``` + +**Options:** +- `--root PATH` โ€” Roots to scan (default: config + common project folders) +- `--max-depth N` โ€” Max depth for scanning +- `--global` โ€” Include global caches (~/.npm, ~/.gradle, etc.) +- `--limit N` โ€” Limit number of items returned (0 = no limit) +- `--delete` โ€” Delete detected developer junk +- `--no-undo` โ€” Permanently delete instead of staging +- `--yes` โ€” Skip confirmation prompts +- `--export PATH` โ€” Export report to JSON +- `--profile NAME` โ€” Config profile to use + +**Detected Patterns:** +- node_modules +- venv, .venv, __pycache__ +- target (Rust) +- bin, obj (C#/Unity) +- dist, build +- coverage +- .gradle, .m2, .cargo, .nuget (global caches) + +--- + +### `xcode-cleaner` + +Inspect and clean Xcode derived data and caches. + +```bash +mac-cleaner xcode-cleaner [OPTIONS] +``` + +**Options:** +- `--category NAME` โ€” Limit cleanup to matching categories +- `--delete` โ€” Delete selected Xcode data +- `--yes` โ€” Skip confirmation prompts +- `--export PATH` โ€” Export results to JSON + +**Categories:** +- DerivedData +- DeviceSupport +- Archives +- Caches +- Documentation + +--- + +### `installer-hunter` + +Find old installers and PKG files. + +```bash +mac-cleaner installer-hunter [OPTIONS] +``` + +**Options:** +- `--root PATH` โ€” Roots to scan (default: Downloads/Desktop/Documents) +- `--min-age-days N` โ€” Only show installers older than N days +- `--min-mb MB` โ€” Minimum size in MB +- `--include-archives` โ€” Include .zip/.tar archives +- `--limit N` โ€” Maximum results to show (default: 200) +- `--delete` โ€” Delete installers under allowed roots +- `--yes` โ€” Skip confirmation prompts +- `--export PATH` โ€” Export results to JSON + +**File Types Detected:** +- .pkg +- .dmg +- .mpkg +- Optional: .zip, .tar, .gz + +--- + +### `purgeable` + +Inspect purgeable space and reclaim via snapshot thinning. + +```bash +mac-cleaner purgeable [OPTIONS] +``` + +**Options:** +- `--volume PATH` โ€” Volume path to inspect (default: /) +- `--thin-gb GB` โ€” Reclaim at least this many GB using tmutil thinning +- `--thin-mb MB` โ€” Reclaim at least this many MB +- `--delete-older-than N` โ€” Delete local snapshots older than N days +- `--keep N` โ€” Keep the newest N snapshots +- `--yes` โ€” Skip confirmation prompts +- `--export PATH` โ€” Export summary to JSON + +--- + +### `cloud-junk` + +Scan cloud storage caches and logs. + +```bash +mac-cleaner cloud-junk [OPTIONS] +``` + +**Options:** +- `--provider NAME` โ€” Limit to provider (dropbox, google-drive, onedrive, box) +- `--clean` โ€” Delete detected cache/log directories +- `--yes` โ€” Skip confirmation for deletions + +**Providers Supported:** +- Dropbox +- Google Drive +- OneDrive +- Box + +--- + +## System Commands + +### `system` + +Inspect startup items, login items, and system security health. + +```bash +mac-cleaner system [OPTIONS] +``` + +**Options:** +- `--launch-items` โ€” Show LaunchAgents / LaunchDaemons +- `--login-items` โ€” Show Login Items +- `--health` โ€” Check system health (SIP, Full Disk Access) +- `--all` โ€” Run all checks + +**Health Checks:** +- SIP (System Integrity Protection) status +- Full Disk Access availability +- macOS version +- Security warnings + +--- + +### `memory-pressure` + +Inspect memory pressure and optionally purge caches. + +```bash +mac-cleaner memory-pressure [OPTIONS] +``` + +**Options:** +- `--relieve` โ€” Run purge to relieve memory pressure +- `--yes` โ€” Skip confirmation for purge + +**Metrics Displayed:** +- Total memory +- Used memory +- Free memory +- Compressed memory +- Free percentage +- Pressure level +- Swap used/free + +--- + +### `brew` + +Manage Homebrew caches and maintenance. + +```bash +mac-cleaner brew [OPTIONS] +``` + +**Options:** +- `--outdated` โ€” Check for outdated formulae and casks +- `--cleanup` โ€” Run brew cleanup +- `--prune-all` โ€” Run brew cleanup --prune=all +- `--autoremove` โ€” Run brew autoremove +- `--doctor` โ€” Run brew doctor +- `--update` โ€” Run brew update +- `--yes` โ€” Skip confirmation for maintenance actions + +**Information Displayed:** +- Homebrew version +- Prefix location +- Cache size +- Cellar size +- Formulae count +- Casks count + +--- + +### `permissions` + +Audit macOS privacy permissions (TCC database). + +```bash +mac-cleaner permissions [OPTIONS] +``` + +**Options:** +- `--system` โ€” Include system-wide TCC database (may require privileges) +- `--export PATH` โ€” Export entries to JSON + +**Permissions Audited:** +- Camera access +- Microphone access +- Screen recording +- Accessibility +- Full Disk Access +- Contacts, Calendar, Reminders +- Photos library access + +--- + +### `snapshots` + +Inspect and prune APFS local snapshots. + +```bash +mac-cleaner snapshots [OPTIONS] +``` + +**Options:** +- `--volume PATH` โ€” Volume path to inspect (default: /) +- `--delete-older-than N` โ€” Delete snapshots older than N days +- `--keep N` โ€” Keep the newest N snapshots +- `--yes` โ€” Skip confirmation for deletions + +--- + +### `time-machine` + +Inspect and guard Time Machine status. + +```bash +mac-cleaner time-machine [OPTIONS] +``` + +**Options:** +- `--enable` โ€” Enable Time Machine backups +- `--disable` โ€” Disable Time Machine backups +- `--warn-days N` โ€” Warn if last backup is older than N days (default: 7) +- `--export PATH` โ€” Export status to JSON + +**Status Information:** +- Backup destinations +- Local snapshot count +- Last backup timestamp +- Backup age warning + +--- + +## Maintenance Commands + +### `dns-cache` + +Flush DNS caches. + +```bash +mac-cleaner dns-cache [OPTIONS] +``` + +**Options:** +- `--flush` โ€” Flush DNS caches +- `--yes` โ€” Skip confirmation prompts + +--- + +### `font-cache` + +Rebuild font caches. + +```bash +mac-cleaner font-cache [OPTIONS] +``` + +**Options:** +- `--rebuild` โ€” Rebuild font caches using atsutil +- `--clear-user` โ€” Delete user font cache folders before rebuild +- `--yes` โ€” Skip confirmation prompts + +--- + +### `spotlight` + +Inspect or rebuild Spotlight index. + +```bash +mac-cleaner spotlight [OPTIONS] +``` + +**Options:** +- `--volume PATH` โ€” Volume path to inspect (default: /) +- `--reindex` โ€” Rebuild Spotlight index +- `--enable` โ€” Enable Spotlight indexing +- `--disable` โ€” Disable Spotlight indexing +- `--yes` โ€” Skip confirmation prompts + +--- + +### `power-optimizer` + +Show or apply power optimization settings. + +```bash +mac-cleaner power-optimizer [OPTIONS] +``` + +**Options:** +- `--apply` โ€” Apply recommended power settings +- `--restore` โ€” Restore last saved power profile +- `--scope SCOPE` โ€” all, battery, or ac (default: all) +- `--yes` โ€” Skip confirmation prompts + +**Settings Managed:** +- Standby delay +- Power nap +- Wake on LAN +- Display sleep timers +- Hard disk sleep + +--- + +### `app-updates` + +Check for app updates across system, brew, and App Store. + +```bash +mac-cleaner app-updates [OPTIONS] +``` + +**Options:** +- `--system` โ€” Check macOS software updates +- `--brew` โ€” Check Homebrew updates +- `--mas` โ€” Check Mac App Store updates (requires mas) +- `--all` โ€” Run all checks (default) +- `--export PATH` โ€” Export results to JSON + +--- + +### `pkg-receipts` + +Inspect and manage pkg receipts. + +```bash +mac-cleaner pkg-receipts [OPTIONS] +``` + +**Options:** +- `--search STRING` โ€” Filter receipts by substring +- `--limit N` โ€” Limit results (default: 30) +- `--details` โ€” Show detailed receipt info +- `--forget ID` โ€” Forget a pkg receipt by identifier +- `--yes` โ€” Skip confirmation prompts +- `--export PATH` โ€” Export receipts to JSON + +--- + +## Monitoring & Reporting + +### `history` + +Show past scan records stored in ~/.config/mac-cleaner/history/. + +```bash +mac-cleaner history [OPTIONS] +``` + +**Options:** +- `--limit N` โ€” Number of records to show (default: 10) + +**Information Displayed:** +- Scan date +- Profile used +- Orphan bytes +- Junk bytes +- Total reclaimable + +--- + +### `diff` + +Compare two scan records. + +```bash +mac-cleaner diff [SCAN_A] [SCAN_B] +``` + +**Arguments:** +- `SCAN_A` โ€” Older scan ID prefix (optional, defaults to second most recent) +- `SCAN_B` โ€” Newer scan ID prefix (optional, defaults to most recent) + +**Comparison Shows:** +- Size delta between scans +- New orphans +- Resolved orphans +- Persistent orphans + +--- + +### `storage-trend` + +Track disk usage trends over time. + +```bash +mac-cleaner storage-trend [OPTIONS] +``` + +**Options:** +- `--record` โ€” Record a new snapshot before showing results +- `--limit N` โ€” Maximum snapshots to display (default: 12) +- `--days N` โ€” Summarize only the last N days +- `--export PATH` โ€” Export snapshots to JSON +- `--volume PATH` โ€” Volume path to record (default: /) + +--- + +### `weekly-digest` + +Generate a weekly scan digest report. + +```bash +mac-cleaner weekly-digest [OPTIONS] +``` + +**Options:** +- `--days N` โ€” Number of days to include (default: 7) +- `--export PATH` โ€” Export digest to JSON + +**Report Includes:** +- Date range +- Scan count +- Total reclaimable +- Average per scan +- Top orphaned apps +- Top junk categories +- Top dev junk categories + +--- + +### `impact-score` + +Compute a cleaning impact score from scan history. + +```bash +mac-cleaner impact-score [OPTIONS] +``` + +**Options:** +- `--scan-id PREFIX` โ€” Scan ID prefix to score (default: latest) +- `--export PATH` โ€” Export impact score to JSON + +**Score Factors:** +- Total reclaimable bytes +- Item counts +- Category distribution +- Historical trends + +--- + +### `breach` + +Check emails against Have I Been Pwned. + +```bash +mac-cleaner breach [OPTIONS] +``` + +**Options:** +- `--email ADDRESS` โ€” Email address to check (can be repeated) +- `--api-key KEY` โ€” HIBP API key (or set HIBP_API_KEY env var) +- `--delay SECONDS` โ€” Delay between requests (default: 1.6) +- `--use-watchlist` โ€” Check addresses saved in watchlist +- `--save` โ€” Save provided emails to watchlist +- `--export PATH` โ€” Export results to JSON + +--- + +### `recent-activity` + +Scan and optionally clear recent activity files. + +```bash +mac-cleaner recent-activity [OPTIONS] +``` + +**Options:** +- `--clear` โ€” Clear items under ~/Library/Recent Items +- `--yes` โ€” Skip confirmation for clearing + +**Categories:** +- Recent documents +- Recent applications +- Recent servers +- Recent volumes + +--- + +## Configuration + +### `config` + +Manage the configuration file (~/.config/mac-cleaner/config.yaml). + +```bash +mac-cleaner config [OPTIONS] +``` + +**Options:** +- `--init` โ€” Create a default config file if none exists +- `--show` โ€” Print the resolved config +- `--profile NAME` โ€” Use specified profile + +**Usage:** +```bash +mac-cleaner config --init # Create default config +mac-cleaner config --show # Print resolved settings +mac-cleaner config --profile dev # Show dev profile config +``` + +--- + +### `config-sync` + +Sync configuration across multiple Macs. + +```bash +mac-cleaner config-sync SUBCOMMAND [OPTIONS] +``` + +**Subcommands:** + +#### `export` +Export config to sync directory. +```bash +mac-cleaner config-sync export [--dest DIR] [--include-history] [--no-icloud] +``` + +#### `import` +Import config from sync directory. +```bash +mac-cleaner config-sync import [--src DIR] [--no-icloud] [--no-backup] +``` + +#### `status` +Show sync metadata. +```bash +mac-cleaner config-sync status [--dir DIR] [--no-icloud] +``` + +--- + +### `schedule` + +Manage weekly automatic scan schedule. + +```bash +mac-cleaner schedule SUBCOMMAND +``` + +**Subcommands:** + +#### `install` +Install a weekly LaunchAgent to run scans automatically. +```bash +mac-cleaner schedule install [--no-notify] +``` + +#### `remove` +Remove the weekly scan LaunchAgent. +```bash +mac-cleaner schedule remove +``` + +#### `status` +Show whether the weekly scan is scheduled and loaded. +```bash +mac-cleaner schedule status +``` + +--- + +### `update` + +Check for a newer version on PyPI and optionally upgrade. + +```bash +mac-cleaner update [OPTIONS] +``` + +**Options:** +- `--check` โ€” Check only, do not upgrade +- `--yes, -y` โ€” Upgrade without prompting + +--- + +### `completions` + +Generate shell completion scripts. + +```bash +mac-cleaner completions [OPTIONS] +``` + +**Options:** +- `--shell TYPE` โ€” Shell type: bash, zsh, fish +- `--instructions` โ€” Show install instructions for your shell + +**Installation:** +```bash +# Bash +mac-cleaner completions --shell bash >> ~/.bash_completions + +# Zsh +mac-cleaner completions --shell zsh > ~/.zfunc/_mac-cleaner + +# Fish +mac-cleaner completions --shell fish > ~/.config/fish/completions/mac-cleaner.fish +``` + +--- + +### `tui-picker` + +Interactive app picker for uninstall operations. + +```bash +mac-cleaner tui-picker [OPTIONS] +``` + +**Options:** +- `--uninstall` โ€” Uninstall the selected app +- `--no-undo` โ€” Permanently delete instead of staging +- `--reveal` โ€” Reveal the app in Finder +- `--open` โ€” Open the app after selection +- `--yes` โ€” Skip confirmation prompts + +--- + +### `menubar` + +Menu bar companion for SwiftBar/xbar. + +```bash +mac-cleaner menubar SUBCOMMAND +``` + +**Subcommands:** + +#### `status` +Emit status for menu bar tools. +```bash +mac-cleaner menubar status [--format plain|swiftbar] +``` + +#### `install` +Install a menu bar plugin script. +```bash +mac-cleaner menubar install [--target swiftbar|xbar] [--interval MIN] [--dir PATH] +``` + +#### `remove` +Remove menu bar plugin scripts. +```bash +mac-cleaner menubar remove [--target swiftbar|xbar] [--dir PATH] +``` + +--- + +### `undo` + +Restore files from the staging area (undo a clean operation). + +```bash +mac-cleaner undo [OPTIONS] +``` + +**Options:** +- `--list` โ€” List available sessions without restoring +- `--session ID` โ€” Session ID prefix to restore (default: latest) +- `--purge` โ€” Permanently purge old staged files beyond retention period +- `--purge-all` โ€” Permanently purge ALL staged sessions regardless of age +- `--verify` โ€” Verify checksums after restore + +**Session Management:** +- Sessions stored in ~/.mac_cleaner_trash/ +- Sessions older than 30 days auto-purged +- Each session has unique ID for restoration + +--- + +### `uninstall-cli` + +Uninstall mac-cleaner CLI and data. + +> Note: This command removes the mac-cleaner installation itself along with all configuration and history data. + +--- + +## Global Options + +These options are available on most commands: + +- `--verbose` โ€” Enable debug logging +- `--log-file PATH` โ€” Write logs to specified file +- `--dry-run` โ€” Do not modify anything (preview mode) +- `--yes` โ€” Skip confirmation prompts +- `--help` โ€” Show command help + +--- + +## Exit Codes + +- `0` โ€” Success +- `1` โ€” Error or threshold exceeded (in CI mode) +- `2` โ€” Invalid arguments + +--- + +## Environment Variables + +- `HIBP_API_KEY` โ€” Have I Been Pwned API key for breach monitoring +- `MAC_CLEANER_CONFIG` โ€” Override config file location +- `NO_COLOR` โ€” Disable colored output + +--- + +## Configuration Profiles + +Built-in profiles for different user types: + +| Profile | Focus | Recommended For | +|---------|-------|-----------------| +| `beginner` | Safe defaults, skips dev caches | General users | +| `developer` | Includes dev junk scanning | Software developers | +| `professional` | Aggressive dev cleanup, lower thresholds | Power users | +| `designer` | Larger file focus, no dev junk | Creative professionals | +| `student` | Safe defaults for school devices | Students | +| `children` | Minimal, safest defaults | Children's devices | + +--- + +*Documentation generated for Mac Deep Cleaner v2.0.0* diff --git a/docs/FEATURES.md b/docs/FEATURES.md deleted file mode 100644 index 9386be4..0000000 --- a/docs/FEATURES.md +++ /dev/null @@ -1,117 +0,0 @@ -# Mac Deep Cleaner โ€” Features (v1.5.0) - -**Mac Deep Cleaner** is a professional macOS cleanup tool that safely detects and helps you remove leftover data from uninstalled apps, along with general junk that accumulates over time. It is designed to be **safe by default**, with **preview-first** workflows and **undo/restore support**. - -All commands are available as both `mac-cleaner ...` and `mdc ...`. - -## What it does - -### 1) Smart App Orphan Detection -Detects leftover app data after uninstalling apps, including items such as: -- Application Support leftovers -- Preferences -- Caches -- Logs -- Saved State -- Group Container data (validated before acting) - -### 2) General Junk Scanner -Finds common junk categories such as: -- Caches (user-owned) -- Logs -- Crash/Diagnostic reports -- Trash leftovers -- `.DS_Store` files -- Xcode derived data / device support artifacts -- Package manager caches (npm/pip/yarn/pnpm/gradle/maven/cargo/go/cocoapods) -- Browser caches (Chrome/Firefox) - -### 2b) Developer Junk Scanner (opt-in) -Finds project build output and dependency directories such as: -- `node_modules`, `venv`, `__pycache__`, `target`, `bin/obj`, `dist`, `coverage` -Optionally includes global caches (e.g., `~/.npm`, `~/.gradle`, `~/.m2`, `~/.cargo`, `~/.nuget`). - -### 3) Duplicate File Finder (by hash) -Identifies duplicates using **two-phase hashing** for speed and accuracy, allowing you to: -- Review duplicate groups -- Estimate wasted space -- Delete extra copies (with confirmations) - -### 4) Large Files Scanner -Finds files over a configurable size threshold and presents results grouped by category. - -### 5) Broken Symlink Detector -Scans common developer paths for dangling symbolic links and reports them with targets. - -### 6) iOS / iPhone Backup Finder -Locates old iOS/iPhone backups and surfaces: -- Device name -- iOS version -- Age -- Size - -Optionally deletes backups interactively. - -### 7) Language Pack Stripper -Finds removable language pack `.lproj` directories and helps you strip unused languages to reclaim space. - -### 8) Universal Binary Thinner (fat binaries) -Detects universal (fat) binaries and can thin them to your target architecture using `ditto --arch`. - -### 9) Undo / Restore (staged deletions) -Instead of permanent deletion, files can be staged into: -- `~/.mac_cleaner_trash/` -with session manifests, enabling restore via: -- `mac-cleaner undo` - -### 10) YAML Configuration + Profiles -Uses `~/.config/mac-cleaner/config.yaml` with profiles to control scanning behavior such as: -- whitelist / skips -- custom scan roots -- scan category toggles -- undo mode -- retention days -- large file threshold - -### 11) Scan History + Diff -Supports: -- storing scan results in history -- comparing two scans to see whatโ€™s new or resolved - -### 12) HTML Report Export -Exports a self-contained HTML report (with Chart.js via CDN) for offline review. - -### 13) System Inspector -Checks/prints: -- Launch Agents / Launch Daemons -- Login items -- SIP and permission health hints - -### 14) Scheduler -Install/remove/status management for weekly automated scans. - -### 15) Self-update (PyPI) -Checks for updates from PyPI and optionally upgrades. - -### 16) CI / Automation Mode -`mac-cleaner scan --ci --threshold-mb N` prints JSON to stdout and exits with -code `1` when reclaimable bytes exceed the threshold. This is intended for -dotfile repos, scheduled jobs, and GitHub Actions-style checks. - -### 17) Live Dashboard -`mac-cleaner dashboard` uses Rich Live/Layout to show installed apps, protected -running apps, orphan groups, junk items, and reclaimable size while the scan is -running. - -### 18) Distribution Helpers -The project includes: -- `bash scripts/build.sh build` for wheel and sdist -- `bash scripts/build.sh test` for compile and CLI smoke checks -- `bash scripts/build.sh pkg` for an unsigned local macOS package -- `Formula/mac-deep-cleaner.rb` as a Homebrew formula scaffold - -## Safety-first behavior (summary) -- **Preview-first**: `scan` doesnโ€™t modify the filesystem -- **System protection**: system-owned items are isolated and protected -- **Validation gate**: paths are validated right before staging/deletion -- **Undo supported**: staged deletions can be restored diff --git a/docs/PYPI_PUBLISHING.md b/docs/PYPI_PUBLISHING.md index 5c3bd45..6aa4c92 100644 --- a/docs/PYPI_PUBLISHING.md +++ b/docs/PYPI_PUBLISHING.md @@ -1,4 +1,4 @@ -# Publishing to PyPI (pypi.org) โ€” mac-deep-cleaner (v1.5.0) +# Publishing to PyPI (pypi.org) โ€” mac-deep-cleaner (v2.0.0) ## What youโ€™ll publish This project is configured to build with `setuptools` from `pyproject.toml` (PEP 621). The package name is: @@ -50,8 +50,8 @@ pip install mac-deep-cleaner==1.2.0 If you want to keep credentials out of shell history: - Use your CI secret store to set `TWINE_USERNAME` and `TWINE_PASSWORD`. -## Quick checklist for v1.5.0 +## Quick checklist for v2.0.0 - `pyproject.toml` โ†’ `project.version = "1.2.0"` -- `README.md` / docs reflect v1.5.0 +- `README.md` / docs reflect v2.0.0 - `python3 -m build` produces valid wheel + sdist - `twine upload dist/*` succeeds diff --git a/docs/test.md b/docs/test.md deleted file mode 100644 index 3e7541b..0000000 --- a/docs/test.md +++ /dev/null @@ -1,347 +0,0 @@ -# Mac Deep Cleaner (mac_cleaner) - -> Project version: **1.0.0** - -This document explains **how the tool works** and provides **command-by-command documentation** for the CLI. - ---- - -## How it works - -Mac Deep Cleaner is a macOS cleanup tool focused on identifying: -1. **Orphaned app leftovers** (files left behind after apps are removed) -2. **User-junk** (caches/logs/trash items) โ€” preview first -3. Optional categories and โ€œextrasโ€ (duplicates, large files, symlinks, iOS backups, etc.) - -### Safety model (high level) -- **Preview mode** is the default behavior: it reports what would be removed. -- When you run **`clean`**, you can choose interactive cleanup or auto cleanup. -- **Undo mode (staging)** is enabled by default for delete operations performed via `clean`. - - Staged files are stored under: `~/.mac_cleaner_trash/` - - You can restore them using: `mac-cleaner undo` -- Some system paths are protected by validation and policy checks in the safety layer (SIP/system protections, running app protections, and path validation). - ---- - -## CLI usage - -### Top-level command -All subcommands live under the Click CLI: - -```bash -python -m mac_cleaner.cli --help -``` - -(If installed, you can also use `mac-cleaner --help`.) - ---- - -## Commands - -### 1) `scan` -Preview scan for **orphaned app leftovers** and **(optionally)** general junk. - -```bash -mac-cleaner scan [OPTIONS] -``` - -Options: -- `--skip-junk` - Only scan orphaned leftovers; skip junk scanning. -- `--export PATH` - Export results by extension: - - `.json` โ†’ JSON - - `.yaml` / `.yml` โ†’ YAML - - `.html` โ†’ HTML report -- `--whitelist PATH` *(multiple allowed)* - Add paths to the protection whitelist (in addition to config whitelist). -- `--show-apps` - Show discovered installed apps list. -- `--profile PROFILE` - Use a config profile (e.g. `developer`, `minimal`, `aggressive` depending on config). -- `--notify` - Post macOS notification when scan completes. -- `--dry-run` - Explicit alias for scan (never deletes). -- `--save-history / --no-save-history` - Save scan results to history (default: on). - -Behavior: -- Discovers installed applications. -- Checks running processes (protected apps). -- Detects orphaned app leftovers. -- Optionally scans caches/logs/trash for junk. - ---- - -### 2) `clean` -Interactive or auto cleanup of orphaned leftovers + junk. - -```bash -mac-cleaner clean [OPTIONS] -``` - -Options: -- `--auto` - Delete automatically without per-item confirmation. -- `--skip-junk` - Clean only orphaned leftovers; skip junk clean. -- `--whitelist PATH` *(multiple allowed)* - Protect these paths. -- `--export PATH` - Export scan results after scanning (before deletion). -- `--profile PROFILE` - Use a config profile. -- `--notify` - Post macOS notification when scan completes. -- `--no-undo` - Permanently delete instead of staging for undo. - -Undo behavior: -- Default: deletions are **staged** (undoable). -- With `--no-undo`: deletions are **permanent**. - ---- - -### 3) `info` -Show tool information and safety guarantees. - -```bash -mac-cleaner info -``` - -No options. - ---- - -### 4) `duplicates` -Find duplicate files by content hash. - -```bash -mac-cleaner duplicates [OPTIONS] -``` - -Options: -- `--path PATH` *(multiple allowed)* - Directories to scan. -- `--min-size KB` (default: `100`) - Ignore smaller files. -- `--export PATH` - Export results to JSON. -- `--delete` - Interactively delete duplicate copies (keeps the first copy). - -Notes: -- Scans user directories (intended behavior is to avoid `/System`). - ---- - -### 5) `large-files` -Find large files and group results by category. - -```bash -mac-cleaner large-files [OPTIONS] -``` - -Options: -- `--path PATH` *(multiple allowed)* - Roots to scan (if provided). -- `--min-mb MB` (default: `100`) - Minimum file size in MB. -- `--limit N` (default: `100`) - Maximum results to show. -- `--export PATH` - Export results to JSON. - -Output: -- Table of largest files by category. - ---- - -### 6) `symlinks` -Find broken (dangling) symbolic links. - -```bash -mac-cleaner symlinks [OPTIONS] -``` - -Options: -- `--path PATH` *(multiple allowed)* - Roots to scan. -- `--delete` - Delete broken symlinks after confirmation. - ---- - -### 7) `extras` -Additional scans: -- iOS backups (old devices) -- removable language packs in `/Applications` - -```bash -mac-cleaner extras [OPTIONS] -``` - -Options: -- `--ios-backups` - Run iOS backup scan. -- `--language-packs` - Run language pack scan. -- `--all` - Run both extras scans. -- `--delete-backups` - Interactively delete old iOS backups. -- `--strip-languages` - Interactively strip removable language packs from matching apps. - -Notes: -- If you do not specify `--ios-backups`, `--language-packs`, or `--all`, the command aborts. - ---- - -### 8) `binary` -Detect universal (โ€œfatโ€) binaries and optionally thin them. - -```bash -mac-cleaner binary [OPTIONS] -``` - -Options: -- `--path PATH` *(multiple allowed)* - Roots to scan. -- `--arch {arm64|x86_64}` - Target arch. Defaults to current CPU. -- `--thin` - Interactively thin fat binaries to target arch. -- `--no-backup` - Skip `.fat_backup` copy (irreversible). - -Behavior: -- Uses Apple recommended method (`ditto --arch`) behind the scenes. - ---- - -### 9) `undo` -Restore staged files from staging area. - -```bash -mac-cleaner undo [OPTIONS] -``` - -Options: -- `--list` - List available sessions without restoring. -- `--session PREFIX` - Restore a specific session by ID prefix (default: latest). -- `--purge` - Permanently purge old staged files beyond retention period. - ---- - -### 10) `history` -Show past scan records stored in history config directory. - -```bash -mac-cleaner history [OPTIONS] -``` - -Options: -- `--limit N` (default: `10`) - ---- - -### 11) `diff` -Compare two scan records. - -```bash -mac-cleaner diff [SCAN_A] [SCAN_B] -``` - -- `SCAN_A`, `SCAN_B` are scan ID prefixes from `history`. - -If omitted: -- compares the two most recent scans. - -Output: -- Size delta -- new/resolved/persistent orphan lists. - ---- - -### 12) `system` -Inspect startup items, login items, and system security health. - -```bash -mac-cleaner system [OPTIONS] -``` - -Options: -- `--launch-items` - Inspect LaunchAgents/LaunchDaemons. -- `--login-items` - Inspect login items. -- `--health` - Check SIP + related hints. -- `--all` - Run all checks. - -If none of the above flags are supplied, the command aborts with guidance. - ---- - -### 13) `schedule` -Manage weekly automatic scan schedule. - -```bash -mac-cleaner schedule COMMAND -``` - -Subcommands: -- `install --no-notify` -- `remove` -- `status` - -Notes: -- The underlying implementation sets up a LaunchAgent/weekly runner. - ---- - -### 14) `update` -Check for newer version and optionally upgrade. - -```bash -mac-cleaner update [OPTIONS] -``` - -Options: -- `--check` - Check only (no upgrade). -- `--yes` / `-y` - Upgrade without prompting. - ---- - -### 15) `config` -Show or initialize config. - -```bash -mac-cleaner config [OPTIONS] -``` - -Options: -- `--init` - Create a default config file if none exists. -- `--show` - Print the resolved config. -- `--profile PROFILE` - Use a config profile while resolving. - -Default behavior: -- shows the resolved configuration panel (and shows JSON if `--show` is specified). - ---- - -## Notes / tips -- Use `scan` first to preview what would be removed. -- Use `clean` with undo enabled for safer cleanup. -- Use `undo --list` to view staging sessions and `undo --session ` to restore. diff --git a/pyproject.toml b/pyproject.toml index 22bce5d..30dcf1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mac-deep-cleaner" -version = "1.5.0" +version = "2.0.0" description = "Professional Mac cleanup tool โ€” Smart App Orphan Detector" readme = "README.md" license = "Apache-2.0" diff --git a/roadmap.md b/roadmap.md index b6da033..d25a750 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,37 +1,9 @@ # Mac Deep Cleaner v2.x Roadmap -Date: 2026-05-11 +Date: 2026-05-15 ## Goals - Ship a full v2.x feature set with professional-grade safety, logging, and undo support. - Keep destructive actions opt-in and gated by explicit flags and confirmations. - Keep new dependencies minimal and justified; document when optional. - Add a feature module per roadmap item, grouped by domain. - -## Proposed Feature Modules (one per feature) - -## Cross-cutting Work -- Add new CLI subcommands and options in src/cli.py -- Extend config schema in src/config/config.py for new features -- Add logging and safe-path validation in src/core/safety.py where needed -- Expand reporting exports in src/reporting for new outputs -- Add tests for parsers and non-destructive scanners in tests/ - -## External Dependencies (tentative) -- textual or prompt_toolkit for interactive TUI picker -- rumps for menu bar companion -- requests or urllib for HIBP API (prefer urllib to avoid new deps) -- pandas/pyarrow NOT planned (keep lightweight) - -## Safety Gates -- All destructive operations honor --dry-run and undo staging. -- Time Machine guard before bulk deletes. -- APFS snapshot support behind explicit flags. -- Restore checksum verification for staged files. - -## Open Decisions (needs confirmation) -- Preferred TUI library (textual vs prompt_toolkit) -- Whether to add optional dependencies vs strict core only -- Handling sudo-required operations (auto prompt vs printed instructions) -- HIBP API key provisioning and storage -- Minimum supported macOS version for system commands diff --git a/scripts/build.sh b/scripts/build.sh index 809aef3..3027cce 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Mac Deep Cleaner v1.5.0 โ€” Build & Install Script +# Mac Deep Cleaner v2.0.0 โ€” Build & Install Script # ============================================================================= # Usage: # bash build.sh โ†’ default: build wheel + sdist diff --git a/src/__init__.py b/src/__init__.py index c049eed..af1e5c1 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,7 +1,6 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Professional Edition Smart App Orphan Detector & System Cleanup Tool for macOS """ -__version__ = "1.5.0" +__version__ = "2.0.0" __author__ = "NK2552003" diff --git a/src/cli.py b/src/cli.py index 8b3c0b5..1fbfe70 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """ -Mac Deep Cleaner v1.5.0 โ€” CLI Entry Point -======================================= All subcommands, new and updated. Subcommands @@ -11,6 +9,7 @@ info Safety guarantees completions Generate shell completion scripts uninstall Full app uninstaller + uninstall-cli Uninstall mac-cleaner CLI and data browser-data Clean browser caches/history/cookies space-map Visual disk space map photos Photo library analyzer @@ -28,6 +27,7 @@ brew Homebrew manager (cache + cleanup) storage-trend Storage usage trend tracker recent-activity Recent files/activity scanner + developer Developer junk scanner / cleanup permissions Audit macOS privacy permissions (TCC) snapshots APFS local snapshot guard menubar Menu bar companion (SwiftBar/xbar) @@ -36,6 +36,20 @@ schedule Install / remove / status of weekly scan update Check for and apply upgrades config Show / init config file + purgeable Purgeable space reclaimer + installer-hunter Find old installers and PKG files + dns-cache Flush DNS cache + font-cache Rebuild font cache + spotlight Re-index Spotlight + power-optimizer Sleep and power optimizer + app-updates App update checker + pkg-receipts PKG receipt manager + xcode-cleaner Xcode derived data cleaner + weekly-digest Weekly digest report + impact-score Cleaning impact score + tui-picker Interactive TUI app picker + config-sync Multi-Mac config sync + time-machine Time Machine backup guard """ from __future__ import annotations @@ -44,7 +58,7 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple import click from rich.console import Console @@ -126,8 +140,11 @@ def _ensure_first_run_profile(profile: Optional[str], ci: bool) -> Optional[str] table = Table(show_header=True, header_style="bold cyan", border_style="dim") table.add_column("Profile", style="bold") table.add_column("Focus") + table.add_column("Recommended", justify="center", width=12) + recommended = "beginner" for name in choices: - table.add_row(name, descriptions.get(name, "")) + rec = "yes" if name == recommended else "" + table.add_row(name, descriptions.get(name, ""), rec) console.print() console.print(Panel( @@ -165,7 +182,7 @@ def main( log_file: Optional[str], dry_run: bool, ) -> None: - """Mac Deep Cleaner v1.5.0 โ€” Professional macOS cleanup tool.""" + """Mac Deep Cleaner v2.0.0 โ€” Professional macOS cleanup tool.""" from core.dry_run import set_dry_run configure_logging( verbose=verbose, @@ -513,6 +530,138 @@ def clean( ) +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# DEVELOPER JUNK +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("developer") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Roots to scan for developer junk (default: config + common project folders).") +@click.option("--max-depth", default=None, type=int, + help="Max depth for dev junk scanning (default: config value).") +@click.option("--global", "include_global", is_flag=True, default=False, + help="Include global caches (~/.npm, ~/.gradle, etc).") +@click.option("--limit", default=0, show_default=True, + help="Limit number of items returned (0 = no limit).") +@click.option("--delete", is_flag=True, default=False, + help="Delete detected developer junk.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export report to JSON.") +@click.option("--profile", default=None, help="Config profile to use.") +@click.pass_context +def cmd_developer( + ctx: click.Context, + roots: Tuple[str, ...], + max_depth: Optional[int], + include_global: bool, + limit: int, + delete: bool, + no_undo: bool, + yes: bool, + export_path: Optional[str], + profile: Optional[str], +) -> None: + """Scan and optionally clean developer junk (node_modules, venv, build dirs).""" + from core.dry_run import skip_if_dry_run + from core.safety import validate_path_for_deletion + from scanners.dev_junk import find_dev_junk + + cfg = load_config(profile=profile) + wl = cfg.whitelist_set + roots_list = [Path(p).expanduser().resolve() for p in roots] or (cfg.dev_junk_roots or None) + depth = max_depth if max_depth is not None else cfg.dev_junk_max_depth + include_global = include_global or bool(getattr(cfg, "scan_dev_junk_global", False)) + limit_val = None if limit <= 0 else limit + + console.print() + console.print(Panel("[bold cyan]Developer Junk[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + with _progress() as prog: + task = prog.add_task("Scanning developer junk...", total=None) + entries = find_dev_junk( + roots=roots_list, + max_depth=depth, + limit=limit_val, + include_global=include_global, + ) + prog.update(task, completed=100, total=100) + + if wl: + entries = [ + e for e in entries + if e.path not in wl and not any(w in e.path.parents for w in wl) + ] + + total = print_dev_junk_report(entries) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "total_bytes": total, + "total_human": bytes_human(total), + "entries": [e.to_dict() for e in entries], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete or not entries: + return + + if skip_if_dry_run(ctx, console, "developer junk cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask( + f"Delete developer junk ({bytes_human(total)})?", + default=False, + ) + if not do_it: + return + + if cfg.undo_mode and not no_undo: + session = new_session() + freed = 0 + for e in entries: + safe, _ = validate_path_for_deletion(e.path) + if safe: + ok, sz = stage_file(e.path, session, category="Dev Junk") + if ok: + freed += sz + session.save() + console.print( + f"\n [green]Staged {bytes_human(freed)} for undo[/green]" + ) + console.print( + f" [dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]" + ) + return + + from core.cleaner import write_deletion_log + from utils import safe_remove + + freed = 0 + deleted = [] + for e in entries: + safe, _ = validate_path_for_deletion(e.path) + if safe: + ok, sz = safe_remove(e.path) + if ok: + freed += sz + deleted.append((str(e.path), sz)) + if deleted: + write_deletion_log(deleted) + console.print(f"\n [green]Removed {bytes_human(freed)}[/green]") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # INFO # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -825,12 +974,18 @@ def cmd_space_map( @main.command("photos") @click.option("--root", "roots", multiple=True, type=click.Path(exists=True), help="Search roots for Photos libraries (default: ~/Pictures).") +@click.option("--anywhere", is_flag=True, default=False, + help="Search recursively under roots (or your home folder when no roots are provided).") +@click.option("--depth", default=6, show_default=True, + help="Max recursion depth for --anywhere searches.") @click.option("--details", is_flag=True, default=False, help="Show file type breakdown for originals.") @click.option("--export", "export_path", type=click.Path(), default=None, help="Export analysis to JSON.") def cmd_photos( roots: Tuple[str, ...], + anywhere: bool, + depth: int, details: bool, export_path: Optional[str], ) -> None: @@ -838,8 +993,24 @@ def cmd_photos( from scanners.photos_analyzer import analyze_photo_library, find_photo_libraries from constants import HOME - search_roots = [Path(p).expanduser().resolve() for p in roots] or [HOME / "Pictures"] - libs = find_photo_libraries(search_roots=search_roots) + if roots: + search_roots = [Path(p).expanduser().resolve() for p in roots] + else: + search_roots = [HOME] if anywhere else [HOME / "Pictures"] + + libs = find_photo_libraries( + search_roots=search_roots, + recursive=anywhere, + max_depth=depth, + ) + if not libs and not anywhere and not roots: + libs = find_photo_libraries( + search_roots=[HOME], + recursive=True, + max_depth=depth, + ) + if libs: + console.print("[dim]No libraries in ~/Pictures; searched your home folder instead.[/dim]") if not libs: console.print("[yellow]No Photos libraries found in the selected roots.[/yellow]") return @@ -1458,6 +1629,8 @@ def cmd_binary( help="Permanently purge old staged files beyond retention period.") @click.option("--purge-all", "purge_all", is_flag=True, default=False, help="Permanently purge ALL staged sessions regardless of age.") +@click.option("--verify", is_flag=True, default=False, + help="Verify checksums after restore.") @click.pass_context def cmd_undo( ctx: click.Context, @@ -1465,6 +1638,7 @@ def cmd_undo( session_id: Optional[str], purge: bool, purge_all: bool, + verify: bool, ) -> None: """Restore files from the staging area (undo a clean operation). @@ -1552,15 +1726,29 @@ def cmd_undo( f"({len(target.files)} files, {target.total_size_human})?", default=False, ): - result = restore_session(target) - console.print( - f"\n [green]โœ“ Restored {result.restored} file(s) " - f"({bytes_human(result.bytes_restored)})[/green]" - ) - if result.failed: - console.print(f" [yellow]โš  {result.failed} file(s) could not be restored[/yellow]") - for err in result.errors: - console.print(f" [dim]{err}[/dim]") + if verify: + from core.restore_checksums import restore_with_verification + vresult = restore_with_verification(target) + console.print( + f"\n [green]โœ“ Restored {vresult.restored} file(s) " + f"({bytes_human(vresult.bytes_restored)})[/green]" + ) + if vresult.mismatched: + console.print(f" [yellow]โš  {vresult.mismatched} checksum mismatch(es)[/yellow]") + if vresult.failed: + console.print(f" [yellow]โš  {vresult.failed} file(s) failed to restore[/yellow]") + for err in vresult.errors: + console.print(f" [dim]{err}[/dim]") + else: + result = restore_session(target) + console.print( + f"\n [green]โœ“ Restored {result.restored} file(s) " + f"({bytes_human(result.bytes_restored)})[/green]" + ) + if result.failed: + console.print(f" [yellow]โš  {result.failed} file(s) could not be restored[/yellow]") + for err in result.errors: + console.print(f" [dim]{err}[/dim]") # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -2071,6 +2259,979 @@ def cmd_recent_activity(ctx: click.Context, clear: bool, yes: bool) -> None: console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# PURGEABLE SPACE +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("purgeable") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--thin-gb", default=None, type=int, + help="Reclaim at least this many GB using tmutil thinning.") +@click.option("--thin-mb", default=None, type=int, + help="Reclaim at least this many MB using tmutil thinning.") +@click.option("--delete-older-than", default=None, type=int, + help="Delete local snapshots older than N days.") +@click.option("--keep", default=None, type=int, + help="Keep the newest N snapshots.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export summary to JSON.") +@click.pass_context +def cmd_purgeable( + ctx: click.Context, + volume: str, + thin_gb: Optional[int], + thin_mb: Optional[int], + delete_older_than: Optional[int], + keep: Optional[int], + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect purgeable space and reclaim via snapshot thinning.""" + from core.dry_run import skip_if_dry_run + from scanners.purgeable import ( + collect_purgeable_sources, + delete_snapshots_by_policy, + summarize_sources, + thin_local_snapshots, + ) + + sources = collect_purgeable_sources(volume) + + console.print() + console.print(Panel("[bold cyan]Purgeable Space[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Source", min_width=24) + table.add_column("Detail", min_width=24) + table.add_column("Size", justify="right", style="yellow", width=12) + for s in sources: + size_label = s.size_human if s.bytes > 0 else "unknown" + table.add_row(s.name, s.detail, size_label) + console.print(table) + + if export_path: + import json + with open(export_path, "w") as f: + json.dump(summarize_sources(sources), f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + do_thin = thin_gb is not None or thin_mb is not None + do_delete = delete_older_than is not None or keep is not None + if not (do_thin or do_delete): + return + + if skip_if_dry_run(ctx, console, "purgeable cleanup"): + return + + if not yes: + from rich.prompt import Confirm + if not Confirm.ask("Proceed with purgeable cleanup?", default=False): + return + + if do_thin: + target_bytes = 0 + if thin_gb is not None: + target_bytes = thin_gb * 1024 * 1024 * 1024 + elif thin_mb is not None: + target_bytes = thin_mb * 1024 * 1024 + result = thin_local_snapshots(volume, target_bytes) + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if do_delete: + deleted, total = delete_snapshots_by_policy( + volume, + keep=keep, + older_than_days=delete_older_than, + ) + console.print(f" Deleted {deleted}/{total} snapshot(s)") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# INSTALLER HUNTER +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("installer-hunter") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Roots to scan (default: Downloads/Desktop/Documents).") +@click.option("--min-age-days", default=None, type=int, + help="Only show installers older than N days.") +@click.option("--min-mb", default=0, show_default=True, + help="Minimum size in MB.") +@click.option("--include-archives", is_flag=True, default=False, + help="Include .zip/.tar archives.") +@click.option("--limit", default=200, show_default=True, + help="Maximum results to show.") +@click.option("--delete", is_flag=True, default=False, + help="Delete installers under allowed roots.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +@click.pass_context +def cmd_installer_hunter( + ctx: click.Context, + roots: Tuple[str, ...], + min_age_days: Optional[int], + min_mb: int, + include_archives: bool, + limit: int, + delete: bool, + yes: bool, + export_path: Optional[str], +) -> None: + """Find old installers and PKG files.""" + from core.dry_run import skip_if_dry_run + from scanners.installer_hunter import delete_installers, find_installers + + root_paths = [Path(p).expanduser().resolve() for p in roots] or None + items = find_installers( + roots=root_paths, + min_age_days=min_age_days, + min_size_mb=min_mb, + include_archives=include_archives, + limit=limit, + ) + + console.print() + console.print(Panel("[bold cyan]Installer Hunter[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not items: + console.print("[green]No installers found.[/green]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Kind", width=12) + table.add_column("Age (days)", justify="right", width=9) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + for item in items: + table.add_row(item.kind, str(item.age_days), item.size_human, str(item.path)) + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "count": len(items), + "items": [ + { + "path": str(i.path), + "size": i.size, + "kind": i.kind, + "modified_at": i.modified_at, + "age_days": i.age_days, + } + for i in items + ], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete: + return + + if skip_if_dry_run(ctx, console, "installer cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Delete installer files?", default=False) + if not do_it: + return + + result = delete_installers(items, allowed_roots=root_paths) + console.print( + f"\n [green]Deleted {result.deleted} file(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# XCODE CLEANER +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("xcode-cleaner") +@click.option("--category", "categories", multiple=True, + help="Limit cleanup to matching categories.") +@click.option("--delete", is_flag=True, default=False, + help="Delete selected Xcode data.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +@click.pass_context +def cmd_xcode_cleaner( + ctx: click.Context, + categories: Tuple[str, ...], + delete: bool, + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect and clean Xcode derived data and caches.""" + from core.dry_run import skip_if_dry_run + from scanners.xcode_cleaner import collect_xcode_junk, delete_xcode_junk + + items = collect_xcode_junk() + console.print() + console.print(Panel("[bold cyan]Xcode Cleaner[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not items: + console.print("[green]No Xcode caches found.[/green]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", min_width=20) + table.add_column("Size", justify="right", style="yellow", width=12) + table.add_column("Path", style="dim") + for item in items: + table.add_row(item.category, bytes_human(item.size), str(item.path)) + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "items": [ + { + "category": i.category, + "path": str(i.path), + "size": i.size, + } + for i in items + ], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete: + return + + if skip_if_dry_run(ctx, console, "Xcode cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Delete Xcode caches?", default=False) + if not do_it: + return + + result = delete_xcode_junk(items, categories=categories) + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# DNS CACHE +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("dns-cache") +@click.option("--flush", is_flag=True, default=False, + help="Flush DNS caches.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_dns_cache(ctx: click.Context, flush: bool, yes: bool) -> None: + """Flush DNS caches.""" + from core.dns_cache import flush_dns_cache + from core.dry_run import skip_if_dry_run + + if not flush: + console.print("Use --flush to clear DNS caches.") + return + + if skip_if_dry_run(ctx, console, "DNS cache flush"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Flush DNS cache now?", default=False) + if not do_it: + return + + result = flush_dns_cache() + if result.success: + console.print("[green]DNS cache flushed.[/green]") + else: + console.print("[yellow]DNS cache flush may be incomplete.[/yellow]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# FONT CACHE +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("font-cache") +@click.option("--rebuild", is_flag=True, default=False, + help="Rebuild font caches using atsutil.") +@click.option("--clear-user", is_flag=True, default=False, + help="Delete user font cache folders before rebuild.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_font_cache(ctx: click.Context, rebuild: bool, clear_user: bool, yes: bool) -> None: + """Rebuild font caches.""" + from core.dry_run import skip_if_dry_run + from core.font_cache import rebuild_font_cache + + if not rebuild: + console.print("Use --rebuild to rebuild font caches.") + return + + if skip_if_dry_run(ctx, console, "font cache rebuild"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Rebuild font caches now?", default=False) + if not do_it: + return + + result = rebuild_font_cache(clear_user=clear_user) + color = "green" if result.success else "red" + console.print(f"[{color}]{'Font cache rebuilt' if result.success else 'Font cache rebuild failed'}[/{color}]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# SPOTLIGHT +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("spotlight") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--reindex", is_flag=True, default=False, + help="Rebuild Spotlight index.") +@click.option("--enable", is_flag=True, default=False, + help="Enable Spotlight indexing.") +@click.option("--disable", is_flag=True, default=False, + help="Disable Spotlight indexing.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_spotlight( + ctx: click.Context, + volume: str, + reindex: bool, + enable: bool, + disable: bool, + yes: bool, +) -> None: + """Inspect or rebuild Spotlight index.""" + from core.dry_run import skip_if_dry_run + from core.spotlight import get_spotlight_status, reindex_spotlight, set_spotlight_indexing + + status = get_spotlight_status(volume) + console.print() + console.print(Panel("[bold cyan]Spotlight[/bold cyan]", + border_style="cyan", padding=(0, 2))) + enabled_label = "enabled" if status.enabled else "disabled" + console.print(f" Status: {enabled_label} ({status.raw})") + + if not (reindex or enable or disable): + return + + if skip_if_dry_run(ctx, console, "Spotlight update"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Proceed with Spotlight changes?", default=False) + if not do_it: + return + + if enable: + ok = set_spotlight_indexing(volume, True) + console.print(" Enabled" if ok else " Enable failed") + if disable: + ok = set_spotlight_indexing(volume, False) + console.print(" Disabled" if ok else " Disable failed") + if reindex: + ok = reindex_spotlight(volume) + console.print(" Reindex started" if ok else " Reindex failed") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# POWER OPTIMIZER +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("power-optimizer") +@click.option("--apply", "apply_changes", is_flag=True, default=False, + help="Apply recommended power settings.") +@click.option("--restore", is_flag=True, default=False, + help="Restore last saved power profile.") +@click.option("--scope", default="all", + type=click.Choice(["all", "battery", "ac"], case_sensitive=False), + show_default=True) +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_power_optimizer( + ctx: click.Context, + apply_changes: bool, + restore: bool, + scope: str, + yes: bool, +) -> None: + """Show or apply power optimization settings.""" + from core.dry_run import skip_if_dry_run + from core.power_optimizer import ( + apply_recommended, + diff_recommendations, + get_power_profile, + restore_profile, + ) + + profile = get_power_profile() + if profile is None: + console.print("[yellow]Unable to read power settings.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Power Optimizer[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Setting", min_width=18) + table.add_column("Battery", justify="right", width=10) + table.add_column("AC", justify="right", width=10) + keys = sorted(set(profile.battery.keys()) | set(profile.ac.keys())) + for key in keys: + table.add_row(key, profile.battery.get(key, "-"), profile.ac.get(key, "-")) + console.print(table) + + changes = diff_recommendations(profile, scope=scope) + if changes: + console.print(f"\n Recommended changes: {len(changes)}") + for change in changes[:10]: + console.print(f" {change.key}: {change.current} -> {change.recommended}") + if len(changes) > 10: + console.print(f" ... {len(changes) - 10} more") + else: + console.print("\n No recommended changes needed.") + + if not (apply_changes or restore): + return + + if skip_if_dry_run(ctx, console, "power settings update"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + action_label = "restore" if restore else "apply" + do_it = Confirm.ask(f"Proceed to {action_label} power settings?", default=False) + if not do_it: + return + + result = restore_profile(scope=scope) if restore else apply_recommended(scope=scope) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# APP UPDATES +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("app-updates") +@click.option("--system", is_flag=True, default=False, + help="Check macOS software updates.") +@click.option("--brew", is_flag=True, default=False, + help="Check Homebrew updates.") +@click.option("--mas", is_flag=True, default=False, + help="Check Mac App Store updates (requires mas).") +@click.option("--all", "all_checks", is_flag=True, default=False, + help="Run all checks (default).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +def cmd_app_updates( + system: bool, + brew: bool, + mas: bool, + all_checks: bool, + export_path: Optional[str], +) -> None: + """Check for app updates across system, brew, and App Store.""" + from core.update_checker import collect_update_report + + if not (system or brew or mas or all_checks): + all_checks = True + + report = collect_update_report() + + console.print() + console.print(Panel("[bold cyan]App Updates[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if all_checks or system: + console.print(f"\n macOS updates: {len(report.system_updates)}") + for item in report.system_updates[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.system_updates) > 10: + console.print(f" [dim]... {len(report.system_updates) - 10} more[/dim]") + + if all_checks or brew: + console.print(f"\n Homebrew formulae: {len(report.brew_formulae)}") + for item in report.brew_formulae[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.brew_formulae) > 10: + console.print(f" [dim]... {len(report.brew_formulae) - 10} more[/dim]") + console.print(f"\n Homebrew casks: {len(report.brew_casks)}") + for item in report.brew_casks[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.brew_casks) > 10: + console.print(f" [dim]... {len(report.brew_casks) - 10} more[/dim]") + + if all_checks or mas: + console.print(f"\n Mac App Store updates: {len(report.mas_updates)}") + for item in report.mas_updates[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.mas_updates) > 10: + console.print(f" [dim]... {len(report.mas_updates) - 10} more[/dim]") + + if report.errors: + console.print("\n [yellow]Warnings:[/yellow]") + for err in report.errors: + console.print(f" [dim]{err}[/dim]") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "system_updates": report.system_updates, + "brew_formulae": report.brew_formulae, + "brew_casks": report.brew_casks, + "mas_updates": report.mas_updates, + "errors": report.errors, + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# PKG RECEIPTS +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("pkg-receipts") +@click.option("--search", default=None, + help="Filter receipts by substring.") +@click.option("--limit", default=30, show_default=True, + help="Limit results.") +@click.option("--details", is_flag=True, default=False, + help="Show detailed receipt info.") +@click.option("--forget", "forget_id", default=None, + help="Forget a pkg receipt by identifier.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export receipts to JSON.") +@click.pass_context +def cmd_pkg_receipts( + ctx: click.Context, + search: Optional[str], + limit: int, + details: bool, + forget_id: Optional[str], + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect and manage pkg receipts.""" + from core.dry_run import skip_if_dry_run + from core.pkg_receipts import forget_receipt, get_receipt_info, list_receipts + + if forget_id: + if skip_if_dry_run(ctx, console, "pkgutil forget"): + return + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Forget receipt {forget_id}?", default=False) + if not do_it: + return + ok, msg = forget_receipt(forget_id) + color = "green" if ok else "red" + console.print(f"[{color}]{msg}[/{color}]") + return + + receipts = list_receipts(search=search, limit=limit) + console.print() + console.print(Panel("[bold cyan]PKG Receipts[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not receipts: + console.print("[yellow]No receipts found.[/yellow]") + return + + if details: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Identifier", min_width=36) + table.add_column("Version", width=12) + table.add_column("Installed", width=20) + for receipt_id in receipts: + info = get_receipt_info(receipt_id) + if info: + table.add_row( + info.identifier, + info.version or "-", + (info.install_time or "-")[:19], + ) + console.print(table) + else: + for receipt_id in receipts: + console.print(f" {receipt_id}") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "receipts": receipts, + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# TIME MACHINE GUARD +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("time-machine") +@click.option("--enable", is_flag=True, default=False, + help="Enable Time Machine backups.") +@click.option("--disable", is_flag=True, default=False, + help="Disable Time Machine backups.") +@click.option("--warn-days", default=7, show_default=True, + help="Warn if last backup is older than N days.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export status to JSON.") +@click.pass_context +def cmd_time_machine( + ctx: click.Context, + enable: bool, + disable: bool, + warn_days: int, + export_path: Optional[str], +) -> None: + """Inspect and guard Time Machine status.""" + from core.dry_run import skip_if_dry_run + from core.time_machine_guard import disable_time_machine, enable_time_machine, get_time_machine_status + + console.print() + console.print(Panel("[bold cyan]Time Machine Guard[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if enable or disable: + if skip_if_dry_run(ctx, console, "Time Machine toggle"): + return + ok, msg = (enable_time_machine() if enable else disable_time_machine()) + color = "green" if ok else "red" + console.print(f"[{color}]{msg}[/{color}]") + + status = get_time_machine_status() + console.print(f"\n Destinations: {len(status.destinations)}") + for dest in status.destinations: + console.print(f" [dim]- {dest}[/dim]") + console.print(f" Local snapshots: {status.local_snapshot_count}") + if status.last_backup: + age = status.last_backup_age_days + console.print(f" Latest backup: {status.last_backup}") + if age is not None and age > warn_days: + console.print(f" [yellow]Warning: last backup is {age} days old[/yellow]") + else: + console.print(" [yellow]No recent backups detected[/yellow]") + + if export_path: + import json + payload = status.to_dict() + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# WEEKLY DIGEST +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("weekly-digest") +@click.option("--days", default=7, show_default=True, + help="Number of days to include.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export digest to JSON.") +def cmd_weekly_digest(days: int, export_path: Optional[str]) -> None: + """Generate a weekly scan digest report.""" + from config.history import list_history + from reporting.weekly_digest import generate_weekly_digest + + records = list_history(limit=200) + digest = generate_weekly_digest(records, days=days) + + console.print() + console.print(Panel("[bold cyan]Weekly Digest[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not digest: + console.print("[yellow]No scans found in the selected time range.[/yellow]") + return + + console.print(f" Range: {digest.start} -> {digest.end}") + console.print(f" Scans: {digest.scan_count}") + console.print(f" Total reclaimable: {digest.total_reclaimable_human}") + console.print(f" Avg per scan: {digest.avg_reclaimable_human}") + + if digest.top_orphan_apps: + console.print("\n Top orphaned apps:") + for name, size in digest.top_orphan_apps: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if digest.top_junk_categories: + console.print("\n Top junk categories:") + for name, size in digest.top_junk_categories: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if digest.top_dev_categories: + console.print("\n Top dev junk:") + for name, size in digest.top_dev_categories: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if export_path: + import json + payload = digest.to_dict() + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# IMPACT SCORE +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("impact-score") +@click.option("--scan-id", default=None, + help="Scan ID prefix to score (default: latest).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export impact score to JSON.") +def cmd_impact_score(scan_id: Optional[str], export_path: Optional[str]) -> None: + """Compute a cleaning impact score from scan history.""" + from config.history import list_history + from reporting.impact_score import compute_impact_from_summary + + records = list_history(limit=50) + if not records: + console.print("[yellow]No scan history found.[/yellow]") + return + + target = None + if scan_id: + for r in records: + if r.scan_id.startswith(scan_id): + target = r + break + else: + target = records[0] + + if not target: + console.print("[yellow]Scan not found.[/yellow]") + return + + summary = target.summary + score = compute_impact_from_summary( + orphan_bytes=target.orphan_bytes, + junk_bytes=target.junk_bytes, + dev_bytes=target.dev_junk_bytes, + orphan_count=int(summary.get("orphan_count", 0)), + junk_count=int(summary.get("junk_count", 0)), + dev_count=int(summary.get("dev_junk_count", 0)), + ) + + console.print() + console.print(Panel("[bold cyan]Impact Score[/bold cyan]", + border_style="cyan", padding=(0, 2))) + console.print(f" Score: [bold]{score.score}[/bold] ({score.label})") + console.print(f" Total reclaimable: {score.total_human}") + console.print(f" Total items: {score.total_items}") + + if export_path: + import json + payload = score.to_dict() + payload["scan_id"] = target.scan_id + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# TUI PICKER +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.command("tui-picker") +@click.option("--uninstall", is_flag=True, default=False, + help="Uninstall the selected app.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--reveal", is_flag=True, default=False, + help="Reveal the app in Finder.") +@click.option("--open", "open_app", is_flag=True, default=False, + help="Open the app after selection.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_tui_picker( + ctx: click.Context, + uninstall: bool, + no_undo: bool, + reveal: bool, + open_app: bool, + yes: bool, +) -> None: + """Interactive app picker.""" + import subprocess + from core.dry_run import dry_run_enabled + from core.tui_picker import pick_app + from core.uninstaller import build_uninstall_plan, execute_uninstall + + apps = discover_installed_apps() + result = pick_app(list(apps.values()), prompt="Pick an app") + app = result.selected + if app is None: + console.print("[yellow]No app selected.[/yellow]") + return + + console.print(f"\nSelected: [bold]{app.name}[/bold] ({app.bundle_id})") + + if reveal: + subprocess.run(["open", "-R", str(app.path)]) + if open_app: + subprocess.run(["open", str(app.path)]) + + if not uninstall: + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; uninstall skipped.[/yellow]") + return + + cfg = load_config() + plan = build_uninstall_plan(app=app, whitelist_set=cfg.whitelist_set) + if not plan.deletable_items: + console.print("[yellow]No removable data found for this app.[/yellow]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", width=16) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + for item in plan.deletable_items[:40]: + table.add_row(item.category, bytes_human(item.size), str(item.path)) + console.print(table) + if len(plan.deletable_items) > 40: + console.print(f" [dim]... {len(plan.deletable_items) - 40} more items omitted[/dim]") + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Proceed with uninstall?", default=False) + if not do_it: + return + + session = None + if cfg.undo_mode and not no_undo: + session = new_session() + result = execute_uninstall(plan, session=session) + if session and result.staged > 0: + session.save() + console.print( + f"\n [green]Staged {bytes_human(result.bytes_freed)} for undo[/green]" + ) + console.print( + f" [dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]" + ) + else: + console.print(f"\n [green]Removed {bytes_human(result.bytes_freed)}[/green]") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# CONFIG SYNC +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +@main.group("config-sync") +def cmd_config_sync() -> None: + """Sync configuration across multiple Macs.""" + + +@cmd_config_sync.command("export") +@click.option("--dest", "dest_dir", default=None, type=click.Path(), + help="Destination sync directory.") +@click.option("--include-history", is_flag=True, default=False, + help="Include scan history in sync bundle.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +def config_sync_export(dest_dir: Optional[str], include_history: bool, no_icloud: bool) -> None: + """Export config to sync directory.""" + from core.config_sync import export_config + + dest = Path(dest_dir).expanduser().resolve() if dest_dir else None + result = export_config(dest_dir=dest, include_history=include_history, prefer_icloud=not no_icloud) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + +@cmd_config_sync.command("import") +@click.option("--src", "src_dir", default=None, type=click.Path(), + help="Source sync directory.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +@click.option("--no-backup", is_flag=True, default=False, + help="Do not backup existing config.") +def config_sync_import(src_dir: Optional[str], no_icloud: bool, no_backup: bool) -> None: + """Import config from sync directory.""" + from core.config_sync import import_config + + src = Path(src_dir).expanduser().resolve() if src_dir else None + result = import_config(src_dir=src, prefer_icloud=not no_icloud, backup=not no_backup) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + +@cmd_config_sync.command("status") +@click.option("--dir", "dest_dir", default=None, type=click.Path(), + help="Sync directory to inspect.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +def config_sync_status(dest_dir: Optional[str], no_icloud: bool) -> None: + """Show sync metadata.""" + from core.config_sync import sync_status + + dest = Path(dest_dir).expanduser().resolve() if dest_dir else None + result = sync_status(dest_dir=dest, prefer_icloud=not no_icloud) + color = "green" if result.success else "yellow" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # PERMISSIONS AUDITOR # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• diff --git a/src/config/config.py b/src/config/config.py index b9fddf7..df63237 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,52 +1,16 @@ -""" -Mac Deep Cleaner v1.5.0 โ€” Configuration Manager -============================================= -Reads and writes a YAML config file at ~/.config/mac-cleaner/config.yaml. - -Config schema (all keys optional) ----------------------------------- -whitelist: - - ~/Library/Application Support/Slack - - ~/Library/Caches/MyApp - -skip_categories: - - "System Cache" - - "Log File" - -dev_junk_roots: - - ~/Projects - - ~/Code - -scan_dev_junk: false -scan_dev_junk_global: false -dev_junk_max_depth: 6 - -custom_scan_roots: - - ~/Projects/tools - - /opt/company - -profile: developer # name of an active profile (merged on top of base config) - -profiles: - minimal: - skip_categories: ["Xcode Junk", "npm Cache", "Cargo Cache"] - developer: - custom_scan_roots: [~/Projects] - skip_categories: [] - -undo_mode: true # stage deletions in ~/.mac_cleaner_trash instead of permanent delete -retention_days: 30 # how long staged files are kept -large_file_threshold_mb: 100 -duplicate_min_size_kb: 4 -notify_after_scan: false - -Usage ------ - from mac_cleaner.config import load_config, Config - - cfg = load_config() # reads file or returns defaults - cfg = load_config(profile="developer") - cfg.save() # writes back to disk + +"""Manage on-disk configuration for Mac Deep Cleaner. + +Configuration is stored as YAML at ~/.config/mac-cleaner/config.yaml. + +Example: + whitelist: + - ~/Library/Application Support/Slack + - ~/Library/Caches/MyApp + + scan_dev_junk: false + scan_dev_junk_global: false + dev_junk_max_depth: 6 """ from __future__ import annotations @@ -137,7 +101,25 @@ @dataclass class Config: - """Resolved configuration, ready for use by the CLI.""" + """Resolved configuration, ready for use by the CLI. + + Attributes: + whitelist: Explicit paths to exclude from deletion. + custom_scan_roots: Extra root folders to scan. + skip_categories: Categories of junk to skip. + scan_orphans: Whether to scan for app leftovers. + scan_junk: Whether to scan for general junk. + scan_dev_junk: Whether to scan for developer junk. + scan_dev_junk_global: Whether to include global caches. + undo_mode: Stage deletions for undo instead of removing permanently. + retention_days: Days to keep staged deletions. + notify_after_scan: Post a notification after scans. + profile: Active profile name if any. + dev_junk_roots: Roots to scan for developer junk. + dev_junk_max_depth: Max depth for dev junk scanning. + large_file_threshold_mb: Minimum size for large-file scans. + duplicate_min_size_kb: Minimum size for duplicate detection. + """ # Paths whitelist: List[Path] = field(default_factory=list) @@ -187,7 +169,12 @@ def whitelist_set(self) -> Set[Path]: # โ”€โ”€ Persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def save(self) -> None: - """Write the current config to disk as YAML.""" + """Write the current config to disk as YAML. + + Raises: + RuntimeError: If PyYAML is not installed. + OSError: If the file cannot be written. + """ if not _YAML_OK: raise RuntimeError("pyyaml is required to save config. pip install pyyaml") @@ -213,10 +200,11 @@ def save(self) -> None: if self.profile: data["profile"] = self.profile - with open(_CONFIG_FILE, "w") as f: + with open(_CONFIG_FILE, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) def to_dict(self) -> Dict[str, Any]: + """Serialize the resolved config to a JSON-safe dictionary.""" return { "whitelist": [str(p) for p in self.whitelist], "skip_categories": sorted(self.skip_categories), @@ -238,7 +226,14 @@ def to_dict(self) -> Dict[str, Any]: # โ”€โ”€ Loader โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _expand(paths: List[Any]) -> List[Path]: - """Expand a list of path strings into Path objects.""" + """Expand a list of path-like values into resolved Path objects. + + Args: + paths: List of values that can be converted to Path. + + Returns: + List of resolved Path objects. + """ result = [] for p in paths: try: @@ -249,22 +244,47 @@ def _expand(paths: List[Any]) -> List[Path]: def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - """Shallow merge: override keys win.""" + """Shallow merge: override keys win. + + Args: + base: Base dictionary. + override: Overrides applied on top of base. + + Returns: + Merged dictionary. + """ merged = copy.copy(base) merged.update(override) return merged +def _coerce_int(value: Any, default: int, min_value: int = 0) -> int: + """Coerce a value to int with a lower bound. + + Args: + value: Value to coerce. + default: Fallback when conversion fails. + min_value: Minimum allowed value. + + Returns: + Coerced integer value. + """ + try: + coerced = int(value) + except (TypeError, ValueError): + return default + return max(min_value, coerced) + + def load_config( path: Optional[Path] = None, profile: Optional[str] = None, ) -> Config: - """ - Load configuration from disk (YAML) and apply profile overrides. + """Load configuration from disk and apply profile overrides. Args: - path: Path to the config file. Defaults to ~/.config/mac-cleaner/config.yaml. - profile: Profile name to activate (overrides file's 'profile' key). + path: Path to the config file. Defaults to ~/.config/mac-cleaner/config.yaml. + profile: Profile name to activate (overrides file's "profile" key). Returns: Config object with all settings resolved. @@ -274,11 +294,11 @@ def load_config( if config_path.exists() and _YAML_OK: try: - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: loaded = yaml.safe_load(f) or {} if isinstance(loaded, dict): raw = loaded - except Exception as exc: + except (OSError, ValueError, TypeError, yaml.YAMLError) as exc: logger.debug("Failed to load config %s: %s", config_path, exc) # Resolve active profile @@ -307,13 +327,13 @@ def load_config( scan_dev_junk=bool(effective.get("scan_dev_junk", False)), scan_dev_junk_global=bool(effective.get("scan_dev_junk_global", False)), undo_mode=bool(effective.get("undo_mode", True)), - retention_days=int(effective.get("retention_days", 30)), + retention_days=_coerce_int(effective.get("retention_days", 30), 30, min_value=0), notify_after_scan=bool(effective.get("notify_after_scan", False)), - large_file_threshold_mb=int(effective.get("large_file_threshold_mb", 100)), - duplicate_min_size_kb=int(effective.get("duplicate_min_size_kb", 4)), + large_file_threshold_mb=_coerce_int(effective.get("large_file_threshold_mb", 100), 100, min_value=0), + duplicate_min_size_kb=_coerce_int(effective.get("duplicate_min_size_kb", 4), 4, min_value=1), profile=active_profile, dev_junk_roots=_expand(effective.get("dev_junk_roots", [])), - dev_junk_max_depth=int(effective.get("dev_junk_max_depth", 6)), + dev_junk_max_depth=_coerce_int(effective.get("dev_junk_max_depth", 6), 6, min_value=1), ) cfg._raw_profiles = all_profiles return cfg @@ -336,8 +356,13 @@ def ensure_config_dir() -> Path: def init_default_config(profile: Optional[str] = None) -> Config: - """ - Write a default config file if none exists, then load and return it. + """Write a default config file if none exists, then load and return it. + + Args: + profile: Optional profile name to set on first run. + + Returns: + Loaded configuration. """ if not _CONFIG_FILE.exists(): cfg = default_config() diff --git a/src/config/history.py b/src/config/history.py index b8c50f7..3e97ffe 100644 --- a/src/config/history.py +++ b/src/config/history.py @@ -1,39 +1,8 @@ -""" -Mac Deep Cleaner v1.5.0 โ€” Scan History & Diff -========================================== -Stores past scan results in ~/.config/mac-cleaner/history/ as JSON files. -Allows comparing two scans to show what's new or resolved since the last run. - -Storage format --------------- -Each scan is stored as _.json with this schema: - -{ - "schema_version": 2, - "scan_id": "abc12345", - "scanned_at": "2024-01-15T10:30:00", - "profile": "developer", // optional - "orphans": { - "Slack": { - "total_size": 123456, - "items": [{"path": "...", "reason": "...", "size": 123}] - } - }, - "junk": [ - {"path": "...", "category": "User Cache", "size": 123} - ], - "dev_junk": [ - {"path": "...", "category": "Node Modules", "size": 123} - ], - "summary": { - "orphan_count": 3, - "orphan_bytes": 456789, - "junk_count": 12, - "junk_bytes": 987654, - "dev_junk_count": 5, - "dev_junk_bytes": 55555 - } -} + +"""Persist and diff scan history on disk. + +Scan history is stored as JSON files in ~/.config/mac-cleaner/history/ and can +be diffed to show what changed between runs. """ from __future__ import annotations @@ -43,10 +12,13 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple from utils import bytes_human +if TYPE_CHECKING: + from config.models import DevJunkEntry, JunkEntry, OrphanEntry + # โ”€โ”€ Paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HISTORY_DIR = Path.home() / ".config" / "mac-cleaner" / "history" @@ -55,6 +27,7 @@ def _ensure_history_dir() -> None: + """Ensure the history directory exists.""" HISTORY_DIR.mkdir(parents=True, exist_ok=True) @@ -62,7 +35,17 @@ def _ensure_history_dir() -> None: @dataclass class ScanRecord: - """A single historical scan result.""" + """A single historical scan result. + + Attributes: + scan_id: Unique scan identifier. + scanned_at: Timestamp of the scan. + profile: Optional profile name. + orphans: Orphaned app data grouped by name. + junk: List of junk items. + dev_junk: List of developer junk items. + summary: Aggregated counts and totals. + """ scan_id: str scanned_at: datetime profile: Optional[str] @@ -92,7 +75,8 @@ def dev_junk_bytes(self) -> int: def total_bytes(self) -> int: return self.orphan_bytes + self.junk_bytes + self.dev_junk_bytes - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the record to a JSON-serializable dictionary.""" return { "schema_version": SCHEMA_VERSION, "scan_id": self.scan_id, @@ -105,13 +89,15 @@ def to_dict(self) -> dict: } def save(self) -> None: + """Persist the scan record to disk.""" _ensure_history_dir() - with open(self.file_path, "w") as f: + with open(self.file_path, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, indent=2, default=str) _prune_old_entries() @classmethod - def from_dict(cls, d: dict) -> "ScanRecord": + def from_dict(cls, d: Dict[str, Any]) -> "ScanRecord": + """Create a ScanRecord from a dictionary payload.""" return cls( scan_id=d["scan_id"], scanned_at=datetime.fromisoformat(d["scanned_at"]), @@ -124,8 +110,16 @@ def from_dict(cls, d: dict) -> "ScanRecord": @classmethod def load(cls, path: Path) -> Optional["ScanRecord"]: + """Load a scan record from disk. + + Args: + path: Path to a JSON history file. + + Returns: + ScanRecord if the file is valid; otherwise None. + """ try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) return cls.from_dict(data) except (json.JSONDecodeError, KeyError, OSError, ValueError): @@ -142,17 +136,17 @@ def __repr__(self) -> str: # โ”€โ”€ Builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def build_scan_record( - orphans: Dict, - junk: List, - dev_junk: Optional[List] = None, + orphans: Dict[str, List["OrphanEntry"]], + junk: List["JunkEntry"], + dev_junk: Optional[List["DevJunkEntry"]] = None, profile: Optional[str] = None, ) -> ScanRecord: - """ - Create a ScanRecord from live scan results. + """Create a ScanRecord from live scan results. Args: - orphans: Dict[str, List[OrphanEntry]] from scanner.scan_orphans() - junk: List[JunkEntry] from scanner.scan_junk() + orphans: Mapping of app name to orphan entries. + junk: List of junk entries. + dev_junk: List of developer junk entries. profile: Active profile name (optional). """ orphan_data: Dict[str, Any] = {} @@ -199,7 +193,14 @@ def build_scan_record( # โ”€โ”€ History listing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def list_history(limit: int = 20) -> List[ScanRecord]: - """Return past scan records, newest first.""" + """Return past scan records, newest first. + + Args: + limit: Maximum records to return. + + Returns: + List of ScanRecord instances. + """ _ensure_history_dir() records: List[ScanRecord] = [] for p in sorted(HISTORY_DIR.glob("*.json"), reverse=True): @@ -232,7 +233,17 @@ def _prune_old_entries() -> None: @dataclass class ScanDiff: - """Comparison between two scan records.""" + """Comparison between two scan records. + + Attributes: + older: Older scan record. + newer: Newer scan record. + new_orphans: App names appearing only in the newer scan. + resolved_orphans: App names missing from the newer scan. + persistent_orphans: App names present in both scans. + junk_delta_bytes: Net change in junk bytes. + dev_junk_delta_bytes: Net change in dev junk bytes. + """ older: ScanRecord newer: ScanRecord @@ -261,6 +272,7 @@ def size_delta_bytes(self) -> int: @property def summary(self) -> Dict[str, Any]: + """Return a JSON-ready summary of the diff.""" return { "older_scan": self.older.scan_id[:8], "older_date": self.older.scanned_at.isoformat(), @@ -277,14 +289,30 @@ def summary(self) -> Dict[str, Any]: def diff_scans(older: ScanRecord, newer: ScanRecord) -> ScanDiff: - """Compare two ScanRecords and return a ScanDiff.""" - return ScanDiff(older=older, newer=newer) + """Compare two ScanRecords and return a ScanDiff. + Args: + older: Older scan record. + newer: Newer scan record. -def diff_with_latest(current_orphans: Dict, current_junk: List) -> Optional[ScanDiff]: + Returns: + ScanDiff instance. """ - Compare the current (live) scan results with the most recent stored scan. - Returns None if there is no history yet. + return ScanDiff(older=older, newer=newer) + + +def diff_with_latest( + current_orphans: Dict[str, List["OrphanEntry"]], + current_junk: List["JunkEntry"], +) -> Optional[ScanDiff]: + """Compare live scan results with the most recent stored scan. + + Args: + current_orphans: Mapping of app name to orphan entries. + current_junk: List of junk entries. + + Returns: + ScanDiff if history exists; otherwise None. """ last = latest_scan() if last is None: diff --git a/src/config/models.py b/src/config/models.py index 631c461..ed1ad23 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -1,15 +1,12 @@ -""" -Mac Deep Cleaner v1.5.0 โ€” Data Models -================================== -Immutable data classes for apps, orphan entries, and junk entries. -""" + +"""Core data models for scan results and metadata.""" from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path -from typing import Set +from typing import Any, Dict, Set from utils import size_of @@ -24,7 +21,13 @@ @dataclass class AppInfo: - """Represents a single installed application.""" + """Represents a single installed application. + + Attributes: + name: Display name of the app. + bundle_id: App bundle identifier. + path: Filesystem path to the app bundle. + """ name: str bundle_id: str path: Path @@ -61,7 +64,17 @@ def __repr__(self) -> str: @dataclass class OrphanEntry: - """A leftover file/directory from an uninstalled app.""" + """A leftover file or directory from an uninstalled app. + + Attributes: + path: Filesystem path to the orphaned item. + app_name: Display name of the app, if known. + reason: Reason category for the orphan entry. + size: Size in bytes. + category: Normalized category label. + bundle_id: Associated bundle identifier, if available. + vendor: Vendor name, if known. + """ path: Path app_name: str = "" reason: str = "Other" # e.g. "App Support", "Cache", "Container" @@ -80,7 +93,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "app_name": self.app_name, @@ -94,7 +108,15 @@ def to_dict(self) -> dict: @dataclass class JunkEntry: - """A general junk file/directory (cache, log, crash report, etc.).""" + """A general junk file or directory. + + Attributes: + path: Filesystem path to the junk item. + category: Category label (e.g., "User Cache"). + is_system: Whether the item is system-owned (never auto-delete). + size: Size in bytes. + bundle_id: Associated bundle identifier, if available. + """ path: Path category: str = "Other" # e.g. "User Cache", "Log File", "Trash" is_system: bool = False # If True, never auto-delete @@ -105,7 +127,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "category": self.category, @@ -117,7 +140,13 @@ def to_dict(self) -> dict: @dataclass class DevJunkEntry: - """Developer junk directory (build output, venv, node_modules, etc.).""" + """Developer junk directory (build output, venv, node_modules, etc.). + + Attributes: + path: Filesystem path to the dev junk directory. + category: Category label. + size: Size in bytes. + """ path: Path category: str = "Other" size: int = 0 @@ -126,7 +155,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "category": self.category, diff --git a/src/constants.py b/src/constants.py index 72b5215..181496c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,16 +1,12 @@ -""" -Mac Deep Cleaner v1.5.0 โ€” Constants & Configuration -================================================= -All safelists, alias tables, search roots, and configuration constants. -""" +"""Constants and configuration data for scans and safety rules.""" from pathlib import Path from typing import Dict, List, Set HOME = Path.home() LOG_FILE = HOME / ".mac_cleaner_deleted.log" -CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.5.0 +CONFIG_DIR = HOME / ".config" / "mac-cleaner" # โ”€โ”€ Scan roots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -68,7 +64,7 @@ "microsoft teams": "com.microsoft.teams", "microsoft teams (work or school)": "com.microsoft.teams2", "microsoft teams classic": "com.microsoft.teams", - # v1.5.0: short-form aliases for Microsoft apps + # short-form aliases for Microsoft apps "excel": "com.microsoft.excel", "word": "com.microsoft.word", "powerpoint": "com.microsoft.powerpoint", @@ -86,7 +82,7 @@ "google chrome canary": "com.google.chrome.canary", "google drive": "com.google.drivefs", "google earth pro": "com.google.googleearthpro", - # v1.5.0 + # v2.0.0 "googledrive": "com.google.drivefs", # โ”€โ”€ JetBrains โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -105,7 +101,6 @@ "appcode": "com.jetbrains.appcode", "fleet": "com.jetbrains.fleet", "jetbrains toolbox": "com.jetbrains.toolbox", - # v1.5.0 "jetbrains toolbox app": "com.jetbrains.toolbox", # โ”€โ”€ Browsers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -123,7 +118,7 @@ "tor browser": "org.torproject.torbrowser", "waterfox": "net.nickolaj.nickelodeon", "sidekick": "com.nicklodeon.nickelodeon", - # v1.5.0: extra short-form browser aliases + # extra short-form browser aliases "brave": "com.brave.browser", "edge": "com.microsoft.edgemac", @@ -173,7 +168,6 @@ "ia writer": "pro.writer.mac", "devonthink 3": "com.devon-technologies.think3", "devonthink": "com.devon-technologies.think3", - # v1.5.0 "linear": "com.linear.linear", "superhuman": "com.superhuman.desktop", @@ -193,7 +187,6 @@ "gimp": "org.gimp.gimp-2.10", "canva": "com.canva.canva", "principle": "com.principleformac.principle", - # v1.5.0 "pixelmator": "com.pixelmatorteam.pixelmator", # โ”€โ”€ Dev tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -225,7 +218,6 @@ "coderunner": "com.krill.coderunner", "coteditor": "com.coteditor.coteditor", "textedit": "com.apple.textedit", - # v1.5.0 "simulator": "com.apple.iphonesimulator", "xcode": "com.apple.dt.xcode", "textmate": "com.macromates.textmate", @@ -342,7 +334,7 @@ # โ”€โ”€ Apple Developer Team ID โ†’ owner name โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ TEAM_ID_MAP: Dict[str, str] = { - # โ”€โ”€ v1.5.0 original entries (verbatim) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ original entries (verbatim) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ "ubf8t346g9": "Microsoft Office", "2bua8c4s2c": "1Password", "7pkpll4vld": "Dropbox", @@ -382,7 +374,7 @@ "t9um3f5r6t": "Spark / Readdle", "w5364u7y5r": "Canva", - # โ”€โ”€ v1.5.0 additions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ additions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ "ug75gva3v9": "Microsoft (general)", "jq525l2msd": "Adobe", "g7hh3359t7": "Dropbox", @@ -489,7 +481,7 @@ # โ”€โ”€ Exact-stem safelist โ€” the stem (lowercased) matches exactly โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ SYSTEM_EXACT_SAFELIST: Set[str] = { - # โ”€โ”€ v1.5.0 original entries (verbatim) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ original entries (verbatim) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Networking / directory "systemconfiguration", "opendirectory", "directoryservice", @@ -587,7 +579,7 @@ "storedownloadd", "commerced", - # โ”€โ”€ v1.5.0 additions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ additions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ "apsd", "appleid", "airplay", diff --git a/src/core/cleaner.py b/src/core/cleaner.py index 10bad25..a54e480 100644 --- a/src/core/cleaner.py +++ b/src/core/cleaner.py @@ -1,16 +1,7 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Cleaner Module -====================================== Handles deletion of orphan and junk files with safety checks, audit logging, and optional staged-deletion (undo) support. -Changes from v1.5.0 ---------------- -- do_cleanup() now accepts an optional `session` parameter. - When provided, files are moved to the staging area (undo.stage_file) - instead of being permanently deleted. -- write_deletion_log() is unchanged โ€” it logs staged moves too. -- All safety gates remain in place. """ from __future__ import annotations @@ -41,7 +32,7 @@ def write_deletion_log(entries: List[Tuple[str, int]]) -> None: try: with open(LOG_FILE, "a") as f: f.write(f"\n{'=' * 60}\n") - f.write(f"Mac Deep Cleaner v1.5.0 โ€” Deletion Log\n") + f.write(f"Mac Deep Cleaner v2.0.0 โ€” Deletion Log\n") f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Items deleted: {len(entries)}\n") f.write(f"Total freed: {bytes_human(sum(s for _, s in entries))}\n") diff --git a/src/core/config_sync.py b/src/core/config_sync.py new file mode 100644 index 0000000..3e84cd5 --- /dev/null +++ b/src/core/config_sync.py @@ -0,0 +1,96 @@ +"""Multi-Mac config sync helpers.""" + +from __future__ import annotations + +import json +import platform +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +from constants import CONFIG_DIR + + +@dataclass +class SyncResult: + """Summary of a sync operation.""" + success: bool + message: str + path: Optional[Path] = None + details: list[str] = field(default_factory=list) + + +def default_sync_dir(prefer_icloud: bool = True) -> Path: + """Return the default sync directory.""" + if prefer_icloud: + icloud_root = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + if icloud_root.exists(): + return icloud_root / "MacCleaner" + return CONFIG_DIR / "sync" + + +def _write_meta(dest: Path) -> None: + meta = { + "host": platform.node(), + "platform": platform.platform(), + "updated_at": datetime.now().isoformat(), + } + dest.mkdir(parents=True, exist_ok=True) + (dest / "sync_meta.json").write_text(json.dumps(meta, indent=2)) + + +def export_config( + dest_dir: Optional[Path] = None, + include_history: bool = False, + prefer_icloud: bool = True, +) -> SyncResult: + """Export config to a sync directory.""" + dest = dest_dir or default_sync_dir(prefer_icloud=prefer_icloud) + config_path = CONFIG_DIR / "config.yaml" + + if not config_path.exists(): + return SyncResult(False, "No config.yaml found to export", dest) + + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(config_path, dest / "config.yaml") + + if include_history: + history = CONFIG_DIR / "history" + if history.exists(): + shutil.copytree(history, dest / "history", dirs_exist_ok=True) + + _write_meta(dest) + return SyncResult(True, "Config exported", dest) + + +def import_config( + src_dir: Optional[Path] = None, + prefer_icloud: bool = True, + backup: bool = True, +) -> SyncResult: + """Import config from a sync directory.""" + src = src_dir or default_sync_dir(prefer_icloud=prefer_icloud) + src_cfg = src / "config.yaml" + if not src_cfg.exists(): + return SyncResult(False, "No config.yaml found in sync directory", src) + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + dest_cfg = CONFIG_DIR / "config.yaml" + if backup and dest_cfg.exists(): + backup_path = CONFIG_DIR / f"config.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}.yaml" + shutil.copy2(dest_cfg, backup_path) + + shutil.copy2(src_cfg, dest_cfg) + return SyncResult(True, "Config imported", src) + + +def sync_status(dest_dir: Optional[Path] = None, prefer_icloud: bool = True) -> SyncResult: + """Return sync metadata if present.""" + dest = dest_dir or default_sync_dir(prefer_icloud=prefer_icloud) + meta = dest / "sync_meta.json" + if not meta.exists(): + return SyncResult(False, "No sync metadata found", dest) + + return SyncResult(True, meta.read_text().strip(), dest) diff --git a/src/core/dns_cache.py b/src/core/dns_cache.py new file mode 100644 index 0000000..d67dcf8 --- /dev/null +++ b/src/core/dns_cache.py @@ -0,0 +1,78 @@ +"""DNS cache flush helpers.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass, field +from typing import List + +logger = logging.getLogger(__name__) + + +@dataclass +class DNSFlushStep: + """One DNS flush command.""" + command: List[str] + success: bool + stdout: str = "" + stderr: str = "" + + +@dataclass +class DNSFlushResult: + """Summary of DNS flush.""" + success: bool + steps: List[DNSFlushStep] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +def _run_cmd(args: List[str], runner=subprocess.run) -> DNSFlushStep: + try: + result = runner( + args, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("DNS command failed: %s", exc) + return DNSFlushStep(command=args, success=False, stderr=str(exc)) + + ok = result.returncode == 0 + return DNSFlushStep( + command=args, + success=ok, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + ) + + +def flush_dns_cache(runner=subprocess.run) -> DNSFlushResult: + """Flush DNS cache using standard macOS commands.""" + commands = [ + ["dscacheutil", "-flushcache"], + ["killall", "-HUP", "mDNSResponder"], + ["killall", "-HUP", "mDNSResponderHelper"], + ] + + steps: List[DNSFlushStep] = [] + errors: List[str] = [] + success_count = 0 + + for cmd in commands: + step = _run_cmd(cmd, runner=runner) + if not step.success: + if step.stderr and "no matching processes" in step.stderr.lower(): + step.success = True + if step.success: + success_count += 1 + else: + errors.append(" ".join(cmd)) + steps.append(step) + + return DNSFlushResult( + success=success_count > 0, + steps=steps, + errors=errors, + ) diff --git a/src/core/font_cache.py b/src/core/font_cache.py new file mode 100644 index 0000000..5813475 --- /dev/null +++ b/src/core/font_cache.py @@ -0,0 +1,99 @@ +"""Font cache rebuild helpers.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List + +from constants import HOME +from utils import safe_remove + +logger = logging.getLogger(__name__) + + +@dataclass +class FontCacheStep: + """One font cache action.""" + command: List[str] + success: bool + stdout: str = "" + stderr: str = "" + + +@dataclass +class FontCacheResult: + """Summary of font cache rebuild.""" + success: bool + steps: List[FontCacheStep] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +_USER_CACHE_DIRS = [ + HOME / "Library" / "FontCaches", + HOME / "Library" / "Caches" / "com.apple.ATS", +] + + +def _run_cmd(args: List[str], runner=subprocess.run) -> FontCacheStep: + try: + result = runner( + args, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("Font cache command failed: %s", exc) + return FontCacheStep(command=args, success=False, stderr=str(exc)) + + ok = result.returncode == 0 + return FontCacheStep( + command=args, + success=ok, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + ) + + +def rebuild_font_cache( + clear_user: bool = False, + runner=subprocess.run, +) -> FontCacheResult: + """Rebuild system font caches using atsutil.""" + steps: List[FontCacheStep] = [] + errors: List[str] = [] + + if clear_user: + for cache_dir in _USER_CACHE_DIRS: + if cache_dir.exists(): + ok, _freed = safe_remove(cache_dir) + if not ok: + errors.append(f"Failed to remove {cache_dir}") + + atsutil = shutil.which("atsutil") + if not atsutil: + return FontCacheResult( + success=False, + steps=steps, + errors=["atsutil not found"], + ) + + for cmd in [ + [atsutil, "server", "-shutdown"], + [atsutil, "databases", "-remove"], + [atsutil, "server", "-ping"], + ]: + step = _run_cmd(cmd, runner=runner) + steps.append(step) + if not step.success: + errors.append(" ".join(cmd)) + + return FontCacheResult( + success=all(s.success for s in steps) and not errors, + steps=steps, + errors=errors, + ) diff --git a/src/core/pkg_receipts.py b/src/core/pkg_receipts.py new file mode 100644 index 0000000..c2909bb --- /dev/null +++ b/src/core/pkg_receipts.py @@ -0,0 +1,100 @@ +"""PKG receipt manager.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class PkgReceipt: + """A single pkg receipt entry.""" + identifier: str + version: Optional[str] = None + volume: Optional[str] = None + location: Optional[str] = None + install_time: Optional[str] = None + + def to_dict(self) -> dict: + return { + "identifier": self.identifier, + "version": self.version, + "volume": self.volume, + "location": self.location, + "install_time": self.install_time, + } + + +def _run(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def list_receipts(search: Optional[str] = None, limit: Optional[int] = None) -> List[str]: + """List pkg receipt identifiers.""" + try: + result = _run(["pkgutil", "--pkgs"], timeout=60) + except (OSError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + + receipts = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if search: + needle = search.lower() + receipts = [r for r in receipts if needle in r.lower()] + if limit is not None and limit > 0: + receipts = receipts[:limit] + return receipts + + +def get_receipt_info(identifier: str) -> Optional[PkgReceipt]: + """Return detailed receipt info for a package id.""" + try: + result = _run(["pkgutil", "--pkg-info", identifier], timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + + data: Dict[str, str] = {} + for line in result.stdout.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip().lower()] = value.strip() + + install_time = None + if "install-time" in data: + try: + install_time = datetime.fromtimestamp(int(data["install-time"])).isoformat() + except (ValueError, OSError): + install_time = data.get("install-time") + + return PkgReceipt( + identifier=identifier, + version=data.get("version"), + volume=data.get("volume"), + location=data.get("location"), + install_time=install_time, + ) + + +def forget_receipt(identifier: str) -> tuple[bool, str]: + """Forget a pkg receipt.""" + try: + result = _run(["pkgutil", "--forget", identifier], timeout=30) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) + if result.returncode == 0: + return True, result.stdout.strip() or "receipt forgotten" + return False, result.stderr.strip() or "pkgutil --forget failed" diff --git a/src/core/power_optimizer.py b/src/core/power_optimizer.py new file mode 100644 index 0000000..f61c0e8 --- /dev/null +++ b/src/core/power_optimizer.py @@ -0,0 +1,198 @@ +"""Sleep and power optimizer helpers.""" + +from __future__ import annotations + +import json +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from constants import CONFIG_DIR + +logger = logging.getLogger(__name__) + + +@dataclass +class PowerProfile: + """Parsed pmset custom settings.""" + battery: Dict[str, str] + ac: Dict[str, str] + + +@dataclass +class PowerChange: + """One recommended change.""" + key: str + current: Optional[str] + recommended: str + scope: str # "battery", "ac", or "all" + + +@dataclass +class ApplyResult: + """Summary of apply/restore run.""" + success: bool + message: str + changes: List[PowerChange] + + +_RECOMMENDED: Dict[str, str] = { + "displaysleep": "10", + "sleep": "30", + "powernap": "0", + "tcpkeepalive": "1", + "standby": "1", + "standbydelayhigh": "86400", + "standbydelaylow": "3600", + "autopoweroff": "1", + "autopoweroffdelay": "28800", +} + +_PROFILE_PATH = CONFIG_DIR / "power_profile.json" + + +def _run(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def parse_pmset_custom(output: str) -> PowerProfile: + battery: Dict[str, str] = {} + ac: Dict[str, str] = {} + current: Optional[Dict[str, str]] = None + + for raw in output.splitlines(): + line = raw.strip() + if not line: + continue + lower = line.lower() + if lower.startswith("battery power"): + current = battery + continue + if lower.startswith("ac power"): + current = ac + continue + if current is None: + continue + parts = line.split() + if len(parts) >= 2: + key = parts[0] + value = parts[-1] + current[key] = value + + return PowerProfile(battery=battery, ac=ac) + + +def get_power_profile() -> Optional[PowerProfile]: + try: + result = _run(["pmset", "-g", "custom"], timeout=10) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("pmset failed: %s", exc) + return None + if result.returncode != 0: + return None + return parse_pmset_custom(result.stdout) + + +def save_profile(profile: PowerProfile, path: Path = _PROFILE_PATH) -> None: + payload = { + "battery": profile.battery, + "ac": profile.ac, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2)) + + +def load_profile(path: Path = _PROFILE_PATH) -> Optional[PowerProfile]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + return PowerProfile(battery=data.get("battery", {}), ac=data.get("ac", {})) + except (json.JSONDecodeError, OSError): + return None + + +def diff_recommendations(profile: PowerProfile, scope: str = "all") -> List[PowerChange]: + changes: List[PowerChange] = [] + + def _maybe_add(current_map: Dict[str, str], scope_label: str) -> None: + for key, recommended in _RECOMMENDED.items(): + if key not in current_map: + continue + current = current_map.get(key) + if current != recommended: + changes.append(PowerChange( + key=key, + current=current, + recommended=recommended, + scope=scope_label, + )) + + if scope in ("battery", "all"): + _maybe_add(profile.battery, "battery" if scope == "battery" else "all") + if scope in ("ac", "all"): + _maybe_add(profile.ac, "ac" if scope == "ac" else "all") + + return changes + + +def apply_changes(changes: List[PowerChange]) -> ApplyResult: + if not changes: + return ApplyResult(True, "No changes required", changes) + + ok = True + for change in changes: + flag = "-a" + if change.scope == "battery": + flag = "-b" + elif change.scope == "ac": + flag = "-c" + try: + result = _run(["pmset", flag, change.key, change.recommended], timeout=10) + if result.returncode != 0: + ok = False + except (OSError, subprocess.TimeoutExpired): + ok = False + + return ApplyResult(ok, "Applied power settings" if ok else "Some settings failed", changes) + + +def apply_recommended(scope: str = "all") -> ApplyResult: + profile = get_power_profile() + if profile is None: + return ApplyResult(False, "Unable to read current power settings", []) + + save_profile(profile) + changes = diff_recommendations(profile, scope=scope) + return apply_changes(changes) + + +def restore_profile(scope: str = "all") -> ApplyResult: + saved = load_profile() + if saved is None: + return ApplyResult(False, "No saved power profile found", []) + + changes: List[PowerChange] = [] + + def _build(map_data: Dict[str, str], scope_label: str) -> None: + for key, value in map_data.items(): + changes.append(PowerChange( + key=key, + current=None, + recommended=str(value), + scope=scope_label, + )) + + if scope in ("battery", "all"): + _build(saved.battery, "battery" if scope == "battery" else "all") + if scope in ("ac", "all"): + _build(saved.ac, "ac" if scope == "ac" else "all") + + return apply_changes(changes) diff --git a/src/core/restore_checksums.py b/src/core/restore_checksums.py new file mode 100644 index 0000000..73390ed --- /dev/null +++ b/src/core/restore_checksums.py @@ -0,0 +1,118 @@ +"""Restore checksum verification helpers.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from core.undo import DeletionSession, TRASH_ROOT + +logger = logging.getLogger(__name__) + +_CHECKSUM_DIR = TRASH_ROOT / "checksums" + + +@dataclass +class VerificationEntry: + """Checksum verification for one file.""" + original_path: str + restored_path: str + checksum_before: str + checksum_after: str + matched: bool + + +@dataclass +class VerificationResult: + """Summary of restore verification.""" + restored: int = 0 + verified: int = 0 + mismatched: int = 0 + failed: int = 0 + bytes_restored: int = 0 + errors: List[str] = field(default_factory=list) + entries: List[VerificationEntry] = field(default_factory=list) + + +def _sha256(path: Path, chunk_size: int = 1024 * 1024) -> Optional[str]: + if not path.exists(): + return None + h = hashlib.sha256() + try: + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(chunk_size), b""): + h.update(chunk) + return h.hexdigest() + except OSError as exc: + logger.debug("checksum failed for %s: %s", path, exc) + return None + + +def _write_manifest(session: DeletionSession, entries: List[VerificationEntry]) -> None: + _CHECKSUM_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "session_id": session.session_id, + "created_at": datetime.now().isoformat(), + "entries": [e.__dict__ for e in entries], + } + (_CHECKSUM_DIR / f"{session.session_id}.json").write_text(json.dumps(payload, indent=2)) + + +def restore_with_verification(session: DeletionSession) -> VerificationResult: + """Restore a session and verify checksums before/after move.""" + result = VerificationResult() + + for f in session.files: + staging = Path(f.staging_path) + original = Path(f.original_path) + + if not staging.exists(): + result.failed += 1 + result.errors.append(f"Missing staged file: {f.original_path}") + continue + + checksum_before = _sha256(staging) + if checksum_before is None: + result.failed += 1 + result.errors.append(f"Checksum failed: {f.original_path}") + continue + + original.parent.mkdir(parents=True, exist_ok=True) + restored_path = original + if original.exists(): + restored_path = original.with_name(original.name + ".restored") + + try: + shutil.move(str(staging), str(restored_path)) + except (shutil.Error, OSError) as exc: + result.failed += 1 + result.errors.append(f"Restore failed: {f.original_path} ({exc})") + continue + + checksum_after = _sha256(restored_path) + if checksum_after is None: + result.failed += 1 + result.errors.append(f"Checksum failed after restore: {restored_path}") + continue + + matched = checksum_before == checksum_after + result.restored += 1 + result.verified += 1 if matched else 0 + result.mismatched += 0 if matched else 1 + result.bytes_restored += f.size + result.entries.append(VerificationEntry( + original_path=f.original_path, + restored_path=str(restored_path), + checksum_before=checksum_before, + checksum_after=checksum_after, + matched=matched, + )) + + _write_manifest(session, result.entries) + return result diff --git a/src/core/safety.py b/src/core/safety.py index 6e27f33..6f56e70 100644 --- a/src/core/safety.py +++ b/src/core/safety.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Safety Module -===================================== All safety checks, safelist lookups, and system-file protection logic. Ensures that system-critical files are NEVER deleted. """ diff --git a/src/core/scanner.py b/src/core/scanner.py index b4cfb94..aa27e86 100644 --- a/src/core/scanner.py +++ b/src/core/scanner.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Scanner Module -====================================== Core scanning logic for orphan detection and general junk discovery. """ diff --git a/src/core/scheduler.py b/src/core/scheduler.py index 2dc9f72..824a38e 100644 --- a/src/core/scheduler.py +++ b/src/core/scheduler.py @@ -1,7 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Notifications & Scheduler -================================================= - Notifications ------------- Posts a native macOS notification after a scan completes using diff --git a/src/core/spotlight.py b/src/core/spotlight.py new file mode 100644 index 0000000..d6f9358 --- /dev/null +++ b/src/core/spotlight.py @@ -0,0 +1,80 @@ +"""Spotlight indexing helpers.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class SpotlightStatus: + """Spotlight status for a volume.""" + volume: str + enabled: Optional[bool] + raw: str + + +def get_spotlight_status(volume: str = "/", runner=subprocess.run) -> SpotlightStatus: + """Return Spotlight indexing status for a volume.""" + try: + result = runner( + ["mdutil", "-s", volume], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil status failed: %s", exc) + return SpotlightStatus(volume=volume, enabled=None, raw=str(exc)) + + text = result.stdout.strip() or result.stderr.strip() + enabled: Optional[bool] + if "indexing enabled" in text.lower(): + enabled = True + elif "indexing and searching disabled" in text.lower(): + enabled = False + else: + enabled = None + + return SpotlightStatus(volume=volume, enabled=enabled, raw=text) + + +def set_spotlight_indexing( + volume: str, + enabled: bool, + runner=subprocess.run, +) -> bool: + """Enable or disable Spotlight indexing.""" + flag = "on" if enabled else "off" + try: + result = runner( + ["mdutil", "-i", flag, volume], + capture_output=True, + text=True, + timeout=30, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil set failed: %s", exc) + return False + + +def reindex_spotlight(volume: str = "/", runner=subprocess.run) -> bool: + """Rebuild Spotlight index for the given volume.""" + if not set_spotlight_indexing(volume, True, runner=runner): + return False + try: + result = runner( + ["mdutil", "-E", volume], + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil reindex failed: %s", exc) + return False diff --git a/src/core/system_inspector.py b/src/core/system_inspector.py index 5375c0f..5de4b13 100644 --- a/src/core/system_inspector.py +++ b/src/core/system_inspector.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” System Inspector -======================================== Three sub-features bundled together because they share macOS system queries: 1. LaunchAgent / LaunchDaemon Manager diff --git a/src/core/time_machine_guard.py b/src/core/time_machine_guard.py new file mode 100644 index 0000000..6205d07 --- /dev/null +++ b/src/core/time_machine_guard.py @@ -0,0 +1,138 @@ +"""Time Machine backup guard helpers.""" + +from __future__ import annotations + +import logging +import plistlib +import re +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + +from core.apfs_snapshots import list_snapshots + +logger = logging.getLogger(__name__) + +_BACKUP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}-\d{6})") + + +@dataclass +class TimeMachineStatus: + """Summary of Time Machine status.""" + destinations: List[str] = field(default_factory=list) + last_backup: Optional[str] = None + last_backup_age_days: Optional[int] = None + is_running: Optional[bool] = None + local_snapshot_count: int = 0 + errors: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "destinations": self.destinations, + "last_backup": self.last_backup, + "last_backup_age_days": self.last_backup_age_days, + "is_running": self.is_running, + "local_snapshot_count": self.local_snapshot_count, + "errors": self.errors, + } + + +def _run(args: List[str], timeout: int = 20) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=False, + timeout=timeout, + ) + + +def _parse_latest_backup(path: str) -> Optional[int]: + match = _BACKUP_RE.search(path) + if not match: + return None + token = match.group(1) + try: + stamp = datetime.strptime(token, "%Y-%m-%d-%H%M%S") + except ValueError: + return None + return (datetime.now() - stamp).days + + +def get_time_machine_status() -> TimeMachineStatus: + status = TimeMachineStatus() + + # Destinations + try: + result = _run(["tmutil", "destinationinfo", "-plist"], timeout=20) + if result.returncode == 0: + pl = plistlib.loads(result.stdout) + dests = pl.get("Destinations", []) + for d in dests: + name = d.get("DestinationName") or d.get("MountPoint") or d.get("ID") + if name: + status.destinations.append(str(name)) + else: + status.errors.append("tmutil destinationinfo failed") + except Exception as exc: + logger.debug("destinationinfo failed: %s", exc) + status.errors.append("tmutil destinationinfo failed") + + # Running status + try: + result = _run(["tmutil", "status", "-plist"], timeout=10) + if result.returncode == 0: + pl = plistlib.loads(result.stdout) + status.is_running = bool(pl.get("Running", False)) + except Exception as exc: + logger.debug("tmutil status failed: %s", exc) + + # Latest backup + try: + latest = subprocess.run( + ["tmutil", "latestbackup"], + capture_output=True, + text=True, + timeout=10, + ) + if latest.returncode == 0: + path = latest.stdout.strip() + status.last_backup = path + status.last_backup_age_days = _parse_latest_backup(path) + except (OSError, subprocess.TimeoutExpired): + pass + + # Local snapshots + status.local_snapshot_count = len(list_snapshots("/")) + + return status + + +def enable_time_machine() -> tuple[bool, str]: + try: + result = subprocess.run( + ["tmutil", "enable"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return True, "Time Machine enabled" + return False, result.stderr.strip() or "tmutil enable failed" + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) + + +def disable_time_machine() -> tuple[bool, str]: + try: + result = subprocess.run( + ["tmutil", "disable"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return True, "Time Machine disabled" + return False, result.stderr.strip() or "tmutil disable failed" + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) diff --git a/src/core/tui_picker.py b/src/core/tui_picker.py new file mode 100644 index 0000000..175cec2 --- /dev/null +++ b/src/core/tui_picker.py @@ -0,0 +1,101 @@ +"""Interactive TUI app picker.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +import click + +from config.models import AppInfo + + +@dataclass +class PickerResult: + """Result of an app picker.""" + selected: Optional[AppInfo] + filter_text: str + + +def pick_app(apps: List[AppInfo], prompt: str = "Select an app") -> PickerResult: + """Pick an app from the list using a TUI or fallback prompt.""" + if not apps: + return PickerResult(selected=None, filter_text="") + + try: + import curses + except Exception: + return _pick_simple(apps, prompt) + + try: + selected = curses.wrapper(lambda stdscr: _pick_curses(stdscr, apps, prompt)) + return PickerResult(selected=selected, filter_text="") + except Exception: + return _pick_simple(apps, prompt) + + +def _pick_simple(apps: List[AppInfo], prompt: str) -> PickerResult: + click.echo("\n" + prompt) + sorted_apps = sorted(apps, key=lambda a: a.name.lower()) + for i, app in enumerate(sorted_apps, 1): + click.echo(f" {i:>3} {app.name} ({app.bundle_id})") + choice = click.prompt("App number", type=click.IntRange(1, len(sorted_apps))) + return PickerResult(selected=sorted_apps[choice - 1], filter_text="") + + +def _pick_curses(stdscr, apps: List[AppInfo], prompt: str) -> Optional[AppInfo]: + import curses + + curses.curs_set(0) + stdscr.nodelay(False) + stdscr.keypad(True) + + filter_text = "" + index = 0 + + def filtered() -> List[AppInfo]: + if not filter_text: + return apps + needle = filter_text.lower() + return [a for a in apps if needle in a.name.lower() or needle in a.bundle_id.lower()] + + while True: + stdscr.clear() + height, width = stdscr.getmaxyx() + header = f"{prompt} (type to filter, ENTER to select, q to quit)" + stdscr.addnstr(0, 0, header, width - 1) + stdscr.addnstr(1, 0, f"Filter: {filter_text}", width - 1) + + items = filtered() + if not items: + stdscr.addnstr(3, 0, "No matches", width - 1) + else: + index = max(0, min(index, len(items) - 1)) + start = max(0, index - (height - 6)) + view = items[start:start + height - 5] + for i, app in enumerate(view): + row = 3 + i + prefix = ">" if (start + i) == index else " " + label = f"{prefix} {app.name} ({app.bundle_id})" + stdscr.addnstr(row, 0, label, width - 1) + + stdscr.refresh() + ch = stdscr.getch() + if ch in (ord("q"), 27): + return None + if ch in (curses.KEY_UP, ord("k")): + index = max(0, index - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + index = min(len(filtered()) - 1, index + 1) + elif ch in (curses.KEY_BACKSPACE, 127, 8): + filter_text = filter_text[:-1] + index = 0 + elif ch in (curses.KEY_ENTER, 10, 13): + items = filtered() + if items: + return items[index] + elif 32 <= ch <= 126: + filter_text += chr(ch) + index = 0 + + diff --git a/src/core/undo.py b/src/core/undo.py index 25cd96a..4c6c800 100644 --- a/src/core/undo.py +++ b/src/core/undo.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Undo / Restore (Staged Deletion) -======================================================== Instead of permanently deleting files, mac-cleaner moves them to a staging area (~/.mac_cleaner_trash/) with a JSON manifest so they can be restored. diff --git a/src/core/update_checker.py b/src/core/update_checker.py new file mode 100644 index 0000000..96d1f19 --- /dev/null +++ b/src/core/update_checker.py @@ -0,0 +1,125 @@ +"""App update checker helpers.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from dataclasses import dataclass, field +from typing import List + +logger = logging.getLogger(__name__) + + +@dataclass +class UpdateReport: + """Summary of available updates.""" + system_updates: List[str] = field(default_factory=list) + brew_formulae: List[str] = field(default_factory=list) + brew_casks: List[str] = field(default_factory=list) + mas_updates: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +def _run(args: List[str], timeout: int = 60) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def check_system_updates() -> List[str]: + """Return available macOS software updates.""" + try: + result = _run(["softwareupdate", "-l"], timeout=120) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + updates: List[str] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("*"): + updates.append(stripped.lstrip("* ")) + elif stripped.lower().startswith("label:"): + updates.append(stripped.split(":", 1)[-1].strip()) + + return updates + + +def check_brew_updates() -> tuple[list[str], list[str]]: + """Return Homebrew outdated formulae and casks.""" + if not shutil.which("brew"): + return [], [] + + formulae: List[str] = [] + casks: List[str] = [] + + try: + out_formula = _run(["brew", "outdated", "--formula"], timeout=60) + if out_formula.returncode == 0: + formulae = [l.strip() for l in out_formula.stdout.splitlines() if l.strip()] + except (OSError, subprocess.TimeoutExpired): + pass + + try: + out_cask = _run(["brew", "outdated", "--cask"], timeout=60) + if out_cask.returncode == 0: + casks = [l.strip() for l in out_cask.stdout.splitlines() if l.strip()] + except (OSError, subprocess.TimeoutExpired): + pass + + return formulae, casks + + +def check_mas_updates() -> List[str]: + """Return Mac App Store updates via mas (if installed).""" + if not shutil.which("mas"): + return [] + + try: + result = _run(["mas", "outdated"], timeout=60) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + updates: List[str] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + updates.append(stripped) + return updates + + +def collect_update_report() -> UpdateReport: + """Collect updates from system, Homebrew, and Mac App Store.""" + report = UpdateReport() + + try: + report.system_updates = check_system_updates() + except Exception as exc: + logger.debug("system update check failed: %s", exc) + report.errors.append("system update check failed") + + try: + formulae, casks = check_brew_updates() + report.brew_formulae = formulae + report.brew_casks = casks + except Exception as exc: + logger.debug("brew update check failed: %s", exc) + report.errors.append("brew update check failed") + + try: + report.mas_updates = check_mas_updates() + except Exception as exc: + logger.debug("mas update check failed: %s", exc) + report.errors.append("mas update check failed") + + return report diff --git a/src/core/updater.py b/src/core/updater.py index 091d3b9..14792e0 100644 --- a/src/core/updater.py +++ b/src/core/updater.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Self-Update -=================================== Checks PyPI for a newer version and upgrades the package in-place using pip. Usage (CLI): diff --git a/src/reporting/exporter.py b/src/reporting/exporter.py index 6fa7d15..9728f4e 100644 --- a/src/reporting/exporter.py +++ b/src/reporting/exporter.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” Export Module -===================================== Exports scan results to JSON or YAML format. """ @@ -27,7 +25,7 @@ def export_json( ) -> None: """Export full scan results to JSON.""" data = { - "tool": "Mac Deep Cleaner v1.5.0", + "tool": "Mac Deep Cleaner v2.0.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: { @@ -84,7 +82,7 @@ def export_yaml( return data = { - "tool": "Mac Deep Cleaner v1.5.0", + "tool": "Mac Deep Cleaner v2.0.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: [e.to_dict() for e in entries] diff --git a/src/reporting/html_report.py b/src/reporting/html_report.py index 3bede8b..86fe6e0 100644 --- a/src/reporting/html_report.py +++ b/src/reporting/html_report.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 โ€” HTML Report Exporter -============================================ Generates a self-contained HTML report with: - Collapsible sections per category - Doughnut chart (Chart.js via CDN) for space breakdown @@ -94,7 +92,7 @@

โ—† Mac Deep Cleaner โ€” Scan Report

-
Generated {generated_at}  ยท  v1.5.0
+
Generated {generated_at}  ยท  v2.0.0
@@ -144,7 +142,7 @@ -
Mac Deep Cleaner v1.5.0  ยท  Report generated {generated_at}
+
Mac Deep Cleaner v2.0.0  ยท  Report generated {generated_at}