tw93/Mole/main 837k tokens More Tools
```
├── .agents/
   ├── skills/
      ├── release-notes
├── .claude/
   ├── agents/
      ├── bash32-portability-reviewer.md (1100 tokens)
      ├── safety-reviewer.md (1300 tokens)
   ├── hooks/
      ├── format-on-edit.sh (400 tokens)
   ├── settings.json (100 tokens)
   ├── skills/
      ├── release-flow/
         ├── SKILL.md (900 tokens)
      ├── release-notes/
         ├── SKILL.md (1300 tokens)
         ├── agents/
            ├── openai.yaml
         ├── scripts/
            ├── post-reactions.sh (200 tokens)
            ├── sponsors.sh (100 tokens)
├── .cursor/
   ├── rules/
      ├── mole-test-safety.mdc (100 tokens)
├── .editorconfig (omitted)
├── .githooks/
   ├── pre-commit (500 tokens)
├── .github/
   ├── CODEOWNERS
   ├── FUNDING.yml
   ├── ISSUE_TEMPLATE/
      ├── bug_report.md (200 tokens)
      ├── config.yml (100 tokens)
      ├── feature_request.md (100 tokens)
      ├── mac_app_bug.yml (600 tokens)
      ├── mac_app_feature.yml (400 tokens)
   ├── dependabot.yml (100 tokens)
   ├── pull_request_template.md (100 tokens)
   ├── workflows/
      ├── bundle_audit.yml (400 tokens)
      ├── check.yml (700 tokens)
      ├── codeql.yml (300 tokens)
      ├── release.yml (2.6k tokens)
      ├── test.yml (800 tokens)
      ├── update-contributors.yml (500 tokens)
├── .gitignore (200 tokens)
├── .gitleaks.toml (200 tokens)
├── .golangci.yml (400 tokens)
├── .shellcheckrc (100 tokens)
├── AGENTS.md (4.5k tokens)
├── CLAUDE.md
├── CONTRIBUTING.md (800 tokens)
├── CONTRIBUTORS.svg (290.2k tokens)
├── LICENSE (omitted)
├── Makefile (400 tokens)
├── README.md (3.1k tokens)
├── SECURITY.md (600 tokens)
├── SECURITY_AUDIT.md (4.5k tokens)
├── TRADEMARK.md (100 tokens)
├── bin/
   ├── analyze.sh (100 tokens)
   ├── clean.sh (11.1k tokens)
   ├── completion.sh (3.7k tokens)
   ├── history.sh (400 tokens)
   ├── installer.sh (5.2k tokens)
   ├── optimize.sh (2k tokens)
   ├── purge.sh (2.2k tokens)
   ├── status.sh (100 tokens)
   ├── touchid.sh (2.5k tokens)
   ├── uninstall.sh (12.7k tokens)
├── cmd/
   ├── analyze/
      ├── analyze_filter_test.go (2.1k tokens)
      ├── analyze_test.go (12.2k tokens)
      ├── cache.go (2.2k tokens)
      ├── cleanable.go (600 tokens)
      ├── cleanable_test.go (300 tokens)
      ├── constants.go (1500 tokens)
      ├── delete.go (1400 tokens)
      ├── delete_fuzz_test.go (300 tokens)
      ├── delete_test.go (1900 tokens)
      ├── format.go (1100 tokens)
      ├── format_test.go (2000 tokens)
      ├── heap.go (200 tokens)
      ├── heap_test.go (800 tokens)
      ├── insights.go (900 tokens)
      ├── insights_test.go (500 tokens)
      ├── json.go (900 tokens)
      ├── json_test.go (500 tokens)
      ├── live_config.go (100 tokens)
      ├── live_scan.go (2.4k tokens)
      ├── main.go (1000 tokens)
      ├── main_stub.go
      ├── model.go (2k tokens)
      ├── scanner.go (5.9k tokens)
      ├── scanner_test.go (900 tokens)
      ├── test_helpers_test.go (300 tokens)
      ├── update.go (6.7k tokens)
      ├── view.go (3.2k tokens)
   ├── status/
      ├── diagnosis.go (400 tokens)
      ├── main.go (1900 tokens)
      ├── main_test.go (2.6k tokens)
      ├── metrics.go (3.7k tokens)
      ├── metrics_battery.go (2.9k tokens)
      ├── metrics_battery_test.go (1000 tokens)
      ├── metrics_bluetooth.go (700 tokens)
      ├── metrics_cpu.go (1300 tokens)
      ├── metrics_disk.go (2.7k tokens)
      ├── metrics_disk_test.go (1200 tokens)
      ├── metrics_fast_test.go (800 tokens)
      ├── metrics_gpu.go (1000 tokens)
      ├── metrics_hardware.go (700 tokens)
      ├── metrics_health.go (1200 tokens)
      ├── metrics_health_test.go (1600 tokens)
      ├── metrics_memory.go (500 tokens)
      ├── metrics_network.go (1500 tokens)
      ├── metrics_network_test.go (1000 tokens)
      ├── metrics_process.go (900 tokens)
      ├── metrics_test.go (800 tokens)
      ├── process_watch.go (700 tokens)
      ├── process_watch_test.go (1500 tokens)
      ├── view.go (5.1k tokens)
      ├── view_test.go (7.7k tokens)
      ├── watch.go (400 tokens)
├── docs/
   ├── SECURITY_DESIGN.md (2.2k tokens)
   ├── release-notes/
      ├── V1.42.0.md (400 tokens)
├── go.mod (300 tokens)
├── go.sum (1500 tokens)
├── install.sh (6.9k tokens)
├── internal/
   ├── units/
      ├── bytes.go (600 tokens)
      ├── bytes_test.go (600 tokens)
├── lib/
   ├── check/
      ├── health_json.sh (1700 tokens)
   ├── clean/
      ├── app_caches.sh (6.6k tokens)
      ├── apps.sh (8.9k tokens)
      ├── brew.sh (2.9k tokens)
      ├── caches.sh (3.6k tokens)
      ├── dev.sh (16.5k tokens)
      ├── hints.sh (6.5k tokens)
      ├── launch_services.sh (1000 tokens)
      ├── maven.sh (100 tokens)
      ├── project.sh (12.2k tokens)
      ├── purge_shared.sh (900 tokens)
      ├── system.sh (5.5k tokens)
      ├── user.sh (20.6k tokens)
   ├── core/
      ├── app_protection.sh (15.3k tokens)
      ├── app_protection_data.sh (3.1k tokens)
      ├── base.sh (6.6k tokens)
      ├── bundle_resolver.sh (1000 tokens)
      ├── commands.sh (100 tokens)
      ├── common.sh (1600 tokens)
      ├── file_ops.sh (8.7k tokens)
      ├── help.sh (700 tokens)
      ├── history.sh (3.5k tokens)
      ├── log.sh (2.8k tokens)
      ├── pkg_receipts.sh (900 tokens)
      ├── sudo.sh (2.4k tokens)
      ├── timeout.sh (2.3k tokens)
      ├── timeouts.sh (700 tokens)
      ├── ui.sh (4k tokens)
   ├── manage/
      ├── purge_paths.sh (700 tokens)
      ├── whitelist.sh (3.7k tokens)
   ├── optimize/
      ├── diagnostics.sh (2.9k tokens)
      ├── maintenance.sh (700 tokens)
      ├── tasks.sh (10.4k tokens)
   ├── ui/
      ├── app_selector.sh (1500 tokens)
      ├── menu_paginated.sh (7.5k tokens)
      ├── menu_simple.sh (2.4k tokens)
   ├── uninstall/
      ├── batch.sh (14.4k tokens)
      ├── brew.sh (2000 tokens)
├── mo
├── mole (8.3k tokens)
├── scripts/
   ├── audit_bundle_drift.sh (600 tokens)
   ├── check.sh (1100 tokens)
   ├── check_release_minos.sh (400 tokens)
   ├── setup-quick-launchers.sh (2.6k tokens)
   ├── test.sh (2.7k tokens)
   ├── update_homebrew_tap_formula.sh (700 tokens)
├── tests/
   ├── .gitignore (100 tokens)
   ├── brew_uninstall.bats (3.1k tokens)
   ├── bundle_resolver.bats (1600 tokens)
   ├── clean_ai_cli_caches.bats (3.3k tokens)
   ├── clean_app_caches.bats (5.5k tokens)
   ├── clean_apps.bats (8.2k tokens)
   ├── clean_browser_versions.bats (2.8k tokens)
   ├── clean_cached_device_firmware.bats (1500 tokens)
   ├── clean_core.bats (4.7k tokens)
   ├── clean_corepack.bats (600 tokens)
   ├── clean_dev_caches.bats (6.4k tokens)
   ├── clean_hints.bats (4.6k tokens)
   ├── clean_launch_services.bats (1300 tokens)
   ├── clean_misc.bats (2.3k tokens)
   ├── clean_system_caches.bats (3.5k tokens)
   ├── clean_system_maintenance.bats (9.1k tokens)
   ├── clean_user_core.bats (7.8k tokens)
   ├── clean_xcode_derived_data.bats (1100 tokens)
   ├── cli.bats (4.3k tokens)
   ├── completion.bats (1200 tokens)
   ├── core_common.bats (5.7k tokens)
   ├── core_performance.bats (900 tokens)
   ├── core_safe_functions.bats (5.3k tokens)
   ├── core_timeout.bats (1700 tokens)
   ├── dev_extended.bats (5.1k tokens)
   ├── file_ops_mole_delete.bats (2.4k tokens)
   ├── file_ops_safe_remove_symlink.bats (400 tokens)
   ├── file_ops_size.bats (800 tokens)
   ├── format_on_edit_hook.bats (500 tokens)
   ├── fuzz_corpus/
      ├── dangerous_paths.txt (500 tokens)
   ├── health_json.bats (300 tokens)
   ├── history.bats (1400 tokens)
   ├── install_checksum.bats (2.9k tokens)
   ├── installer.bats (2.7k tokens)
   ├── installer_fd.bats (1300 tokens)
   ├── installer_zip.bats (2.2k tokens)
   ├── manage_sudo.bats (800 tokens)
   ├── manage_whitelist.bats (2.3k tokens)
   ├── menu_trap_restore.bats (700 tokens)
   ├── no_color.bats (200 tokens)
   ├── optimize.bats (10k tokens)
   ├── optimize_db.bats (600 tokens)
   ├── path_validation_fuzz.bats (500 tokens)
   ├── purge.bats (7.8k tokens)
   ├── purge_config_paths.bats (1000 tokens)
   ├── regression.bats (1800 tokens)
   ├── scripts.bats (1600 tokens)
   ├── test_match_apps_helper.sh (200 tokens)
   ├── timeout_tty_read.exp (100 tokens)
   ├── timeout_tty_read_fixture.sh (100 tokens)
   ├── timeout_tty_restore.exp (100 tokens)
   ├── uninstall.bats (18.2k tokens)
   ├── uninstall_naming_variants.bats (2.5k tokens)
   ├── uninstall_remove_file_list.bats (1100 tokens)
   ├── uninstall_safety.bats (3.4k tokens)
   ├── uninstall_scan_bash32.bats (3k tokens)
   ├── update.bats (2.4k tokens)
   ├── user_file_ops.bats (2.1k tokens)
```


## /.agents/skills/release-notes

```agents/skills/release-notes path="/.agents/skills/release-notes" 
../../.claude/skills/release-notes
```

## /.claude/agents/bash32-portability-reviewer.md

---
name: bash32-portability-reviewer
description: Scans Mole shell diffs for bash 3.2 / macOS-default-bash landmines that have shipped bugs before: nounset on empty arrays, `[[ -n ]] && cmd` short-circuit, heredoc `read -n1` byte theft, `run_with_timeout` exec bypassing function mocks. Use after any change under `bin/`, `lib/`, `install.sh`, or `tests/*.bats`.
tools: Read, Grep, Glob, Bash
---

You are a bash portability reviewer for Mole. Your only job is to catch the specific macOS-default-bash (3.2) and `set -euo pipefail` pitfalls that CLAUDE.md documents as having already cost this project a release-day bug. You read code, you never write it.

## The four documented landmines

These come from the "Shell and release pitfalls" section of `CLAUDE.md`. Each one shipped or nearly shipped a real bug. Re-read that section before reporting.

### Landmine 1: nounset on empty arrays

**Pattern**: `"${arr[@]}"` expansion when `arr=()` is possibly empty, under `set -u` (or any file that sources `set -euo pipefail`).
**Symptom**: `unbound variable` on macOS bash 3.2. Real examples: `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=()` in `lib/manage/whitelist.sh`, and `kept_files=()` in `lib/clean/apps.sh` when every orphan service is whitelisted.
**Fix**: guard the expansion with `[[ ${#arr[@]} -gt 0 ]] && ...`.

How to find: grep the diff for `"${[A-Za-z_][A-Za-z0-9_]*\[@\]}"` and check whether the surrounding array is initialized as possibly empty. Do not limit the scan to uppercase array names.

### Landmine 2: `[[ -n "$var" ]] && cmd` returns 1 when var is empty

**Pattern**: `[[ -n "$x" ]] && something` used as an inline guard. The `&&` short-circuit means the whole expression returns 1 when `$x` is empty, even though the intent was "skip silently". Under `set -e`, or inside a `{...} > file ||` redirect, this silently breaks the success path.
**Symptom**: silent failure of a wrapper / redirect block. Real example: `write_install_channel_metadata` in `install.sh`, stable channel always tripped a warning because the inline short-circuit returned 1.
**Fix**: switch to `if [[ -n "$x" ]]; then cmd; fi` whenever the conditional sits inside an exit-code-sensitive block.

How to find: grep the diff for `\[\[ -n .* \]\] &&` or `\[\[ -z .* \]\] &&`. Inspect whether the line is the last command of a `{ ... }` block, a function body, a subshell, or under a `||` clause.

### Landmine 3: bats heredoc steals bytes from `read -n1`

**Pattern**: a function under test calls `read -r -s -n1` (typical for confirmation prompts in `lib/core/ui.sh`), and the test runs it via `bash <<'EOF' ... EOF`. The `-n1` read consumes the next byte from the heredoc source, corrupting the next command (e.g., `echo` becomes `cho`, exit 127).
**Symptom**: bats test with garbled stderr like `cho: command not found`.
**Fix**: redirect the function's stdin from `/dev/null` inside the test, e.g. `the_function < /dev/null`.

How to find: in `tests/*.bats`, look for `bash <<` heredocs that exercise functions reading single bytes. Confirm a `< /dev/null` redirect on the function call.

### Landmine 4: `run_with_timeout` execs the binary, bypassing bash function mocks

**Pattern**: a test overrides a command via a bash function (e.g., `osascript() { ... }`), then exercises code that wraps the command in `run_with_timeout`. `gtimeout`/`timeout` exec the real PATH binary, so the function shadow is invisible.
**Symptom**: a test that "should" hit the mock reaches the real binary, or fails because the mocked behavior never ran.
**Fix**: use a PATH stub directory: write a real executable script at `$TMP/bin/osascript`, prepend `$TMP/bin` to `PATH` for the test, do not use function shadowing.

How to find: in `tests/*.bats`, grep for function-style command shadows (`osascript()`, `sudo()`, `launchctl()`) on functions that are reached through `run_with_timeout` in the production code path.

## Out of scope

- General style or readability nits.
- `unwrap` / `panic` / `expect` in any context.
- Suggestions to refactor unrelated code.
- Anything outside `bin/`, `lib/`, `install.sh`, `mole`, `tests/*.bats`, `scripts/*.sh`.

## How to review

1. `git diff` against the branch base. Restrict attention to in-scope files.
2. For each in-scope file, run the four landmine searches above.
3. For every match, read enough surrounding context to decide whether the landmine actually fires (the pattern is necessary but not sufficient; e.g., a `[[ -n ]] && cmd` outside an exit-code-sensitive block is fine).
4. Report only confirmed or strongly-suspected fires. Do not report the pattern as a finding if the surrounding code already handles it.

## Output format

```
LANDMINE <n>: <file>:<line> - <one-line problem statement>
  Pattern: <the actual matched line, copied>
  Why it fires here: <one sentence pointing to the surrounding context>
  Fix: <one concrete suggestion>
```

End with one of:
- `VERDICT: no landmines found`
- `VERDICT: <N> landmines, fix before merge`

If a pattern matched but you could not verify the surrounding context, say so as `UNVERIFIED: <file>:<line> - <reason>` instead of inventing a finding.

Keep it terse. No preamble.

**Zero-findings case**: if you have no landmines and no UNVERIFIED items, emit only the single line `VERDICT: no landmines found`. Do not write a justification paragraph. Do not summarize what you looked at. The absence of findings is the message.


## /.claude/agents/safety-reviewer.md

---
name: safety-reviewer
description: Audits Mole shell/Go changes against this repo's destructive-action safety contract: file deletion, app protection, sudo/osascript/launchctl guards, operation logging, cleanup previews, and leftover matching. Use before merging anything that touches lib/clean/**, lib/uninstall/**, lib/manage/**, bin/clean.sh, bin/purge.sh, bin/uninstall.sh, lib/core/file_ops.sh, lib/core/app_protection*.sh.
tools: Read, Grep, Glob, Bash
---

You are a safety reviewer for Mole, a macOS cleanup tool. Your job is to catch destructive-action regressions before they ship. You read code, you never write it.

## What this repo treats as P0

A change is P0-unsafe if any of these are true:

1. **Raw delete**: introduces `rm -rf`, `rm -r`, `find ... -delete`, `unlink`, or `trash` invocations outside `lib/core/file_ops.sh`. Every removal in user-visible flows must route through `mole_delete` from `lib/core/file_ops.sh` so Trash routing, oplog, dry-run, and path protection stay consistent.
2. **Unprotected path**: adds a cleanup, purge, or uninstall path that does not first pass through `should_protect_path` (or a domain helper like `is_app_protected`, `is_bundle_protected`, or the `app_protection*` lookups in `lib/core/`).
3. **Protected-path write**: writes into `/System`, `/Library/Apple`, anything under `com.apple.*`, or any path the app_protection data marks as protected.
4. **Unguarded privileged call**: adds a new direct use of `sudo`, `osascript`, `launchctl`, `defaults write`, `pkill`, `killall`, `mdutil`, or `dscl` that is not gated by `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH`, and not fully mocked in tests.
5. **Bypassed dry-run**: destructive code path that does not check `MOLE_DRY_RUN` or call the helper that does.
6. **Lost oplog**: change that drops or routes around `record_operation` / `MO_NO_OPLOG` semantics for a user-visible delete.
7. **Package-manager removal without preview**: Homebrew or other package-manager cleanup executes removal without first showing exact candidates and honoring dry-run/test mode.

## What this repo treats as P1

- AI-tool cache cleanup that is not conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, Codex caches may contain config, credentials, or session state. Removing the whole cache dir is unsafe, list specific subpaths.
- New bundle-ID matcher in `lib/core/app_protection_data.sh` without a corresponding test case in `tests/uninstall_*.bats` or `tests/bundle_resolver.bats`.
- Broad uninstall leftover matching. Reject generic/common app words, short names without a floor, broad `Preferences/ByHost` candidates, `TeamID.*` style wildcards, and helper-remnant removal before the parent app is confirmed gone.
- System-service orphan detection that treats unreadable plist errors as paths, accepts non-absolute `Program` output, or relies on `/Library/PrivilegedHelperTools` existing in CI.
- Cleanup of tiny macOS UI state with visible side effects, such as wallpaper cover thumbnails, unless the change proves meaningful user value and has a regression test.
- Changes to ESC timeout in `lib/core/ui.sh` (CLAUDE.md says: do not change without explicit ask).
- Edits to any of the three intentionally-divergent implementations of `start_section` / `end_section` / `note_activity` in `lib/core/base.sh`, `bin/clean.sh`, `bin/purge.sh` without reading the cross-reference comment in `lib/core/base.sh` first. Source order decides which one wins; the wording, color, and dry-run export semantics differ on purpose.

## What this repo treats as P2

- A delete-adjacent code path missing a Bats test under `tests/`.
- Dry-run flow not verified with `MOLE_DRY_RUN=1 ./mole <cmd>` or `MOLE_TEST_NO_AUTH=1 ./mole <cmd> --dry-run`.
- Sudo-bearing path not verified with `MOLE_TEST_NO_AUTH=1`.

## How to review

1. `git diff` against the branch base. Identify files under `lib/clean/`, `lib/uninstall/`, `lib/manage/`, `bin/clean.sh`, `bin/purge.sh`, `bin/uninstall.sh`, `lib/core/file_ops.sh`, `lib/core/app_protection*.sh`, `cmd/analyze/`. These are the in-scope surfaces.
2. For each in-scope file, grep the diff for: `rm -rf`, `rm -r`, `find.*-delete`, `unlink`, bare `sudo`, `osascript`, `launchctl`, `defaults write`, `pkill`, `killall`, new path literals.
3. For every match, verify the safety contract above. If a contract isn't met, that's a finding.
4. Read the surrounding 10-20 lines of the file (not just the diff) to confirm the dry-run/protection guard isn't already present upstream in the function.
5. Cross-check that the listed test commands in CLAUDE.md "Hotspot Ownership" for the touched area actually exist and cover the change. If the hotspot says "run `bats tests/clean_app_caches.bats`" and the diff is in `lib/clean/user.sh`, that test file should have been added to or exercised.
6. For issue-driven cleanup changes, read the issue title/body and make sure the fix does not silently broaden into another cleanup category. A reporter asking for one leftover path is not permission to add broad recursive matching.

## What NOT to flag

- `unwrap` / `panic` / `expect` in test files (`*_test.go`, `tests/*.bats`), doctests, or string literals. CLAUDE.md flags this as a credibility-loss pattern.
- Style nits unrelated to safety.
- "Could be refactored" suggestions. Stick to the contract.
- Files outside the in-scope list, unless the diff actually changes deletion or privilege behavior there.

## Output format

Report findings in this exact shape, ordered by severity:

```
P0: <file>:<line> - <one-line problem statement>
  Why unsafe: <one sentence pointing to the broken invariant>
  Fix: <one concrete suggestion>

P1: <file>:<line> - ...

P2: <file>:<line> - ...
```

End with a one-line verdict:
- `VERDICT: safe to merge` if no P0/P1 findings.
- `VERDICT: changes required` if any P0 or P1.

If you cannot tell from the diff whether a guard is present (e.g., the function under edit calls a helper you didn't read), say so explicitly: `UNVERIFIED: <reason, what would resolve it>`. Do not assume the safety guard exists.

Keep findings terse. No preamble. No closing summary. Maintainers read the verdict and the P0 lines first.

**Zero-findings case**: if you have no P0, P1, P2, or UNVERIFIED items, emit only the single line `VERDICT: safe to merge`. Do not write a justification paragraph. Do not summarize what you looked at. The absence of findings is the message.


## /.claude/hooks/format-on-edit.sh

```sh path="/.claude/hooks/format-on-edit.sh" 
#!/bin/bash
# Format files changed by Claude Code with the project's configured formatters.
# Stdin is the tool hook payload (JSON). Failures must not block the edit.

set -u

if ! command -v jq > /dev/null 2>&1; then
    exit 0
fi

PAYLOAD=$(cat)
HOOK_CWD=$(printf '%s' "$PAYLOAD" | jq -r '.cwd // empty' 2> /dev/null || true)
[[ -n "$HOOK_CWD" ]] || HOOK_CWD="$PWD"

PROJECT_ROOT=$(git -C "$HOOK_CWD" rev-parse --show-toplevel 2> /dev/null || true)
[[ -n "$PROJECT_ROOT" ]] || exit 0
PROJECT_ROOT=$(cd -P "$PROJECT_ROOT" 2> /dev/null && pwd) || exit 0

format_repo_file() {
    local input_path="$1"
    local candidate=""
    local candidate_dir=""
    local resolved=""

    case "$input_path" in
        /*) candidate="$input_path" ;;
        *) candidate="$HOOK_CWD/$input_path" ;;
    esac

    # Never follow an edited symlink outside the repository.
    [[ -f "$candidate" && ! -L "$candidate" ]] || return 0
    candidate_dir=$(cd -P "$(dirname "$candidate")" 2> /dev/null && pwd) || return 0
    resolved="$candidate_dir/$(basename "$candidate")"
    case "$resolved" in
        "$PROJECT_ROOT"/*) ;;
        *) return 0 ;;
    esac

    case "$resolved" in
        *.sh | "$PROJECT_ROOT/mole")
            if command -v shfmt > /dev/null 2>&1; then
                shfmt -i 4 -ci -sr -w "$resolved" > /dev/null 2>&1 || true
            fi
            ;;
        *.go)
            if command -v goimports > /dev/null 2>&1; then
                goimports -w -local github.com/tw93/mole "$resolved" > /dev/null 2>&1 || true
            elif command -v gofmt > /dev/null 2>&1; then
                gofmt -w "$resolved" > /dev/null 2>&1 || true
            fi
            ;;
    esac
}

FILE=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2> /dev/null || true)
[[ -n "$FILE" ]] && format_repo_file "$FILE"

exit 0

```

## /.claude/settings.json

```json path="/.claude/settings.json" 
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"
          }
        ]
      }
    ]
  }
}

```

## /.claude/skills/release-flow/SKILL.md

---
name: release-flow
description: "Mole CLI release runbook: distribution channels, pre-flight checklist, capital-V tag publish, curated notes handoff, and release-only pitfalls. Read before any release-flavored task in this repo."
---

# Mole CLI Release Flow

Tag-driven flow. The `release.yml` workflow watches `'V*'` tag pushes (capital `V`), builds amd64 and arm64 binaries on macOS, generates `SHA256SUMS`, attaches build provenance, creates the GitHub Release without notes, then bumps the personal Homebrew tap and opens a Homebrew core PR.

## Distribution channels

| Channel | What ships | Trigger | Automation |
|---|---|---|---|
| Nightly (`mo update --nightly`) | `main` HEAD via `install.sh` | Any commit pushed to `main` | Automatic; no tag or release involved |
| GitHub stable release | amd64/arm64 binaries + `SHA256SUMS` | Push a capital-`V` tag | `release.yml` builds and creates the release; curated notes are a manual follow-up |
| Homebrew personal tap (`tw93/homebrew-tap`) | Formula bump | Same `V*` tag workflow | Automatic; do not re-run manually unless the workflow log shows a failure |
| Homebrew core | Version-bump PR to `Homebrew/homebrew-core` | Same `V*` tag workflow | Automatic PR; merge timing is upstream's |

At the start of any release-flavored task, restate which channels this run will touch and which it will not, and confirm with the maintainer before acting. Channel scope is specified by the maintainer, never inferred.

## Pre-flight checklist

1. `grep '^VERSION=' mole` matches the new version.
2. `SECURITY_AUDIT.md` opening line reflects the new version and date.
3. `git status -s` is empty or only contains intentionally staged release work.
4. `git log origin/main..HEAD --oneline` shows only commits you intend to ship.
5. `./scripts/check.sh --format` and `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` both exit 0.
6. `go test ./cmd/...` and `make build` both pass.

## Tag and publish

```bash
git push origin main
git tag V<version>          # capital V; release workflow ignores lowercase v
git push origin V<version>
```

Wait for the workflow to finish (typically 2 minutes for V1.38.0). The workflow creates the release with assets but `generate_release_notes: false`, so notes must be added in a follow-up step.

## Apply curated release notes

The curated-notes flow (bilingual format, `gh release edit` instead of `create`, thanks block, and the six-reaction set) is owned by `.claude/skills/release-notes/SKILL.md`. `.agents/skills/release-notes` is a symlink to that canonical directory for Codex discovery, and its Codex-only invocation policy lives in `agents/openai.yaml`; do not replace the symlink with a copied mirror. Follow that skill; do not duplicate its format details here. Version, codename, and emoji go only in the release title; the body h1 is just `Mole`.

Ritual anchors: before drafting, read the latest stable release body as the hard format template (`gh release view <latest-tag> --json body`); the title takes a codename plus emoji per repo convention (for example `V1.45.0 Quiet 🤫`). After publishing, add all six positive reactions (`+1`, `laugh`, `heart`, `hooray`, `rocket`, `eyes`) with `.claude/skills/release-notes/scripts/post-reactions.sh V<version>` (the script lives inside the skill, not in the top-level `scripts/`), then re-read the release reactions to confirm all six landed.

## Release-notes craft

Format rules (impact ordering, command existence checks, icon semantics, no em dash, no inline PR refs) live in `.claude/skills/release-notes/SKILL.md` under "Format rules". Keep that skill as the single source of truth for notes formatting.

## Release-only pitfalls

- **`gh release create` conflicts with the workflow-created release**: the workflow already creates the release on tag push, so post-tag note publishing must use `gh release edit`, never `create`.
- **Tag prefix is case-sensitive**: `release.yml` filters on `'V*'`. A lowercase `v1.38.0` tag will not trigger the workflow.

Shell and bats pitfalls (bash 3.2 arrays, heredoc `read -n1`, mock bypasses, CI runner quirks) stay in `AGENTS.md` under "Shell and Test Pitfalls"; re-read that section when release work touches shell code or tests.


## /.claude/skills/release-notes/SKILL.md

---
name: release-notes
description: Publish curated release notes for a Mole `V<version>` tag. Encodes the compact bilingual format, the gh release edit (not create) flow, reporter/contributor thanks, and the six-reaction set. User-only because publishing is a side effect that touches the public release page.
disable-model-invocation: true
---

# Mole release notes

This skill drives the curated-notes step that runs **after** `release.yml` has finished. The workflow creates the GitHub Release with assets but with `generate_release_notes: false`, so notes must be added in a follow-up `gh release edit` (never `gh release create`, the release already exists, and `create` will conflict).

## Inputs to gather

Before drafting, confirm:

1. **Version**. Capital `V`, e.g. `V1.38.0`. Lowercase `v` does not trigger the workflow and may indicate a botched tag.
2. **CodeName + emoji**. Ask the user. The title format is `V<version> <CodeName> <emoji>`.
3. **Release commit range**. `git log <previous-tag>..V<version> --oneline` gives the raw material.
4. **User-visible behavior changes**. Scan the full commit message bodies (not just subjects) for any narrowed detection, removed feature, or "controlled regression" wording. Examples that have shipped before: the V1.40 VPN narrowing (37a446c9) silently stopped detecting split-tunnel third-party VPNs, the Bluetooth reset removal (357ee057) dropped a flow some users depended on. These belong in notes even when not bug-fix-shaped, because users will hit them in production and won't know what changed.
5. **Issue reporters and PR contributors in this cycle**. Use the merged PRs and fixed issues in the release range. Keep it short, for example `Issue reporters and PR contributors this cycle: @a · @b.` Exclude `tw93` and bots.
6. **Verify release exists**. `gh release view V<version> --repo tw93/Mole --json id,name` should return non-empty. If it doesn't, the workflow hasn't finished, wait, don't `gh release create`.

## Pre-flight (cross-check against AGENTS.md)

These should already be true if the tag was pushed correctly. Confirm before publishing notes:

- `grep '^VERSION=' mole` matches `<version>`.
- `SECURITY_AUDIT.md` opening line reflects the new version and date.
- `./scripts/check.sh --format` clean.
- `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` exits 0.
- `go test ./cmd/...` and `make build` pass.

If any fail, stop. The notes can wait; a bad release tag cannot.

## Format

Strictly follow the current compact release shape. Compare against a recent release if unsure:
`gh release view V1.45.0 --repo tw93/Mole --json body --jq .body`. (Do not copy older pages such as V1.43.1 or V1.44.x; their body h1 drifted and was rejected.)

Structure:

```
<div align="center">
  <img src="https://cdn.tw93.fun/pic/cole.png" alt="Mole Logo" width="120" height="120" style="border-radius:50%" />
  <h1 style="margin: 12px 0 6px;">Mole</h1>
  <p><em>Deep clean and optimize your Mac.</em></p>
</div>

### Changelog

1. **<English headline>**: <one-sentence English elaboration>.
2. ...

### 更新日志

1. **<中文 headline>**:<一句中文说明>。
2. ...

### Thanks

Issue reporters and PR contributors this cycle: @handle1 · @handle2.

### Mole Mac App

Prefer a GUI? Try [Mole Mac App](https://mole.fit). The CLI stays free and open source.
```

No `---` separators between sections, and no trailing repository link; the published pages end on the Mole Mac App line.

### Format rules (all are documented bugs that have shipped before)

- **Body h1 is just `Mole`**. Version, codename, and emoji live only in the `--title` argument (`V<version> <CodeName> <emoji>`); repeating them in the body header is redundant and has been explicitly rejected before.
- **No em dash anywhere**. Use commas, periods, colons, semicolons, or parentheses.
- **No sponsor list by default**. The current public release style thanks issue reporters and PR contributors for this cycle only.
- **No emoji except the version emoji in the release title**. Body section headers stay plain, including `### Thanks` (the old `Thanks 💖` header is gone from the published pages).
- **No inline PR refs, no inline `@handle` thanks**. PRs and people belong in the dedicated Thanks block only.
- **English block first, 中文 block second**. Same numbered order in both blocks. Same number of items.
- **Order items by user-perceived impact, not commit chronology**. Headline change first; internal safety hardening, performance, and bug fixes follow.
- **Do not describe overview icons that no longer exist**. Analyze overview rows are text-only because emoji width and baselines vary across terminals. If icons return later, they must not imply that user data such as iOS Backups, Xcode Archives, or Old Downloads is safe to delete.
- **Verify every command mentioned in the notes actually exists in HEAD**. AGENTS.md cites `mo check / mo doctor` as a case where a removed command nearly shipped as a "feature".
- **Keep the Mole Mac App cross-link only if it matches the current release style**. Do not turn it into a sales block.

## Publish

Once the user approves the draft:

```bash
gh release edit V<version> --repo tw93/Mole \
  --title "V<version> <CodeName> <emoji>" \
  --notes-file <path-to-draft>
```

**Never** `gh release create`, it conflicts with the release the workflow already made.

Then add the six reactions with this skill's helper (path is relative to this SKILL.md, not the repo-root `scripts/`): `bash "$(dirname <this SKILL.md>)/scripts/post-reactions.sh" V<version>`.

## After publish

- `gh release view V<version> --repo tw93/Mole --web` (open in browser) so the user can eyeball it.
- Remind the user: Homebrew tap + Homebrew core PR are workflow-driven and should already be in flight; do not re-run them manually unless the workflow log shows a failure.

## When NOT to act

This skill is user-invocable only. It must not run unprompted:

- If the user mentions release notes in passing, draft only; do not call `gh release edit`.
- If `gh release view` shows the release does not exist yet, wait. The workflow takes about 2 minutes for an Mn.m.0.
- If the user has not given an explicit "publish" / "提交" signal, stop after the draft.

## Helper scripts

Both live in this skill's `scripts/` directory (next to this SKILL.md), not in the repo-root `scripts/`:

- `scripts/sponsors.sh` - legacy helper for fetching recent sponsors. Do not use it in Mole release notes unless the user explicitly asks for sponsor names.
- `scripts/post-reactions.sh <tag>` - adds the six reactions (`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`) to the release.


## /.claude/skills/release-notes/agents/openai.yaml

```yaml path="/.claude/skills/release-notes/agents/openai.yaml" 
policy:
  allow_implicit_invocation: false

```

## /.claude/skills/release-notes/scripts/post-reactions.sh

```sh path="/.claude/skills/release-notes/scripts/post-reactions.sh" 
#!/bin/bash
# Add the standard six reactions (+1, laugh, hooray, heart, rocket, eyes) to a
# tw93/Mole release. Usage: post-reactions.sh V<version>

set -euo pipefail

TAG="${1:-}"
if [[ -z "$TAG" ]]; then
    echo "Usage: $0 V<version>" >&2
    exit 1
fi

if [[ "$TAG" != V* ]]; then
    echo "Tag must start with capital V (release.yml ignores lowercase v): $TAG" >&2
    exit 1
fi

if ! command -v gh > /dev/null 2>&1; then
    echo "gh CLI is required" >&2
    exit 1
fi

RELEASE_ID=$(gh api "repos/tw93/Mole/releases/tags/$TAG" --jq '.id')
if [[ -z "$RELEASE_ID" ]]; then
    echo "Release not found for tag: $TAG" >&2
    exit 1
fi

for r in +1 laugh hooray heart rocket eyes; do
    gh api "repos/tw93/Mole/releases/$RELEASE_ID/reactions" \
        -X POST -f content="$r" --silent
done

echo "Posted 6 reactions to $TAG (release id $RELEASE_ID)"

```

## /.claude/skills/release-notes/scripts/sponsors.sh

```sh path="/.claude/skills/release-notes/scripts/sponsors.sh" 
#!/bin/bash
# Fetch the 30 most recent sponsors of tw93 via gh api graphql.
# Uses the minimal query that works on a token without `read:user` scope.
# Adding createdAt or privacyLevel requires `read:user`.

set -euo pipefail

if ! command -v gh > /dev/null 2>&1; then
    echo "gh CLI is required" >&2
    exit 1
fi

gh api graphql -f query='{
  user(login:"tw93"){
    sponsorshipsAsMaintainer(first:30, orderBy:{field:CREATED_AT, direction:DESC}){
      nodes{
        sponsorEntity{
          ... on User{login}
          ... on Organization{login}
        }
      }
    }
  }
}' --jq '.data.user.sponsorshipsAsMaintainer.nodes[].sponsorEntity.login'

```

## /.cursor/rules/mole-test-safety.mdc

```mdc path="/.cursor/rules/mole-test-safety.mdc" 
---
description: Mole test safety and verification defaults
alwaysApply: true
---

# Mole Test Safety

- Default test and verification commands must run with `MOLE_TEST_NO_AUTH=1` or through `scripts/test.sh`, which sets it automatically.
- Tests must never trigger real `sudo`, Touch ID, password prompts, AppleScript permission dialogs, or system service changes.
- Any new direct use of `sudo`, `osascript`, or `launchctl` must either be guarded by `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` or be fully mocked in the test path.
- Prefer focused regression tests first, then run full no-auth verification before release-sensitive changes.

```

## /.githooks/pre-commit

```githooks/pre-commit path="/.githooks/pre-commit" 
#!/usr/bin/env bash
# Pre-commit hook: mirrors GitHub CI checks locally.
# Installed via: git config core.hooksPath .githooks
#
# Runs on every `git commit`. Catches format/lint/test failures before push.

set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

_ok() { echo -e "${GREEN}✓${NC} $1"; }
_fail() { echo -e "${RED}✗${NC} $1"; }
_info() { echo -e "${YELLOW}→${NC} $1"; }

echo ""
_info "Running pre-commit checks (mirrors GitHub CI)..."
echo ""

# Only check staged shell/Go files to keep commits fast.
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
HAS_SHELL=$(echo "$STAGED" | grep -E '\.sh$|^mole$|^bin/' || true)
HAS_GO=$(echo "$STAGED" | grep -E '\.go{{contextString}}#39; || true)

FAILED=0

# --- 1. Shell syntax check (fast, no tool required) ---
if [[ -n "$HAS_SHELL" ]]; then
    _info "Shell syntax check..."
    while IFS= read -r f; do
        [[ -f "$f" ]] || continue
        if ! bash -n "$f" 2>&1; then
            _fail "Syntax error: $f"
            FAILED=1
        fi
    done <<< "$HAS_SHELL"
    [[ $FAILED -eq 0 ]] && _ok "Shell syntax clean"
fi

# --- 2. shfmt format check (if installed) ---
if [[ -n "$HAS_SHELL" ]] && command -v shfmt > /dev/null 2>&1; then
    _info "shfmt format check..."
    UNFORMATTED=""
    while IFS= read -r f; do
        [[ -f "$f" ]] || continue
        if ! shfmt -i 4 -ci -sr -d "$f" > /dev/null 2>&1; then
            UNFORMATTED="$UNFORMATTED $f"
        fi
    done <<< "$HAS_SHELL"
    if [[ -n "$UNFORMATTED" ]]; then
        _fail "shfmt: unformatted files:$UNFORMATTED"
        _info "Fix with: ./scripts/check.sh --format"
        FAILED=1
    else
        _ok "shfmt format clean"
    fi
fi

# --- 3. shellcheck (if installed) ---
if [[ -n "$HAS_SHELL" ]] && command -v shellcheck > /dev/null 2>&1; then
    _info "shellcheck..."
    while IFS= read -r f; do
        [[ -f "$f" ]] || continue
        if ! shellcheck "$f" 2>&1; then
            FAILED=1
        fi
    done <<< "$HAS_SHELL"
    [[ $FAILED -eq 0 ]] && _ok "shellcheck clean"
fi

# --- 4. Go vet (if staged Go files) ---
if [[ -n "$HAS_GO" ]] && command -v go > /dev/null 2>&1; then
    _info "go vet..."
    if go vet ./cmd/... 2>&1; then
        _ok "go vet clean"
    else
        _fail "go vet failed"
        FAILED=1
    fi
fi

echo ""
if [[ $FAILED -ne 0 ]]; then
    _fail "Pre-commit checks failed. Fix the issues above before committing."
    _info "Run './scripts/check.sh --format' to auto-fix formatting."
    echo ""
    exit 1
fi

_ok "All pre-commit checks passed."
echo ""

```

## /.github/CODEOWNERS

```github/CODEOWNERS path="/.github/CODEOWNERS" 
* @tw93

```

## /.github/FUNDING.yml

```yml path="/.github/FUNDING.yml" 
github: ['tw93']
custom: ['https://cats.tw93.fun?name=Mole']

```

## /.github/ISSUE_TEMPLATE/bug_report.md

---
name: Bug Report
about: Report a bug or issue with Mole
title: '[BUG] '
labels: bug
assignees: ''
---

## Describe the bug

A clear and concise description of what the bug is. We suggest using English for better global understanding.

If you believe the issue may allow unsafe deletion, path validation bypass, privilege boundary bypass, or release/install integrity issues, do not file a public bug report. Report it privately using the contact details in `SECURITY.md`.

## Steps to reproduce

1. Run command: `mo ...`
2. ...
3. See error

## Expected behavior

A clear and concise description of what you expected to happen.

## Debug logs

Please run the command with `--debug` flag and paste the output here:

```bash
mo <command> --debug
# Example: mo clean --debug
```

<details>
<summary>Debug output</summary>

```text
Paste the debug output here
```

</details>

## Environment

Please run `mo update` to ensure you are on the latest version, then paste the output of `mo --version` below:

```text
Paste mo --version output here
```

## Additional context

Add any other context about the problem here, such as screenshots or related issues.


## /.github/ISSUE_TEMPLATE/config.yml

```yml path="/.github/ISSUE_TEMPLATE/config.yml" 
blank_issues_enabled: false
contact_links:
  - name: Private Security Report
    url: mailto:hitw93@gmail.com?subject=Mole%20security%20report
    about: Report a suspected vulnerability privately instead of opening a public issue
  - name: Telegram Community
    url: https://t.me/+9f9gf4ZrFSQ2OWVl
    about: Join our Telegram group for questions and discussions
  - name: GitHub Discussions
    url: https://github.com/tw93/mole/discussions
    about: Ask questions and share ideas with the community

```

## /.github/ISSUE_TEMPLATE/feature_request.md

---
name: Feature Request
about: Suggest an idea for Mole
title: '[FEATURE] '
labels: enhancement
assignees: ''
---

## Feature description

A clear and concise description of the feature you'd like to see. We suggest using English for better global understanding.

## Use case

Describe the problem this feature would solve or the benefit it would provide.

## Proposed solution

How do you envision this feature working?

## Alternatives considered

Have you considered any alternative solutions or workarounds?

## Additional context

Add any other context, mockups, or examples about the feature request here.


## /.github/ISSUE_TEMPLATE/mac_app_bug.yml

```yml path="/.github/ISSUE_TEMPLATE/mac_app_bug.yml" 
name: Mac App Bug
description: Report a bug in the paid Mole Mac app.
title: "[Mac App Bug] "
labels: ["mac-app", "bug"]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for reporting a Mole Mac app bug.

        The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app.

        Do not include license keys, order numbers, private paths, or logs with personal data. For payment, refund, license, or security issues, email hi@mole.fit instead.
  - type: input
    id: app_version
    attributes:
      label: Mole Mac app version
      description: Open Mole, choose About Mole, and paste the version shown there.
      placeholder: "1.0.0"
    validations:
      required: true
  - type: input
    id: macos_version
    attributes:
      label: macOS version
      placeholder: "macOS 15.5"
    validations:
      required: true
  - type: input
    id: mac_model
    attributes:
      label: Mac model
      placeholder: "MacBook Pro M3 Pro, Mac mini M2, etc."
    validations:
      required: false
  - type: dropdown
    id: area
    attributes:
      label: Affected area
      options:
        - Full Disk Access / permissions
        - Clean
        - Uninstall
        - Optimize
        - Analyze
        - Status
        - Activation / license
        - Download / update
        - Other
    validations:
      required: true
  - type: textarea
    id: description
    attributes:
      label: What happened?
      description: Describe the bug clearly. Screenshots are welcome if they do not reveal private data.
      placeholder: "Mole did..."
    validations:
      required: true
  - type: textarea
    id: steps
    attributes:
      label: Steps to reproduce
      placeholder: |
        1. Open Mole
        2. Go to ...
        3. Click ...
        4. See ...
    validations:
      required: true
  - type: textarea
    id: expected
    attributes:
      label: Expected behavior
      placeholder: "I expected Mole to..."
    validations:
      required: true
  - type: textarea
    id: actual
    attributes:
      label: Actual behavior
      placeholder: "Instead, Mole..."
    validations:
      required: true
  - type: textarea
    id: attachments
    attributes:
      label: Screenshots or safe logs
      description: Attach screenshots or paste logs only after removing private paths, license keys, and order details.
      placeholder: "Drag screenshots here, or paste safe logs."
    validations:
      required: false
  - type: checkboxes
    id: checklist
    attributes:
      label: Before submitting
      options:
        - label: I am using the latest Mole Mac app build available from https://mole.fit.
          required: true
        - label: I did not include license keys, order numbers, or private data.
          required: true

```

## /.github/ISSUE_TEMPLATE/mac_app_feature.yml

```yml path="/.github/ISSUE_TEMPLATE/mac_app_feature.yml" 
name: Mac App Feature
description: Suggest an improvement for the paid Mole Mac app.
title: "[Mac App Feature] "
labels: ["mac-app", "enhancement"]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for suggesting an improvement for the Mole Mac app.

        The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app.

        Please keep requests concrete: what workflow is hard today, what you expected Mole to do, and why it matters.
  - type: dropdown
    id: area
    attributes:
      label: Area
      options:
        - Clean
        - Uninstall
        - Optimize
        - Analyze
        - Status
        - Activation / license
        - Onboarding / permissions
        - Website / help
        - Other
    validations:
      required: true
  - type: textarea
    id: problem
    attributes:
      label: What problem would this solve?
      placeholder: "When I use Mole, I want to..."
    validations:
      required: true
  - type: textarea
    id: proposal
    attributes:
      label: What would you like Mole to do?
      placeholder: "A good solution could be..."
    validations:
      required: true
  - type: textarea
    id: current_workaround
    attributes:
      label: Current workaround
      placeholder: "Today I work around it by..."
    validations:
      required: false
  - type: textarea
    id: examples
    attributes:
      label: Examples, screenshots, or references
      description: Add screenshots, links, or examples if they help explain the request.
      placeholder: "Optional context."
    validations:
      required: false
  - type: checkboxes
    id: checklist
    attributes:
      label: Before submitting
      options:
        - label: This request is for the Mole Mac app, not the Mole CLI.
          required: true
        - label: I did not include license keys, order numbers, or private data.
          required: true

```

## /.github/dependabot.yml

```yml path="/.github/dependabot.yml" 
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    labels:
      - "dependencies"
    reviewers:
      - "tw93"
    open-pull-requests-limit: 10

  - package-ecosystem: "gomod"
    directory: "/"
    schedule:
      interval: "weekly"
    labels:
      - "dependencies"
    reviewers:
      - "tw93"
    open-pull-requests-limit: 10

```

## /.github/pull_request_template.md

## Summary

- Describe the change.

## Safety Review

- Does this change affect cleanup, uninstall, optimize, installer, remove, analyze delete, update, or install behavior?
- Does this change affect path validation, protected directories, symlink handling, sudo boundaries, or release/install integrity?
- If yes, describe the new boundary or risk change clearly.

## Tests

- List the automated tests you ran.
- List any manual checks for high-risk paths or destructive flows.

## Safety-related changes

- None.


## /.github/workflows/bundle_audit.yml

```yml path="/.github/workflows/bundle_audit.yml" 
name: Bundle Drift Audit

# Monthly check that mole's protected bundle lists still cover every system
# app shipped in the latest macOS image. Opens an issue if /System/Applications
# contains a bundle ID not matched by the protection patterns.

on:
  schedule:
    - cron: '0 12 1 * *' # 1st of each month, 12:00 UTC
  workflow_dispatch:

permissions:
  contents: read
  issues: write

concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: true

jobs:
  audit:
    name: Bundle drift
    runs-on: macos-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Run drift audit
        id: drift
        continue-on-error: true
        run: |
          # pipefail so the audit's exit 2 (drift found) propagates through
          # tee. Without it the step's status is tee's 0, outcome stays
          # 'success', and the "Open issue on drift" gate below never fires.
          set -o pipefail
          ./scripts/audit_bundle_drift.sh | tee /tmp/audit.txt

      - name: Open issue on drift
        if: ${{ steps.drift.outcome != 'success' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          MACOS=$(sw_vers -productVersion)
          TITLE="Bundle drift on macOS $MACOS"
          # Avoid duplicate issues for the same macOS version.
          EXISTING=$(gh issue list --label bundle-drift --search "$TITLE in:title" --json number --jq '.[0].number' || true)
          if [[ -n "$EXISTING" ]]; then
            echo "Updating existing issue #$EXISTING"
            gh issue comment "$EXISTING" --body "$(cat /tmp/audit.txt)"
          else
            gh issue create \
              --title "$TITLE" \
              --label bundle-drift \
              --body "$(cat /tmp/audit.txt)"
          fi

```

## /.github/workflows/check.yml

```yml path="/.github/workflows/check.yml" 
name: Check

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  format:
    name: Format
    runs-on: macos-latest
    timeout-minutes: 20

    steps:
      - name: Checkout
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Cache Homebrew
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: |
            ~/Library/Caches/Homebrew
            /usr/local/Cellar/shfmt
            /usr/local/Cellar/shellcheck
            /usr/local/Cellar/golangci-lint
          key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }}
          restore-keys: |
            ${{ runner.os }}-brew-quality-v2-

      - name: Install tools
        run: brew install shfmt shellcheck

      - name: Set up Go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Install goimports
        run: go install golang.org/x/tools/cmd/goimports@latest

      - name: Check shell formatting (shfmt -d)
        run: |
          SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \
            -not -path "./.git/*" \
            -not -path "*/node_modules/*" \
            -not -path "*/tests/tmp-*/*" \
            -not -path "*/.*")
          if ! echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -d; then
            echo "::error::Shell files are not formatted. Run './scripts/check.sh --format' locally and commit."
            exit 1
          fi

      - name: Check Go formatting (goimports -l)
        run: |
          export PATH=$(go env GOPATH)/bin:$PATH
          UNFORMATTED=$(goimports -l -local github.com/tw93/mole ./cmd ./internal)
          if [[ -n "$UNFORMATTED" ]]; then
            echo "::error::Go files are not formatted:"
            echo "$UNFORMATTED"
            echo "Run './scripts/check.sh --format' locally and commit."
            exit 1
          fi

  quality:
    name: Check
    runs-on: macos-latest
    timeout-minutes: 20
    needs: format
    permissions:
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Cache Homebrew
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: |
            ~/Library/Caches/Homebrew
            /usr/local/Cellar/shfmt
            /usr/local/Cellar/shellcheck
            /usr/local/Cellar/golangci-lint
          key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }}
          restore-keys: |
            ${{ runner.os }}-brew-quality-v2-

      - name: Install tools
        run: brew install shfmt shellcheck

      - name: Set up Go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Install golangci-lint
        run: |
          go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
          echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"

      - name: Run check script
        run: ./scripts/check.sh --no-format

```

## /.github/workflows/codeql.yml

```yml path="/.github/workflows/codeql.yml" 
name: CodeQL

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main, dev]
  schedule:
    - cron: '17 3 * * 1'

permissions:
  contents: read
  security-events: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        include:
          - language: go
            build-mode: manual
          - language: actions
            build-mode: none

    steps:
      - name: Checkout
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Set up Go
        if: matrix.language == 'go'
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Initialize CodeQL
        uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
        with:
          languages: ${{ matrix.language }}
          build-mode: ${{ matrix.build-mode }}
          queries: security-extended

      - name: Build for CodeQL
        if: matrix.build-mode == 'manual'
        run: make build

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
        with:
          category: "/language:${{ matrix.language }}"

```

## /.github/workflows/release.yml

```yml path="/.github/workflows/release.yml" 
name: Release

on:
  push:
    tags:
      - 'V*'

permissions:
  contents: read

jobs:
  build:
    name: Build
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    strategy:
      matrix:
        include:
          - os: macos-latest
            target: release-amd64
            artifact_name: binaries-amd64
          - os: macos-latest
            target: release-arm64
            artifact_name: binaries-arm64
    steps:
      - name: Checkout code
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Set up Go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Build Binaries
        run: |
          make ${{ matrix.target }}
          ls -l bin/
          ./scripts/check_release_minos.sh

      - name: Package binaries for Homebrew
        run: |
          cd bin
          # Package binaries into tar.gz for Homebrew resource
          if [[ "${{ matrix.target }}" == "release-arm64" ]]; then
            tar -czf binaries-darwin-arm64.tar.gz analyze-darwin-arm64 status-darwin-arm64
            ls -lh binaries-darwin-arm64.tar.gz
          else
            tar -czf binaries-darwin-amd64.tar.gz analyze-darwin-amd64 status-darwin-amd64
            ls -lh binaries-darwin-amd64.tar.gz
          fi

      - name: Upload artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: ${{ matrix.artifact_name }}
          path: bin/*-darwin-*
          retention-days: 1

  release:
    name: Publish Release
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: write
      attestations: write
      id-token: write
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: bin
          pattern: binaries-*
          merge-multiple: true

      - name: Display structure of downloaded files
        run: ls -R bin/

      - name: Generate release checksums
        run: |
          cd bin
          mapfile -t release_files < <(find . -maxdepth 1 -type f -printf '%P\n' | sort)
          if [[ ${#release_files[@]} -eq 0 ]]; then
            echo "No release assets found"
            exit 1
          fi
          sha256sum "${release_files[@]}" > SHA256SUMS
          cat SHA256SUMS

      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-path: |
            bin/analyze-darwin-*
            bin/status-darwin-*
            bin/binaries-darwin-*.tar.gz
            bin/SHA256SUMS

      - name: Create Release
        uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
        if: startsWith(github.ref, 'refs/tags/')
        with:
          name: ${{ github.ref_name }}
          files: bin/*
          generate_release_notes: false
          draft: false
          prerelease: false

  update-personal-tap:
    name: Update Personal Tap
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: release
    steps:
      - name: Checkout code
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Extract version from tag
        id: tag_version
        run: |
          TAG=${GITHUB_REF#refs/tags/}
          VERSION=${TAG#V}
          echo "tag=$TAG" >> $GITHUB_OUTPUT
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "Releasing version: $VERSION (tag: $TAG)"

      - name: Update Homebrew formula (Personal Tap)
        env:
          PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
          TAG: ${{ steps.tag_version.outputs.tag }}
          VERSION: ${{ steps.tag_version.outputs.version }}
        run: |
          set -euo pipefail

          curl -fsSL -o /tmp/SHA256SUMS "https://github.com/tw93/Mole/releases/download/${TAG}/SHA256SUMS"
          ARM_SHA=$(awk '$2 == "binaries-darwin-arm64.tar.gz" { print $1 }' /tmp/SHA256SUMS)
          AMD_SHA=$(awk '$2 == "binaries-darwin-amd64.tar.gz" { print $1 }' /tmp/SHA256SUMS)
          SOURCE_SHA=$(curl -fsSL "https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" | sha256sum | awk '{print $1}')

          if [[ -z "$ARM_SHA" || -z "$AMD_SHA" || -z "$SOURCE_SHA" ]]; then
            echo "Failed to resolve release checksums"
            exit 1
          fi

          git clone "https://x-access-token:${PAT_TOKEN}@github.com/tw93/homebrew-tap.git" /tmp/homebrew-tap
          ./scripts/update_homebrew_tap_formula.sh \
            --formula /tmp/homebrew-tap/Formula/mole.rb \
            --tag "${TAG}" \
            --source-sha "${SOURCE_SHA}" \
            --arm-sha "${ARM_SHA}" \
            --amd-sha "${AMD_SHA}"

          cd /tmp/homebrew-tap
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

          if git diff --quiet -- Formula/mole.rb; then
            echo "No Homebrew formula changes to push"
            exit 0
          fi

          git add Formula/mole.rb
          git commit -m "mole ${VERSION}" -m "Automated release via GitHub Actions"
          git push origin HEAD:main

  update-homebrew-core:
    name: Update Homebrew Core
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: release
    steps:
      - name: Checkout code
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Extract version from tag
        id: tag_version
        run: |
          TAG=${GITHUB_REF#refs/tags/}
          VERSION=${TAG#V}
          echo "tag=$TAG" >> $GITHUB_OUTPUT
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "Releasing version: $VERSION (tag: $TAG)"

      - name: Update Homebrew formula (Official Core)
        id: official_core
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
          TAG: ${{ steps.tag_version.outputs.tag }}
          VERSION: ${{ steps.tag_version.outputs.version }}
        # mislav/bump-homebrew-formula-action@v4.1 fatals on the GitHub tarball
        # HEAD's HTTP 303 redirect (upstream issue #340). Until that lands, do
        # the bump by hand: sync our fork, edit Formula/m/mole.rb, push, and
        # open the PR via gh. Keeps the rest of the release flow on the same
        # rails as the personal-tap step above.
        run: |
          set -euo pipefail

          SOURCE_SHA=$(curl -fsSL "https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" | sha256sum | awk '{print $1}')
          if [[ -z "$SOURCE_SHA" ]]; then
            echo "Failed to resolve source tarball sha256"
            exit 1
          fi

          export GIT_TERMINAL_PROMPT=0
          REMOTE="https://x-access-token:${GH_TOKEN}@github.com/tw93/homebrew-core.git"
          UPSTREAM="https://github.com/Homebrew/homebrew-core.git"
          WORK_DIR=$(mktemp -d)
          BRANCH="mole-${VERSION}"

          git clone --depth=1 "$REMOTE" "$WORK_DIR"
          cd "$WORK_DIR"
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git remote add upstream "$UPSTREAM"
          git fetch upstream main --depth=1
          git reset --hard upstream/main
          git checkout -B "$BRANCH"

          # Only rewrite url and the source sha256 right after it. The bottle
          # block stays untouched: Homebrew's check-bottle-block CI hook fails
          # PRs that modify it because BrewTestBot rebuilds bottles after the
          # url change lands.
          awk -v new_url="https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" \
              -v new_sha="$SOURCE_SHA" '
            BEGIN { url_done = 0; sha_done = 0 }
            !url_done && /^  url "/ { print "  url \"" new_url "\""; url_done = 1; next }
            url_done && !sha_done && /^  sha256 "/ { print "  sha256 \"" new_sha "\""; sha_done = 1; next }
            { print }
          ' Formula/m/mole.rb > Formula/m/mole.rb.new
          mv Formula/m/mole.rb.new Formula/m/mole.rb

          if git diff --quiet -- Formula/m/mole.rb; then
            echo "Formula already on ${VERSION}, nothing to push"
            echo "core_status=published" >> "$GITHUB_OUTPUT"
            echo "pr_url=https://formulae.brew.sh/formula/mole" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          git add Formula/m/mole.rb
          git commit -m "mole ${VERSION}" -m "Automated release via GitHub Actions"
          git push --force-with-lease origin "$BRANCH"

          # Homebrew closes PRs that omit its current template. Keep the full
          # checklist, with unperformed local checks left unticked, so the PR
          # remains honest and the template checker can validate it.
          read -r -d '' PR_BODY <<'PR_BODY_EOF' || true
          Release notes: https://github.com/tw93/Mole/releases/tag/__TAG__

          -----

          <!-- Do not tick a checkbox if you haven’t performed its action. Honesty is indispensable for a smooth review process. -->
          <!-- Use [x] to mark item done before creation, or just click the checkboxes with device pointer after creation -->
          <!-- In the following questions `<formula>` is the name of the formula you're editing. -->

          - [ ] Have you followed the [guidelines for contributing](https://github.com/Homebrew/homebrew-core/blob/HEAD/CONTRIBUTING.md)?
          - [x] Have you ensured that your commits follow the [commit style guide](https://docs.brew.sh/Formula-Cookbook#commit)?
          - [ ] Have you checked that there aren't other open [pull requests](https://github.com/Homebrew/homebrew-core/pulls) for the same formula update/change?
          - [ ] Have you built your formula locally with `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --build-from-source <formula>`?
          - [ ] Is your test running fine `brew test <formula>`?
          - [ ] Does your build pass `brew audit --strict <formula>` (after doing `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --build-from-source <formula>`)? If this is a new formula, does it pass `brew audit --new <formula>`?

          The formula URL and checksum were generated by the Mole release workflow. Homebrew Core CI performs the platform builds, tests, and audit after the PR opens.

          -----

          - [ ] AI was used to generate or assist with generating this PR. *Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes*.

          -----
          PR_BODY_EOF
          PR_BODY="${PR_BODY//__TAG__/$TAG}"

          PR_NUMBER=$(gh api \
            "repos/Homebrew/homebrew-core/pulls?state=all&head=tw93:${BRANCH}" \
            --jq '.[0].number // empty')
          if [[ -z "$PR_NUMBER" ]]; then
            PR_NUMBER=$(gh api --method POST repos/Homebrew/homebrew-core/pulls \
              -f base=main \
              -f head="tw93:${BRANCH}" \
              -f title="mole ${VERSION}" \
              --raw-field body="$PR_BODY" \
              --jq '.number')
          else
            # Editing a template-closed PR triggers Homebrew's checker to
            # reopen it automatically when the closer was its own bot.
            gh api --method PATCH "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" \
              --raw-field body="$PR_BODY" > /dev/null
          fi

          PR_STATE=""
          for _ in {1..12}; do
            PR_STATE=$(gh api "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" --jq '.state')
            [[ "$PR_STATE" == "open" ]] && break
            sleep 5
          done
          if [[ "$PR_STATE" != "open" ]]; then
            echo "Homebrew Core PR #${PR_NUMBER} is not open after template validation" >&2
            exit 1
          fi

          echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
          echo "pr_url=https://github.com/Homebrew/homebrew-core/pull/${PR_NUMBER}" >> "$GITHUB_OUTPUT"
          echo "core_status=pr-open" >> "$GITHUB_OUTPUT"

      - name: Verify formula updates
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
        run: |
          CORE_STATUS="${{ steps.official_core.outputs.core_status }}"
          PR_NUMBER="${{ steps.official_core.outputs.pr_number }}"
          if [[ "$CORE_STATUS" == "pr-open" ]]; then
            PR_STATE=$(gh api "repos/Homebrew/homebrew-core/pulls/${PR_NUMBER}" --jq '.state')
            if [[ "$PR_STATE" != "open" ]]; then
              echo "Homebrew Core PR #${PR_NUMBER} is not open" >&2
              exit 1
            fi
          elif [[ "$CORE_STATUS" != "published" ]]; then
            echo "Unknown Homebrew Core publication state: ${CORE_STATUS:-empty}" >&2
            exit 1
          fi

          echo "✓ Homebrew Core formula update verified"
          echo "  Version: ${{ steps.tag_version.outputs.version }}"
          echo "  Tag: ${{ steps.tag_version.outputs.tag }}"
          echo "  Official core: ${{ steps.official_core.outputs.pr_url }}"

```

## /.github/workflows/test.yml

```yml path="/.github/workflows/test.yml" 
name: Validation

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main, dev]

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  tests:
    name: Unit & Integration Tests
    runs-on: macos-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Install tools
        run: brew install bats-core shellcheck coreutils parallel

      - name: Set up Go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Run test script
        env:
          MOLE_PERF_BYTES_TO_HUMAN_LIMIT_MS: "6000"
          MOLE_PERF_GET_FILE_SIZE_LIMIT_MS: "3000"
          MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000"
          MOLE_TEST_JOBS: "6"
          BATS_FORMATTER: tap
          LANG: en_US.UTF-8
          LC_ALL: en_US.UTF-8
        run: ./scripts/test.sh

  compatibility:
    name: macOS
    strategy:
      matrix:
        os: [macos-14, macos-15]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Install bats
        run: brew install bats-core coreutils

      - name: Set up Go
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
        with:
          go-version-file: go.mod

      - name: Smoke load on ${{ matrix.os }}
        run: |
          echo "Testing on ${{ matrix.os }}..."
          bash -n mole
          source lib/core/common.sh
          echo "✓ Successfully loaded on ${{ matrix.os }}"

      - name: Run core bats subset
        env:
          MOLE_TEST_NO_AUTH: "1"
          MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000"
          BATS_FORMATTER: tap
          LANG: en_US.UTF-8
          LC_ALL: en_US.UTF-8
        run: |
          bats tests/core_safe_functions.bats \
               tests/core_common.bats \
               tests/core_timeout.bats \
               tests/regression.bats \
               tests/cli.bats

  security:
    name: Security Checks
    runs-on: macos-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          fetch-depth: 0

      - name: Install tools
        run: brew install bats-core coreutils

      - name: Check for unsafe rm usage
        run: |
          echo "Checking for unsafe rm patterns..."
          # Filters in order:
          #   safe_remove / validate_path: known safe wrapper helpers in file_ops.sh
          #   # SAFE: explicit annotation that a direct rm is gated by narrow checks
          #   ^path:line:[ws]#: pure comment lines that mention rm -rf in docs
          #   echo : help text or messages that print rm -rf as documentation
          if grep -rn "rm -rf" lib/ bin/ install.sh mole 2>/dev/null | grep -v -E "safe_remove|validate_path|# SAFE|^[^:]+:[0-9]+:[[:space:]]*#|echo "; then
            echo "✗ Unsafe rm -rf usage found (annotate gated calls with '# SAFE: <reason>' or route through safe_remove)"
            exit 1
          fi
          echo "✓ No unsafe rm usage found"

      - name: Verify app protection
        run: |
          echo "Verifying critical file protection..."
          bash -c '
          source lib/core/common.sh
          if should_protect_from_uninstall "com.apple.Safari"; then
            echo "✓ Safari is protected"
          else
            echo "✗ Safari protection failed"
            exit 1
          fi
          '

      - name: Scan for secrets (gitleaks)
        uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_CONFIG: .gitleaks.toml

      - name: Run high-risk path regression tests
        env:
          MOLE_TEST_NO_AUTH: "1"
          BATS_FORMATTER: tap
          LANG: en_US.UTF-8
          LC_ALL: en_US.UTF-8
        run: bats tests/core_safe_functions.bats tests/purge.bats tests/installer.bats

```

## /.github/workflows/update-contributors.yml

```yml path="/.github/workflows/update-contributors.yml" 
name: Update Contributors

on:
  push:
    branches: [main, dev]
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * 0" # Every Sunday at midnight UTC

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  update-contributors:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          fetch-depth: 0

      - name: Generate contributors SVG
        uses: tw93/contributors-list@3722c5cb0d662c5e6dbffe78448002c461aab3b9 # master pinned 2026-07
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          svgPath: CONTRIBUTORS.svg
          svgWidth: 1000
          avatarSize: 72
          avatarMargin: 45
          userNameHeight: 20
          noFetch: false
          noCommit: true
          truncate: 0
          includeBots: false
          excludeUsers: "github-actions web-flow dependabot claude"
          itemTemplate: |
            <g transform="translate({{ x }}, {{ y }})">
              <defs>
                <clipPath id="cp-{{ login }}">
                  <circle cx="36" cy="36" r="36" />
                </clipPath>
              </defs>
              <a xlink:href="{{{ url }}}" href="{{{ url }}}" class="contributor-link" target="_blank" rel="nofollow sponsored" title="{{{ name }}}">
                <image width="72" height="72" xlink:href="{{{ avatar }}}" href="{{{ avatar }}}" clip-path="url(#cp-{{ login }})" />
                <text x="36" y="86" text-anchor="middle" alignment-baseline="middle" font-size="10" fill="#666" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif">{{{ name }}}</text>
              </a>
            </g>

      - name: Commit & Push
        uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
        with:
          commit_message: "chore: update contributors [skip ci]"
          file_pattern: CONTRIBUTORS.svg
          commit_user_name: github-actions[bot]
          commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com
          commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

```

## /.gitignore

```gitignore path="/.gitignore" 
# macOS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Editor files
*~
*.swp
*.swo
.idea/
.vscode/*.code-workspace
.vscode/settings.json

# Logs
*.log
logs/

# Temporary files
tmp/
temp/
*.tmp
*.temp
*.dmg
tests/tmp-*

# Cache
.cache/
*.cache
.gocache/
.gomod/

# Backup files
*.bak
*.backup

# System files
*.pid
*.lock

# Local AI Assistant Instructions
# CLAUDE.md is a symlink to AGENTS.md (shared source of truth, tracked).
# Personal overrides go in *.local.md, which stay gitignored.
CLAUDE.local.md
AGENTS.local.md
AGENTS.override.md
.claude/*
!.claude/agents/
!.claude/hooks/
!.claude/skills/
!.claude/settings.json
.claude/settings.local.json
.agents/settings.local.json
.gemini/
.kiro/
GEMINI.md
ANTIGRAVITY.md
WARP.md
journal/
.cursorrules

# Go build artifacts (development)
cmd/analyze/analyze
cmd/status/status
/status
/analyze
mole-analyze
# Go binaries
bin/analyze-go
bin/status-go
bin/analyze-darwin-*
bin/status-darwin-*

# Test artifacts
tests/tmp-*/
tests/*.tmp
tests/*.log

# Go test coverage files
*.out
coverage.out
coverage.html

session.json
run_tests.ps1
mole_guidelines.md

```

## /.gitleaks.toml

```toml path="/.gitleaks.toml" 
# Mole gitleaks configuration
# Replaces grep-based secret scan in .github/workflows/test.yml
# Uses gitleaks default ruleset (AWS / GitHub / Slack / private keys / etc).

[extend]
useDefault = true

[allowlist]
description = "Mole-specific allowlist"

# Test fixtures and mock binaries never hold real secrets.
paths = [
    '''tests/.*''',
    '''.*\.bats{{contextString}}#39;'',
    # Bundle ID lists in app protection use "keychain*", "security*" etc. as
    # wildcard patterns, not secrets.
    '''lib/core/app_protection\.sh''',
    '''lib/core/app_protection_data\.sh''',
]

# Mole talks about "password" / "Touch ID" in UI prompts and sudo helpers.
# These are user-visible strings, not credentials.
regexes = [
    '''password( and| or| dialog| prompt| input|,)''',
    '''Touch ID or password''',
    '''_request_password''',
]

```

## /.golangci.yml

```yml path="/.golangci.yml" 
# golangci-lint configuration for Mole
# https://golangci-lint.run/usage/configuration/

version: "2"

run:
  timeout: 5m
  # Only lint Go code in cmd directory
  modules-download-mode: readonly

linters:
  enable:
    # Default linters
    - govet
    - staticcheck
    - errcheck
    - ineffassign
    - unused
    # Additional linters
    - modernize
    - revive
    - unparam
    # Security scanner. mole deletes files and shells out, so gosec is
    # high-value here even with some false positives.
    - gosec

  settings:
    govet:
      enable-all: true
      disable:
        - shadow
        - fieldalignment # struct field alignment optimization is noisy
    errcheck:
      exclude-functions:
        - (io.Closer).Close
        - (*os/exec.Cmd).Run
        - (*os/exec.Cmd).Start
    revive:
      rules:
        - name: use-waitgroup-go
    staticcheck:
      checks: ["all", "-QF1003", "-SA9003"]
    gosec:
      # mole is a system tool: scanning user-supplied paths and shelling out
      # to mdls/du/osascript are core features, not vulnerabilities. We rely
      # on validatePath (covered by FuzzValidatePath) for path safety and
      # on osascript escaping in delete.go for command construction.
      excludes:
        - G104 # already covered by errcheck
        - G115 # integer overflow on stat.{Bavail,Bsize,Dev,Ino} and FileInfo.Size; macOS-safe
        - G204 # subprocess with variable; required for du/mdls/osascript/mdfind
        - G301 # mkdir perms; we use 0o755 intentionally
        - G302 # chmod perms; same
        - G304 # file inclusion via variable; we scan user paths by design
        - G306 # WriteFile perms; we use 0o644 intentionally
        - G702 # command injection taint; mdfind/du take user paths by design
        - G703 # path traversal taint; scanning user paths is mole's purpose

  exclusions:
    rules:
      # Ignore certain patterns in test files
      - path: _test\.go
        linters:
          - errcheck
          - gosec
          - unparam
      # Ignore errors from os.Remove in cleanup code
      - text: "os.Remove"
        linters:
          - errcheck

```

## /.shellcheckrc

```shellcheckrc path="/.shellcheckrc" 
# Mole project shellcheck configuration

disable=SC2155  # Declare and assign separately
disable=SC2034  # Unused variables
disable=SC2059  # Don't use variables in printf format
disable=SC1091  # Not following sourced files
disable=SC2038  # Use find -print0 | xargs -0 pattern

```

## /AGENTS.md

# Mole Agent Guide

This file is the shared source of truth for any AI agent working on this repo (Claude Code, Codex, etc.). `CLAUDE.md` is a symlink to this file. Put machine-specific or personal overrides in `AGENTS.local.md` / `CLAUDE.local.md`; both are gitignored.

## Project

Mole is a macOS system cleanup and optimization tool with shell and Go components. It performs file cleanup, app protection checks, and maintenance tasks, so safety rules matter more than speed.

## Product Direction

Mole is a terminal-first macOS maintenance toolkit. Its core job is to help power users inspect reclaimable space, remove known-safe leftovers, uninstall apps safely, run bounded maintenance, and check health from a CLI, script, or compact TUI. It is not a general Mac control center, package manager, background monitor, or GUI feature mirror.

### What Mole Should Do

- Make cleanup and uninstall actions boring, reviewable, logged, protected by path/app rules, and dry-run capable.
- Prefer reversible user-facing removals through Trash where the command surface expects recoverability.
- Keep `clean`, `uninstall`, `purge`, and `installer` focused on reclaimable files, app leftovers, rebuildable caches, installer artifacts, and exact known cleanup targets.
- Keep `analyze` as a disk explorer and ad hoc cleanup surface. Optimize first paint, navigation, sorting, filtering, and safe deletion before adding dashboard-style features.
- Keep `status` as a compact read-only health dashboard plus stable JSON/NDJSON automation output. It may surface actionable signals, but should not become an iStat clone, alerting daemon, or configurable metrics workbench.
- Keep `optimize` focused on explicit, bounded maintenance tasks that can be explained before execution and tested without real authorization prompts.
- Keep command UX dense and terminal-native: short labels, stable alignment, predictable shortcuts, one-screen summaries, then optional drill-down.
- Keep Mole Mac references as a cross-link or support path. The CLI and Mac app can share product values without requiring feature parity.

### What Mole Should Not Do

- Do not add broad system modification, privacy reset, package management, app bundle patching, or device-management features just because they are technically possible.
- Do not remove or rewrite third-party app bundle contents, signed resources, user documents, credentials, sessions, active databases, or active developer-tool state.
- Do not add background agents, persistent monitoring, notifications, schedulers, menu bar behavior, or GUI-like state unless explicitly requested and justified as CLI scope.
- Do not broaden leftover matching from exact app or bundle evidence into vendor-wide, TeamID-prefix, generic-name, or fallback wildcard deletion.
- Do not turn `status` into a noisy dashboard. Extra rows, live alerts, and tuning controls need a common user action, not just an available metric.
- Do not add prompts, preferences, or output modes to solve every edge case. Prefer quieter defaults, preview/read-only guidance, or declining unsupported operations.
- Do not treat Mole Mac features as required CLI gaps. The CLI should stay narrower, scriptable, and safety-first when parity would add complexity or ambiguity.

### Product Decision Filter

Before accepting a new feature, answer these questions in the PR, issue, or review notes when the fit is not obvious:

1. Does it clearly belong to cleanup, uninstall, analyze, optimize, status, purge, installer, update, completion, touchid, install, or remove?
2. Is it safe by default, previewable where destructive, testable without real auth, and explainable in one terminal screen?
3. Can the user verify what will change before Mole changes it?
4. Is the target data locally rebuildable, disposable, or backed by exact app/bundle evidence?
5. Would this be better as Mole Mac UI, documentation, a warning, or an explicit "not supported" answer?

If the answer is no or unclear, decline the feature, narrow it, or park it until the product value beats the added surface area.

## Repository Map

- `lib/core/` - shared shell safety, UI, file operations, operation logs, app protection logic, and centralized timeout constants (`timeouts.sh`).
- `lib/core/app_protection_data.sh` - readonly bundle ID and pattern arrays consumed by `app_protection.sh`. Data only, no logic.
- `cmd/analyze/` - Go disk-analysis TUI. `main.go` is bootstrap only; `model.go` holds types and accessor methods; `update.go` holds the Bubble Tea Update chain.
- `tests/fuzz_corpus/` holds property-test corpora consumed by `path_validation_fuzz.bats`.
- `scripts/` - check, test, build, and release helpers. `audit_bundle_drift.sh` backs the monthly bundle audit; per-PR perf is covered by `tests/core_performance.bats`.
- `docs/SECURITY_DESIGN.md` - design doc for the path validation / app protection / # SAFE annotation contract.
- `SECURITY_AUDIT.md` - security review notes.

## Commands

```bash
./scripts/check.sh --format
MOLE_TEST_NO_AUTH=1 ./scripts/test.sh
MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats
MOLE_DRY_RUN=1 ./mole clean
MOLE_TEST_NO_AUTH=1 ./mole clean --dry-run
MOLE_TEST_NO_AUTH=1 ./mole purge --dry-run
MOLE_TEST_NO_AUTH=1 ./mole installer --dry-run
find bin lib -name '*.sh' -print0 | xargs -0 -n1 bash -n
make build
go test ./...
```

Public docs and examples should prefer the installed `mo` command. Use `./mole` in this repository when verifying source-tree behavior before installation. `analyze` and `analyse` are both accepted command spellings.

## Critical Safety Rules

- Never use raw `rm -rf` or `find -delete`; use safe deletion helpers.
- Use `mole_delete` from `lib/core/file_ops.sh` for removals so Trash routing, operation logs, dry-run behavior, and path protection stay consistent.
- Never modify protected paths such as `/System`, `/Library/Apple`, or `com.apple.*`.
- Route user-facing cleanup through Trash where the project expects recoverability, especially for analyze-driven ad hoc cleanup.
- Never let verification block on sudo, AppleScript, or macOS authorization prompts unless the task explicitly targets auth behavior.
- Use `MOLE_DRY_RUN=1` before destructive cleanup flows.
- Use `MOLE_TEST_NO_AUTH=1` for tests, manual repro, and verification unless real auth behavior is being tested.
- Any new direct use of `sudo`, `osascript`, or `launchctl` must have a `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` guard or be fully mocked in tests.
- Do not change ESC timeout behavior in `lib/core/ui.sh` unless explicitly requested.
- Preserve operation logging to the project log path unless the user explicitly asks to change `MO_NO_OPLOG` behavior.
- **AI-generated PRs touching destructive sinks need line-by-line review.** Any PR touching `find_app_files`, `mole_delete`, `remove_file_list`, Group Container / `~/Library/Containers` traversal, `TeamID.*.prefix*` style wildcards, or any `find` recursion that ends in deletion must be audited per branch (fallback branches often regress to broad globs even when the primary branch looks correct), per protected-path coverage (does `should_protect_path` already include the new entry point?), and per user-confirmation step (does the PR silently skip an existing prompt?). When the PR is plausibly AI-generated, raise the bar: ask the contributor to narrow matchers to the exact bundle ID or app path before merge; do not approve "this looks fine." PR #874 (Group Container + diagnostic discovery) and PR #875 (interactive file selector) were merged and then reverted (`6ea1987`, `b4e9205`) precisely because a TeamID-prefix wildcard in a fallback branch matched far more than intended. Same shape, same revert risk.

## Working Rules

- Use helpers from `lib/core/file_ops.sh` for deletion logic.
- Check `should_protect_path()` before adding cleanup behavior.
- Check app protection helpers before adding app cache, uninstall, or leftover cleanup behavior.
- Keep AI-tool cache cleanup conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, and similar developer tools may have active versions, config, credentials, or session state that must not be removed accidentally.
- Do not clean tiny macOS UI state just because it is rebuildable. Wallpaper previews, preference thumbnails, and similar cover/state caches can create visible blank or cloud-download UI while reclaiming only a few MB; keep them unless there is strong user value and a regression test.
- Homebrew cleanup must be preview-first. Show the exact `brew autoremove` candidates before removal, preserve dry-run behavior, and keep tests on mocked `brew`; do not let a cleanup path execute real package-manager removals in verification.
- Sudo gates must not treat typed password characters as "skip". Only an explicit skip key should skip privileged cleanup; direct typed input must proceed into the real sudo prompt and have a regression test.
- Long cleanup scans need both an overall wall-clock budget and inner-loop checkpoints. If a project/artifact scan times out, degrade to partial or skipped-slow-scan output instead of appearing hung.
- System-service orphan scans must parse plist `Program` / `ProgramArguments` values as absolute paths only. Use non-interactive sudo for unreadable root-owned plists when needed, reject PlistBuddy error text as data, and keep CI tests on `/Library/LaunchDaemons` rather than relying on `/Library/PrivilegedHelperTools`.
- Uninstall leftover expansion must stay exact and boring: bundle ID or app-name variants only, reject generic/common words, keep short-name floors, skip broad locations like `Preferences/ByHost`, and only remove helper remnants after the parent app is confirmed gone and protected-path checks pass.
- Any new uninstall teardown path (launch services, login items, cask zap, helper bootout) must route through the shared-bundle-id sibling guard, covering `/Volumes` copies, inverse-name, and shared-identity variants, with a bats regression per variant. Five consecutive fixes converged on this invariant in 2026-06/07; do not add a teardown branch that bypasses the guard.
- Preference repair and optimize cleanup must skip protected and whitelisted plists before attempting removal.
- Keep shell code formatted with `./scripts/check.sh --format`.
- Prefer targeted Bats tests during development; run the full suite before committing.
- Do not add AI attribution trailers to commits.
- `start_section` / `end_section` / `note_activity` have three intentionally different implementations in `lib/core/base.sh`, `bin/clean.sh`, and `bin/purge.sh`. Source order decides which one wins, and the wording, color, and dry-run export semantics differ on purpose. Read the cross-reference comment in `lib/core/base.sh` before changing any of them.
- **Test-orphan pattern: grep the whole repo including top-level entry scripts before declaring a function dead.** Mole has a recurring shape where a helper is defined in `lib/core/base.sh` (or similar core lib), has full bats coverage in `tests/`, and is referenced by zero production callers. Past instances, all since removed from the repo: `is_sip_enabled`, `is_darwin_ge`, `get_invoking_user`, `get_brand_name`, `get_mole_temp_root`, `scan_external_volumes`, `clean_dev_editors`, `perform_updates`, `format_brew_update_label`, `brew_has_outdated`. A "zero callers" verdict requires three checks: (1) grep across `lib`, `bin`, `cmd`, `scripts`, `tests`, AND the top-level entry (`mole` shim, install/uninstall scripts), not just core lib dirs; (2) check for string-built call sites (`eval`, `declare -f`, `compgen`); (3) re-grep after removal to confirm nothing was hand-wired. When deleting a write-only helper, also trace every variable it wrote and every config it read; the entire data path may be orphaned. Sub-agent "dead code" reports are starting points, not verdicts.

## Hotspot Ownership

These files are intentionally large. Do not start by splitting them. Keep edits narrow, preserve local safety boundaries, and run the listed tests when touching each area.

- `lib/clean/user.sh` owns user-level cleanup flows, browser caches, cloud/app support cleanup, device firmware, and Apple Silicon caches. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_user_core.bats tests/clean_app_caches.bats tests/clean_cached_device_firmware.bats` when touching this area, or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh` if behavior crosses sections.
- `lib/core/app_protection.sh` owns uninstall/data/path protection policy and bundle matching; `lib/core/app_protection_data.sh` owns the protected app category lists. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall_safety.bats tests/uninstall_naming_variants.bats tests/bundle_resolver.bats`.
- `lib/clean/project.sh` owns purge discovery, project artifact filtering, purge menus, and purge config. Run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`.
- `bin/uninstall.sh` owns uninstall command orchestration, app inventory, metadata refresh, and list/json output. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_scan_bash32.bats`.
- `lib/uninstall/batch.sh` owns batch uninstall execution, the shared-bundle-id sibling guard, launch service and login item teardown, and brew cask removal routing. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/brew_uninstall.bats tests/uninstall_remove_file_list.bats`.
- `lib/clean/dev.sh` owns developer-tool cleanup, language/toolchain caches, AI agent caches, and Codex runtime handling. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_dev_caches.bats tests/dev_extended.bats`.
- `lib/optimize/tasks.sh` owns optimize task registration and system maintenance actions. Run `MOLE_TEST_NO_AUTH=1 bats tests/optimize.bats tests/optimize_db.bats`.
- `bin/clean.sh` owns clean command orchestration, section output, and safe cleanup execution. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats tests/clean_apps.bats tests/cli.bats`.
- `cmd/analyze/update.go` owns the Bubble Tea `Update` chain and message handlers (Init, scanCmd, updateKey, goBack, switchToOverviewMode, enterSelectedDir). This is the largest file in `cmd/analyze/` and the natural landing spot for new key bindings, message types, or navigation behavior. Run `go test ./cmd/analyze`. `cmd/analyze/main.go` is bootstrap only (flag parsing, `main()`, helpers); `cmd/analyze/model.go` holds types and the model struct.
- `cmd/analyze/analyze_test.go` and `cmd/status/view_test.go` are test hotspots. Add new cases near related behavior; split later only when touching many adjacent cases. Run `go test ./cmd/...`.

## Verification

- Shell changes: run `./scripts/check.sh --format`, then the relevant Bats test or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh`.
- Go changes: run `go test ./...`.
- Cleanup behavior: verify with dry-run or test mode first.
- File operation changes: run `MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/user_file_ops.bats`.
- Installer changes: run `MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats`.
- Purge changes: run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`.
- Whitelist or management changes: run `MOLE_TEST_NO_AUTH=1 bats tests/manage_whitelist.bats tests/manage_sudo.bats`.
- Uninstall changes: run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_remove_file_list.bats`.
- Documentation-only changes: check links and commands.

`make check`, `make format`, `make test`, `make test-go`, and `make verify` are wrappers around the scripts above. `make verify` intentionally runs `check` plus Go tests only; use the full Bats suite before risky cleanup, uninstall, or release work.

If `golangci-lint` reports issues from deleted temporary worktrees or non-existent paths, clear its local cache and rerun the linter:

```bash
golangci-lint cache clean
golangci-lint run ./cmd/...
```

## GitHub Operations

- Re-read the live issue or PR title, body, comments, state, labels, and author language before any public reply or closeout.
- Keep CLI issues and Mole Mac app issues separate. A fix in `mole-mac` does not imply a close in this CLI repo, and a CLI fix does not prove a Mac app issue is fixed unless the Mac app release path is verified.
- When closing a fixed bug or shipped feature, use project wording from the issue context and include the expected release path only when confirmed.
- **Remote diagnostics for unreproducible reports**: when an issue needs runtime evidence from the reporter's machine, ask them to run `curl -fsSL 'https://mole.fit/downloads/Mole-Diagnose.command' | bash` and email the resulting `Mole-Diagnose-*.zip` from their Desktop to the support address. The script is read-only (process samples, recent logs, crash reports) and lives in the `mole-mac` repo at `site/downloads/Mole-Diagnose.command`; it targets the Mole Mac app, so for CLI-only issues prefer asking for the relevant `mo` command output or `mo status` JSON instead. The zip contains local paths and logs: have reporters email it, never attach it to a public issue.
- **Default issue closeout pipeline** once a fix is confirmed: commit lands on `main` (that alone makes it installable via nightly), verify the fix is actually on `main`, then reply in the reporter's language, opening with `@reporter`, in short paragraphs rather than one block, with the concrete update command: `mo update --nightly` now, the next stable release only when that path is confirmed. Closing is the maintainer's call: propose the close and wait for confirmation, and the closing comment should invite reopening if the problem persists.

## Release

Tag-driven flow via `release.yml` on capital-`V` tag pushes. The full release runbook (distribution channels, pre-flight checklist, tag/publish commands, curated notes handoff, release-only pitfalls) lives in `.claude/skills/release-flow/SKILL.md`; read it before starting any release-flavored task. Notes formatting stays owned by `.claude/skills/release-notes/SKILL.md`. One rule that always applies: restate which distribution channels a release-flavored run will touch and confirm with the maintainer before acting; channel scope is specified by the maintainer, never inferred.

## Shell and Test Pitfalls (cumulative)

These are real bugs hit on this codebase. Each one cost time. Re-read before touching the same area.

- **bash 3.2 nounset on empty arrays**: macOS default bash raises "unbound variable" when expanding `"${arr[@]}"` on an empty array under `set -u`. Always guard with `[[ ${#arr[@]} -gt 0 ]]` before expansion. Hit in `lib/manage/whitelist.sh` for `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=()`.
- **`[[ -n "$var" ]] && cmd` returns 1 when var is empty**: under `set -e` (or any caller that reads the exit code), this short-circuit form propagates exit 1 from the test, even though the intent was "skip silently". If the surrounding compound command relies on exit 0 (for example a `{...} > file ||` redirect), the optional cmd silently breaks the success path. Use plain `if/fi` whenever the conditional sits inside an exit-code-sensitive block. Hit in `install.sh` `write_install_channel_metadata` (stable channel always tripped the warning).
- **bats heredoc steals bytes from `read -n1`**: when the inner script runs via `bash <<'EOF' ... EOF`, a `read -r -s -n1` in the function under test consumes the next byte from the heredoc source itself, corrupting the next command (e.g. `echo` becomes `cho`, exit 127). Fix is to redirect the function's stdin from `/dev/null` inside the test.
- **`run_with_timeout` execs the binary, bypassing bash function mocks**: gtimeout/timeout exec the real PATH binary, so a shell-function override of (e.g.) `osascript` is invisible. Tests must use a PATH stub directory and prepend it to `PATH`, not function shadowing.
- **CI runners lack `/Library/PrivilegedHelperTools`**: `clean_orphaned_system_services` guards that scan with `[[ -d /Library/PrivilegedHelperTools ]]`, which is false on GitHub macOS runners, so a test that feeds an orphan helper through that path finds zero orphans in CI even though it passes locally (the dir exists on dev machines). Route orphan-service tests through `/Library/LaunchDaemons`, which always exists on macOS. Hit fixing #1082.
- **A test can pass vacuously when the function early-returns**: `clean_apps.bats` `setup_file` exports `MOLE_TEST_MODE=1`, and `clean_orphaned_system_services` returns immediately under that flag, leaving `$output` empty. A test whose *last* assertion is a `[[ "$output" != *"..."* ]]` (true on empty) then passes green while its real `==` assertion in the middle is silently swallowed (same shape as #886). Always end each assertion with `|| return 1`, and override `MOLE_TEST_MODE=0` (plus a `sudo -n true` mock) when the test needs the function body to actually run.
- **BSD grep has no GNU `-Z`/`--null` output mode**: on stock macOS `grep -Z` means `--decompress` (ugrep aliases treat it as fuzzy matching), so `grep -rlZ ... | while read -d ''` consumes nothing and the loop is silently dead. Enumerate with `find ... -print0` and probe each file with `grep -qF` instead. The path-referenced LaunchAgent unload in `stop_launch_services` shipped dead this way from #816 until 2026-07.
- **PlistBuddy announces file creation on stdout**: `/usr/libexec/PlistBuddy -c "Add ..."` against a missing file prints `File Doesn't Exist, Will Create: <path>` to stdout, which lands in bats `$output` and trips negative `[[ "$output" != *"<name>"* ]]` assertions. Redirect stdout too (`> /dev/null 2>&1`) when creating plist fixtures.
- **macOS 14's /bin/bash fires errexit through if-guarded mock functions**: the macos-14 runner's bash 3.2.57 build kills a `set -e` script when an exported shell-function `sudo` mock returns nonzero inside an `if fn; then` condition with a `2> /dev/null` redirect; the same bash version on macOS 15+ and dev machines does not, so the failure reproduces on no local machine. Symptom: a bats inner script exits 1 with empty output at the first failing mock probe. Fix pattern: disable errexit before the first sudo probe inside the helper (see `safe_sudo_find_delete`) and restore it before each validation-gate return. When a test fails only on one runner image, make the test print exit status, captured output, and a mock call trace on failure instead of a bare rc assertion; that evidence trail is how this one was pinned. Hit fixing test 31 of `core_safe_functions.bats`, 2026-07.


## /CLAUDE.md

AGENTS.md

## /CONTRIBUTING.md

# Contributing to Mole

## Setup

```bash
# Install development tools
brew install shfmt shellcheck bats-core golangci-lint

# Install goimports for better Go formatting
go install golang.org/x/tools/cmd/goimports@latest

# Install pre-commit hook (runs format/lint checks on every commit)
git config core.hooksPath .githooks
```

## Development

Run quality checks before committing (auto-formats code):

```bash
./scripts/check.sh
```

Run tests:

```bash
./scripts/test.sh
```

## Code Style

### Basic Rules

- Bash 3.2+ compatible (macOS default)
- 4 spaces indent
- Use `set -euo pipefail` in all scripts
- Quote all variables: `"$variable"`
- Use `[[ ]]` not `[ ]` for tests
- Use `local` for function variables, `readonly` for constants
- Function names: `snake_case`
- BSD commands not GNU (e.g., `stat -f%z` not `stat --format`)

Config: `.editorconfig` and `.shellcheckrc`

### File Operations

**Always use safe wrappers, never `rm -rf` directly:**

```bash
# Single file/directory
safe_remove "/path/to/file"

# Purge files older than 7 days
safe_find_delete "$dir" "*.log" 7 "f"

# With sudo
safe_sudo_remove "/Library/Caches/com.example"
```

See `lib/core/file_ops.sh` for all safe functions.

### Pipefail Safety

All commands that might fail must be handled:

```bash
# Correct: handle failure
find /nonexistent -name "*.cache" 2>/dev/null || true

# Correct: check array before use
if [[ ${#array[@]} -gt 0 ]]; then
    for item in "${array[@]}"; do
        process "$item"
    done
fi

# Correct: arithmetic operations
((count++)) || true
```

### Error Handling

```bash
# Network requests with timeout
result=$(curl -fsSL --connect-timeout 2 --max-time 3 "$url" 2>/dev/null || echo "")

# Command existence check
if ! command -v brew >/dev/null 2>&1; then
    log_warning "Homebrew not installed"
    return 0
fi
```

### UI and Logging

```bash
# Logging
log_info "Starting cleanup"
log_success "Cache cleaned"
log_warning "Some files skipped"
log_error "Operation failed"

# Spinners
with_spinner "Cleaning cache" rm -rf "$cache_dir"

# Or inline
start_inline_spinner "Processing..."
# ... work ...
stop_inline_spinner "Complete"
```

### Debug Mode

Enable debug output with `--debug`:

```bash
mo --debug clean
./bin/clean.sh --debug
```

Modules check the internal `MO_DEBUG` variable:

```bash
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
    echo "[MODULE] Debug message" >&2
fi
```

Format: `[MODULE_NAME] message` output to stderr.

## Requirements

- macOS 10.14 or newer, works on Intel and Apple Silicon
- Default macOS Bash 3.2+ plus administrator privileges for cleanup tasks
- Install Command Line Tools with `xcode-select --install` for curl, tar, and related utilities
- Go 1.24+ is required to build the `mo status` or `mo analyze` TUI binaries locally.

## Go Components

`mo status` and `mo analyze` use Go with Bubble Tea for interactive dashboards.

**Code organization:**

- Each module split into focused files by responsibility
- `cmd/analyze/` - Disk analyzer with 7 files under 500 lines each
- `cmd/status/` - System monitor with metrics split into 11 domain files

**Development workflow:**

- Format code with `gofmt -w ./cmd/...`
- Run `go vet ./cmd/...` to check for issues
- Build with `go build ./...` to verify all packages compile

**Building Go Binaries:**

For local development:

```bash
# Build binaries for current architecture
make build

# Or run directly without building
go run ./cmd/analyze
go run ./cmd/status
```

For releases, GitHub Actions builds architecture-specific binaries automatically.

**Guidelines:**

- Keep files focused on single responsibility
- Extract constants instead of magic numbers
- Use context for timeout control on external commands
- Add comments explaining **why** something is done, not just **what** is being done.

## Pull Requests

1. Fork and create branch from `main`
2. Make changes
3. Run checks: `./scripts/check.sh`
4. Commit and push
5. Open PR targeting `main`

CI will verify formatting, linting, and tests.


## /Makefile

``` path="/Makefile" 
# Makefile for Mole

.PHONY: all build clean check format test test-go verify release release-amd64 release-arm64 mod-download

# Output directory
BIN_DIR := bin

# Go toolchain
GO ?= go
GO_DOWNLOAD_RETRIES ?= 3

# Binaries
ANALYZE := analyze
STATUS := status

# Source directories
ANALYZE_SRC := ./cmd/analyze
STATUS_SRC := ./cmd/status

# Build flags
LDFLAGS := -s -w
RELEASE_GO_ENV := CGO_ENABLED=0

all: build

# Download modules with retries to mitigate transient proxy/network EOF errors.
mod-download:
	@attempt=1; \
	while [ $attempt -le $(GO_DOWNLOAD_RETRIES) ]; do \
		echo "Downloading Go modules ($attempt/$(GO_DOWNLOAD_RETRIES))..."; \
		if $(GO) mod download; then \
			exit 0; \
		fi; \
		sleep $((attempt * 2)); \
		attempt=$((attempt + 1)); \
	done; \
	echo "Go module download failed after $(GO_DOWNLOAD_RETRIES) attempts"; \
	exit 1

# Local build (current architecture)
build: mod-download
	@echo "Building for local architecture..."
	$(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC)
	$(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC)

check:
	./scripts/check.sh --no-format

format:
	./scripts/check.sh --format

test:
	MOLE_TEST_NO_AUTH=1 ./scripts/test.sh

test-go:
	$(GO) test ./...

verify: check test-go

# Release build targets. Keep these pure-Go so the macOS SDK on the
# release runner cannot raise the Mach-O minimum OS version via cgo.
release-amd64: mod-download
	@echo "Building release binaries (amd64)..."
	$(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC)
	$(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC)

release-arm64: mod-download
	@echo "Building release binaries (arm64)..."
	$(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC)
	$(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC)

clean:
	@echo "Cleaning binaries..."
	rm -f $(BIN_DIR)/$(ANALYZE)-* $(BIN_DIR)/$(STATUS)-* $(BIN_DIR)/$(ANALYZE)-go $(BIN_DIR)/$(STATUS)-go

```

## /README.md

<div align="center">
  <h1>Mole</h1>
  <p><em>🐹 Clean, uninstall, analyze, optimize, and monitor your Mac from the terminal.</em></p>
</div>

<p align="center">
  <a href="https://github.com/tw93/mole/stargazers"><img src="https://img.shields.io/github/stars/tw93/mole?style=flat-square" alt="Stars"></a>
  <a href="https://github.com/tw93/mole/releases"><img src="https://img.shields.io/github/v/tag/tw93/mole?label=version&style=flat-square" alt="Version"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL_v3-blue.svg?style=flat-square" alt="License"></a>
  <a href="https://github.com/tw93/mole/commits"><img src="https://img.shields.io/github/commit-activity/m/tw93/mole?style=flat-square" alt="Commits"></a>
  <a href="https://twitter.com/HiTw93"><img src="https://img.shields.io/badge/follow-Tw93-red?style=flat-square&logo=Twitter" alt="Twitter"></a>
  <a href="https://t.me/+9f9gf4ZrFSQ2OWVl"><img src="https://img.shields.io/badge/chat-Telegram-blueviolet?style=flat-square&logo=Telegram" alt="Telegram"></a>
</p>

<p align="center">
  <img src="https://gw.alipayobjects.com/zos/k/ro/ZzF8e8.png" alt="Mole - 95.50GB freed" width="1000" />
</p>

> 💡 The CLI is free and open source. Prefer a native Mac app? [Mole for Mac](https://mole.fit) adds visual cleanup review, app updates, uninstall, maintenance, disk maps, live status, and a menu bar HUD. One license covers 2 Macs, with lifetime updates and a 14-day refund.

## Features

- **All-in-one toolkit**: Combines CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus in a **single binary**
- **Deep cleaning**: Removes caches, logs, leftovers, and orphaned app data to **reclaim gigabytes of space**
- **Smart uninstaller**: Removes apps plus launch agents, preferences, and **hidden remnants**
- **Disk insights**: Visualizes usage, finds large files, **rebuilds caches**, and refreshes system services
- **Live monitoring**: Shows real-time CPU, GPU, memory, disk, and network stats

## Quick Start

**Install via Homebrew**

```bash
brew install mole
```

Homebrew follows Homebrew's supported macOS tiers. Use macOS 14 or later for the Homebrew path; older macOS versions should use the script installer below on a best-effort basis.

**Or via script**

```bash
# Optional args: -s latest for main branch code, -s 1.17.0 for specific version
curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash
```

> Note: Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters.

**Run**

```bash
mo                           # Interactive menu
mo clean                     # Deep cleanup + already-uninstalled app leftovers
mo uninstall                 # Remove installed apps + their leftovers
mo optimize                  # Refresh caches & services
mo analyze                   # Visual disk explorer (or 'mo analyse')
mo status                    # Live system health dashboard
mo purge                     # Clean project build artifacts
mo installer                 # Find and remove installer files

mo touchid                   # Configure Touch ID for sudo
mo completion                # Set up shell tab completion
mo update                    # Update Mole
mo update --nightly          # Update to latest unreleased main build, script install only
mo remove                    # Remove Mole from system
mo --help                    # Show help
mo --version                 # Show installed version
```

**Preview safely**

```bash
mo clean --dry-run
mo uninstall --dry-run
mo history
mo history --json
mo purge --dry-run

# Also works with: optimize, installer, remove, completion, touchid enable
mo clean --dry-run --debug   # Preview + detailed logs
mo optimize --whitelist      # Manage protected optimization rules
mo clean --whitelist         # Manage protected caches
mo purge --paths             # Configure project scan directories
mo analyze /Volumes          # Analyze external drives only
```

## Security & Safety Design

Mole is a local system maintenance tool, and some commands can perform destructive local operations.

Mole uses safety-first defaults: path validation, protected-directory rules, conservative cleanup boundaries, and explicit confirmation for higher-risk actions. When risk or uncertainty is high, Mole skips, refuses, or requires stronger confirmation rather than broadening deletion scope.

`mo analyze` is safer for ad hoc cleanup because it moves files to Trash through Finder instead of deleting them directly.

Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for reporting guidance, safety boundaries, and current limitations.

## Tips

- Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室.
- Safety and logs: `clean`, `uninstall`, `purge`, `installer`, and `remove` are destructive. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/Library/Logs/mole/operations.log` and can be reviewed with `mo history`. Disable with `MO_NO_OPLOG=1`. Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md).
- App leftovers: use `mo clean` when the app is already uninstalled, and `mo uninstall` when the app is still installed.
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.

## Features in Detail

### Deep System Cleanup

```bash
$ mo clean

Scanning cache directories...

  ✓ User app cache                                           45.2GB
  ✓ Browser cache (Chrome, Safari, Firefox)                  10.5GB
  ✓ Developer tools (Xcode, Node.js, npm)                    23.3GB
  ✓ System logs and temp files                                3.8GB
  ✓ App-specific cache (Spotify, Dropbox, Slack)              8.4GB
  ✓ Trash                                                    12.3GB

====================================================================
Space freed: 95.5GB | Free space now: 223.5GB
====================================================================
```

Note: In `mo clean` -> Developer tools, Mole removes unused CoreSimulator `Volumes/Cryptex` entries and skips `IN_USE` items.

### Smart App Uninstaller

```bash
$ mo uninstall

Select Apps to Remove
═══════════════════════════
▶ ☑ Photoshop 2024            (4.2G) | Old
  ☐ IntelliJ IDEA             (2.8G) | Recent
  ☐ Premiere Pro              (3.4G) | Recent

Uninstalling: Photoshop 2024

  ✓ Removed application
  ✓ Cleaned 52 related files across 12 locations
    - Application Support, Caches, Preferences
    - Logs, WebKit storage, Cookies
    - Extensions, Plugins, Launch daemons

Note: On macOS 15 and later, Local Network permission entries can outlive app removal. Mole warns when an uninstalled app declares Local Network usage, but it does not auto-reset `/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist` because that reset is global and requires Recovery mode.

====================================================================
Space freed: 12.8GB
====================================================================
```

### System Optimization

```bash
$ mo optimize

System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d

  ✓ Rebuild system databases and clear caches
  ✓ Reset network services
  ✓ Refresh Finder and Dock
  ✓ Clean diagnostic and crash logs
  ✓ Remove swap files and restart dynamic pager
  ✓ Rebuild launch services and spotlight index

====================================================================
System optimization completed
====================================================================

Use `mo optimize --whitelist` to exclude specific optimizations. Path patterns work too, so you can keep a long-lived mounted disk image around (for example `/Volumes/mail`) without it showing up as a detach candidate.
```

### Disk Space Analyzer

> Note: By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path.

```bash
$ mo analyze

Analyze Disk  (302.1GB free)
Select a location to explore:

 ▶  1. ████████████████████████  47.9%  |  Home                       75.4GB
    2. ███████████               22.0%  |  User Library               34.6GB
    3. ███████                   14.2%  |  Applications               22.4GB
    4. █████                     10.7%  |  System Library             16.9GB
    5. ███                        5.2%  |  Old Downloads (90d+)       8.2GB  >3mo

↑↓→ | Enter | R Refresh | O Open | P Preview | F File | Esc/Q Quit
```

### Live System Status

Real-time dashboard with health score, hardware info, and performance metrics.

```bash
$ mo status

Mole Status  Health ● 92  MacBook Pro · M4 Pro · 32GB · macOS 14.5

⚙ CPU                                    ▦ Memory
Total   ████████████░░░░░░░  45.2%       Used    ███████████░░░░░░░  58.4%
Load    0.82 / 1.05 / 1.23 (8 cores)     Total   14.2 / 24.0 GB
Core 1  ███████████████░░░░  78.3%       Free    ████████░░░░░░░░░░  41.6%
Core 2  ████████████░░░░░░░  62.1%       Avail   9.8 GB

▤ Disk                                   ⚡ Power
Used    █████████████░░░░░░  67.2%       Level   ██████████████████  100%
Free    156.3 GB                         Status  Charged
Read    ▮▯▯▯▯  2.1 MB/s                  Health  Normal · 423 cycles
Write   ▮▮▮▯▯  18.3 MB/s                 Temp    58°C · 1200 RPM

⇅ Network                                ▶ Processes
Down    ▁▁█▂▁▁▁▁▁▁▁▁▇▆▅▂  0.54 MB/s      Code       ▮▮▮▮▯  42.1%
Up      ▄▄▄▃▃▃▄▆▆▇█▁▁▁▁▁  0.02 MB/s      Chrome     ▮▮▮▯▯  28.3%
Proxy   HTTP · 192.168.1.100             Terminal   ▮▯▯▯▯  12.5%
```

Health score is based on CPU, memory, disk, temperature, and I/O load, with color-coded ranges.

Shortcuts: In `mo status`, press `k` to toggle the cat and save the preference, and `q` to quit.

When enabled, `mo status` shows a read-only alert banner for processes that stay above the configured CPU threshold for a sustained window. Use `--proc-cpu-threshold`, `--proc-cpu-window`, or `--proc-cpu-alerts=false` to tune or disable it.

#### Machine-Readable Output

Both `mo analyze` and `mo status` support a `--json` flag for scripting and automation.

`mo status` also auto-detects when its output is piped (not a terminal) and switches to JSON automatically.

```bash
# Disk analysis as JSON
$ mo analyze --json ~/Documents
{
  "path": "/Users/you/Documents",
  "overview": false,
  "entries": [
    { "name": "Library", "path": "...", "size": 80939438080, "is_dir": true },
    ...
  ],
  "large_files": [
    { "name": "backup.zip", "path": "...", "size": 8796093022 }
  ],
  "total_size": 168393441280,
  "total_files": 42187
}

# System status as JSON
$ mo status --json
{
  "host": "MacBook-Pro",
  "health_score": 92,
  "cpu": { "usage": 45.2, "logical_cpu": 8, ... },
  "memory": { "total": 25769803776, "used": 15049334784, "used_percent": 58.4 },
  "disks": [ ... ],
  "uptime": "3d 12h 45m",
  ...
}

# Auto-detected JSON when piped
$ mo status | jq '.health_score'
92
```

### Project Artifact Purge

Clean old build artifacts such as `node_modules`, `target`, `.build`, `build`, and `dist` to free up disk space.

```bash
mo purge

Select Categories to Clean - 18.5GB (8 selected)

➤ ● my-react-app       3.2GB | node_modules
  ● old-project        2.8GB | node_modules
  ● rust-app           4.1GB | target
  ● next-blog          1.9GB | node_modules
  ○ current-work       856MB | node_modules  | Recent
  ● django-api         2.3GB | venv
  ● vue-dashboard      1.7GB | node_modules
  ● backend-service    2.5GB | node_modules
```

> Note: We recommend installing `fd` on macOS.
> `brew install fd`

> Safety: This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default.

<details>
<summary><strong>Custom Scan Paths</strong></summary>

Run `mo purge --paths` to configure scan directories, or edit `~/.config/mole/purge_paths` directly:

```shell
~/Documents/MyProjects
~/Work/ClientA
~/Work/ClientB
```

When custom paths are configured, Mole scans only those directories. Otherwise, it uses defaults like `~/Projects`, `~/GitHub`, and `~/dev`.

</details>

### Installer Cleanup

Find and remove large installer files across Downloads, Desktop, Homebrew caches, iCloud, and Mail. Each file is labeled by source.

```bash
mo installer

Select Installers to Remove - 3.8GB (5 selected)

➤ ● Photoshop_2024.dmg     1.2GB | Downloads
  ● IntelliJ_IDEA.dmg       850.6MB | Downloads
  ● Illustrator_Setup.pkg   920.4MB | Downloads
  ● PyCharm_Pro.dmg         640.5MB | Homebrew
  ● Acrobat_Reader.dmg      220.4MB | Downloads
  ○ AppCode_Legacy.zip      410.6MB | Downloads
```

## Quick Launchers

Launch Mole commands from Raycast or Alfred:

```bash
curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick-launchers.sh | bash
```

Adds 5 commands: `Mole Clean`, `Mole Uninstall`, `Mole Optimize`, `Mole Analyze`, `Mole Status`.

### Raycast Setup

After running the script, complete these steps in Raycast:

1. Open Raycast Settings (⌘ + ,)
2. Go to **Extensions** → **Script Commands**
3. Click **"Add Script Directory"** (or **"+"**)
4. Add path: `~/Library/Application Support/Raycast/script-commands`
5. Search in Raycast for: **"Reload Script Directories"** and run it
6. Done! Search for `Mole Clean` or `clean`, `Mole Optimize`, or `Mole Status` to use the commands

> **Note**: The script creates the commands, but Raycast still requires a one-time manual script directory setup.

### Terminal Detection

Mole auto-detects your terminal app. iTerm2 has known compatibility issues. We highly recommend [Kaku](https://github.com/tw93/Kaku). Other good options are Alacritty, kitty, WezTerm, Ghostty, and Warp. To override, set `MO_LAUNCHER_APP=<name>`.

## Community Love

Thanks to everyone who helped build Mole. Go follow them. ❤️

<a href="https://github.com/tw93/Mole/graphs/contributors">
  <img src="./CONTRIBUTORS.svg?v=2" width="1000" />
</a>

<br/><br/>
Real feedback from users who shared Mole on X.

<img src="https://gw.alipayobjects.com/zos/k/dl/lovemole.jpeg" alt="Community feedback on Mole" width="1000" />

## Support

- Getting [Mole for Mac](https://mole.fit) is the most direct way to support Mole's development.
- If Mole helped you, give it a star, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.), or open an issue or PR.
- I have two cats, TangYuan and Coke. If you think Mole delights your life, you can feed them <a href="https://cats.tw93.fun?name=Mole" target="_blank">canned food 🥩</a>.

<details>
<summary>These lovely people already did 🐱</summary>
<br/>
<a href="https://cats.tw93.fun?name=Mole"><img src="https://cdn.jsdelivr.net/gh/tw93/sponsors@main/assets/sponsors.svg" width="1000" loading="lazy" /></a>
</details>

## License

Mole is open source under GPL-3.0, see [LICENSE](LICENSE). A version you modify and share stays open under the same license, and if you fork Mole into your own product, to avoid confusion please give it a different name and credit Mole as the source. [Mole for Mac](https://mole.fit) is a separate, proprietary app, and Mole is here for the long run.


## /SECURITY.md

# Security Policy

Mole is a local system maintenance tool. It includes high-risk operations such as cleanup, uninstall, optimization, and artifact removal. We treat safety boundaries, deletion logic, and release integrity as security-sensitive areas.

## Reporting a Vulnerability

Please report suspected security issues privately.

- Email: `hitw93@gmail.com`
- Subject line: `Mole security report`

Do not open a public GitHub issue for an unpatched vulnerability.

If GitHub Security Advisories private reporting is enabled for the repository, you may use that channel instead of email.

Include as much of the following as possible:

- Mole version and install method
- macOS version
- Exact command or workflow involved
- Reproduction steps or proof of concept
- Whether the issue involves deletion boundaries, symlinks, sudo, path validation, or release/install integrity

## Response Expectations

- We aim to acknowledge new reports within 7 calendar days.
- We aim to provide a status update within 30 days if a fix or mitigation is not yet available.
- We will coordinate disclosure after a fix, mitigation, or clear user guidance is ready.

Response times are best-effort for a maintainer-led open source project, but security reports are prioritized over normal bug reports.

## Supported Versions

Security fixes are only guaranteed for:

- The latest published release
- The current `main` branch

Older releases may not receive security fixes. Users running high-risk commands should stay current.

## What We Consider a Security Issue

Examples of security-relevant issues include:

- Path validation bypasses
- Deletion outside intended cleanup boundaries
- Unsafe handling of symlinks or path traversal
- Unexpected privilege escalation or unsafe sudo behavior
- Sensitive data removal that bypasses documented protections
- Release, installation, update, or checksum integrity issues
- Vulnerabilities in logic that can cause unintended destructive behavior

## What Usually Does Not Qualify

The following are usually normal bugs, feature requests, or documentation issues rather than security issues:

- Cleanup misses that leave recoverable junk behind
- False negatives where Mole refuses to clean something
- Cosmetic UI problems
- Requests for broader or more aggressive cleanup behavior
- Compatibility issues without a plausible security impact

If you are unsure whether something is security-relevant, report it privately first.

## Security-Focused Areas in Mole

The project pays particular attention to:

- Destructive command boundaries
- Path validation and protected-directory rules
- Sudo and privilege boundaries
- Symlink and path traversal handling
- Sensitive data exclusions
- Packaging, release artifacts, checksums, and update/install flows

For the current technical design and known limitations, see [SECURITY_AUDIT.md](SECURITY_AUDIT.md).


## /SECURITY_AUDIT.md

# Mole Security Audit

This document describes the security-relevant behavior of the current `main` branch, updated for V1.46.0 on 2026-07-11. It is intended as a public description of Mole's safety boundaries, destructive-operation controls, release integrity signals, and known limitations.

## Executive Summary

Mole is a local system maintenance tool. Its main risk surface is not remote code execution; it is unintended local damage caused by cleanup, uninstall, optimize, purge, installer cleanup, or other destructive operations.

The project is designed around safety-first defaults:

- destructive paths are validated before deletion
- critical system roots and sensitive user-data categories are protected
- sudo use is bounded and additional restrictions apply when elevated deletion is required
- symlink handling is conservative
- preview, confirmation, timeout, and operation logging are used to make destructive behavior more visible and auditable

Mole prioritizes bounded cleanup over aggressive cleanup. When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope.

The project continues to strengthen:

- release integrity and public security signals
- targeted regression coverage for high-risk paths
- clearer documentation for privilege boundaries and known limitations

## Threat Surface

The highest-risk areas in Mole are:

- direct file and directory deletion
- recursive cleanup across common user and system cache locations
- uninstall flows that combine app removal with remnant cleanup
- project artifact purge for large dependency/build directories
- elevated cleanup paths that require sudo
- release, install, and update trust signals for distributed artifacts

`mo analyze` is intentionally lower-risk than cleanup flows:

- it does not require sudo
- it respects normal user permissions and SIP
- delete actions require explicit confirmation
- deletion routes through Finder Trash behavior rather than direct permanent removal

## Destructive Operation Boundaries

All destructive shell file operations are routed through guarded helpers in `lib/core/file_ops.sh`.

Core controls include:

- `validate_path_for_deletion()` rejects empty paths
- relative paths are rejected
- path traversal segments such as `..` as a path component are rejected
- paths containing control characters are rejected
- raw `find ... -delete` is avoided for security-sensitive cleanup logic
- removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()`
- uninstall removal flows that move items to Trash use `mole_delete`, which validates the path again and records the operation result. `mole_delete` now also validates symlinks instead of skipping them, and normalizes the target by collapsing repeated slashes and stripping a trailing slash before the protected-path check, so equivalent path spellings cannot slip past protection.
- incomplete download cleanup skips files currently open (lsof check) and uses quoted glob patterns to prevent word-splitting on filenames that contain spaces
- stale LaunchServices cleanup in `mo clean` (`lib/clean/launch_services.sh`) only unregisters records with `lsregister -u` and never deletes files; it acts on an entry only when the dump marks it `Bundle node not found on disk` and the referenced `.app` is confirmed absent (`[[ ! -e ]]`), rejects `/System`, `/Library/Apple`, `..` traversal, and newline/carriage-return paths, honors dry-run, is bounded by `MOLE_LAUNCH_SERVICES_STALE_LIMIT` (default 50), and never performs a global `lsregister -r -f` rebuild
- orphaned system-service cleanup in `mo clean` (`lib/clean/apps.sh` `clean_orphaned_system_services`) runs only when sudo is already available, scans `/Library/{LaunchDaemons,LaunchAgents,PrivilegedHelperTools}` while skipping `com.apple.*`, and flags an entry only when its launchd `Program`/`ProgramArguments[0]` path is absolute and missing, or a `PrivilegedHelperTools` helper whose parent app is uninstalled (`bundle_has_installed_app`). Package-manager and system binary locations, a known-helper protect list, mdfind-resolved installed apps, the whitelist, and `should_protect_path` (with `SYSTEM_CRITICAL_BUNDLES` still enforced) all exclude entries before removal. Root-owned plists are read with non-interactive sudo and fail closed, so an unreadable plist is never misread as a missing binary; removal runs `launchctl unload` then the guarded `safe_sudo_remove`, and honors dry-run (issue #1082)

Blocked paths remain protected even with sudo. Examples include:

```text
/
/System
/bin
/sbin
/usr
/etc
/var
/private
/Library/Extensions
```

Some subpaths under otherwise protected roots are explicitly allowlisted for bounded cleanup where the project intentionally supports cache/log maintenance. Examples include:

- `/private/tmp`
- `/private/var/tmp`
- `/private/var/log`
- `/private/var/folders`
- `/private/var/db/diagnostics`
- `/private/var/db/DiagnosticPipeline`
- `/private/var/db/powerlog`
- `/private/var/db/reportmemoryexception`

This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns.

## Path Protection Reference

### Protected Prefixes (Never Deleted)

```text
/
/System
/bin
/sbin
/usr
/etc
/var
/private
/Library/Extensions
```

### Whitelist Exceptions (Allowlisted for Cleanup)

Some subpaths under protected roots are explicitly allowlisted:

- `/private/tmp`
- `/private/var/tmp`
- `/private/var/log`
- `/private/var/folders`
- `/private/var/db/diagnostics`
- `/private/var/db/DiagnosticPipeline`
- `/private/var/db/powerlog`
- `/private/var/db/reportmemoryexception`

### Protected Categories

In addition to path blocking, these categories are protected:

- Keychains, password managers, credentials
- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale, AmneziaWG, WireGuard, NetworkExtension preferences)
- AI tools (Cursor, Claude, ChatGPT, Ollama)
- Codex Desktop runtime state and active VM/runtime caches
- OrbStack and similar local container/VM runtimes: live container and machine images under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, plus protected bundles `dev.orbstack.*` and `dev.kdrag0n.MacVirt`. Rebuildable caches such as `~/Library/Caches/dev.orbstack.OrbStack` remain cleanable.
- Browser history and cookies
- Apple-owned app group containers, including `group.com.apple.notes`
- Time Machine data (during active backup)
- `com.apple.*` LaunchAgents/LaunchDaemons
- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration
- iCloud-synced `Mobile Documents`

## Implementation Details

All deletion routes pass through `lib/core/file_ops.sh`:

- `validate_path_for_deletion()` - Empty, relative, traversal checks
- `should_protect_path()` - Prefix and pattern matching
- `safe_remove()`, `safe_find_delete()`, `safe_sudo_remove()` - Guarded operations

The current design rationale is kept in this audit document so the safety model stays next to the implementation notes.

## Protected Directories and Categories

Mole has explicit protected-path and protected-category logic in addition to root-path blocking.

Protected or conservatively handled categories include:

- system components such as Control Center, System Settings, TCC, Spotlight, Finder, and Dock-related state
- keychains, password-manager data, tokens, credentials, and similar sensitive material
- VPN and proxy tools such as Shadowsocks, V2Ray, Clash, Tailscale, AmneziaWG, WireGuard, and NetworkExtension preferences
- AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama
- Codex Desktop runtime state and active VM/runtime caches
- OrbStack and similar local container/VM runtimes, including live data under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, while rebuildable runtime caches stay eligible for cleanup
- `~/Library/Messages/Attachments`
- Apple Notes and other Apple-owned app group containers, including `~/Library/Group Containers/group.com.apple.notes`
- browser history and cookies
- Time Machine data while backup state is active or ambiguous
- `com.apple.*` LaunchAgents and LaunchDaemons
- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration
- iCloud-synced `Mobile Documents` data

Project purge also uses conservative heuristics:

- purge targets must be inside configured project boundaries
- direct-child artifact cleanup is only allowed in single-project mode
- recently modified artifacts are treated as recent for 7 days
- nested artifacts are filtered to avoid parent-child over-deletion
- protected vendor/build-output heuristics block ambiguous directories

Developer cleanup also preserves high-value state. Examples intentionally left alone include:

- `~/.cargo/bin`
- `~/.rustup`
- `~/.mix/archives`
- `~/.stack/programs`

## Symlink and Path Traversal Handling

Symlink behavior is intentionally conservative.

- path validation checks symlink targets before deletion
- symlinks pointing at protected system targets are rejected
- `mole_delete` validates symlinks rather than skipping them, so a symlink whose target resolves into a protected root is refused instead of silently moved
- `safe_sudo_remove()` refuses to sudo-delete symlinks
- `safe_find_delete()` and `safe_sudo_find_delete()` refuse to scan symlinked base directories
- installer discovery avoids treating symlinked installer files as deletion candidates
- analyzer scanning skips following symlinks to unexpected targets

Path traversal handling is also explicit:

- non-absolute paths are rejected for destructive helpers
- `..` is rejected when it appears as a path component
- legitimate names containing `..` inside a single path element remain allowed to avoid false positives for real application data
- `mo analyze` delete validates the raw user-supplied path before `filepath.Abs` resolves it, then validates the resolved absolute path a second time, closing a window where traversal segments could survive `Abs` normalization

## Privilege Escalation and Sudo Boundaries

Mole uses sudo for a subset of system-maintenance paths, but elevated behavior is still bounded by validation and protected-path rules.

Key properties:

- sudo access is explicitly requested instead of assumed
- non-interactive preview remains conservative when sudo is unavailable
- protected roots remain blocked even when sudo is available
- sudo deletion uses the same path validation gate as non-sudo deletion
- sudo cleanup skips or reports denied operations instead of widening scope
- sudo-required uninstall paths are routed to the invoking user's Trash where possible instead of root-owned Trash or direct deletion
- sudo Trash routing refuses unsafe Trash locations, including symlinked Trash directories
- authentication, SIP/MDM, and read-only filesystem failures are classified separately in file-operation results
- sudo credential prompting passes through the system's native PAM prompt rather than a hardcoded string, ensuring correct behavior across locales and PAM configurations
- Touch ID PAM configuration (`mo touchid`) uses `sudo install -m 444 -o root -g wheel` for atomic file writes, preventing temporary permission windows where PAM files could be user-writable (fixed in V1.39.0; prior versions used `sudo mv` which preserved temp-file ownership)
- the perl-based command timeout fallback creates a new process group with `setpgid(0, 0)` rather than calling `setsid()`, so the timed child keeps the controlling terminal. This lets nested sudo inside a Homebrew cask uninstall script reuse the already-cached credential instead of failing on a detached tty, while the group-kill cleanup semantics (`kill TERM -pid`) are unchanged.

When sudo is denied or unavailable, Mole prefers skipping privileged cleanup to forcing execution through unsafe fallback behavior.

## Sensitive Data Exclusions

Mole is not intended to aggressively delete high-value user data.

Examples of conservative handling include:

- sensitive app families are excluded from generic orphan cleanup
- orphaned app data waits for inactivity windows before cleanup
- Claude VM orphan cleanup uses a separate stricter rule
- uninstall file lists are decoded and revalidated before removal
- reverse-DNS bundle ID validation is required before LaunchAgent and LaunchDaemon pattern matching; bundle ID matching uses boundary-aware comparisons (`mole_name_starts_with_bundle_id_boundary`, `mole_name_has_bundle_id_boundary`) to prevent cross-app false matches (e.g. `com.example` not matching `com.example123`), and `defaults delete` is guarded by `mole_is_reverse_dns_bundle_id()` to reject malformed or adversarial domain strings
- LaunchAgents that only declare `MachServices` are unload-only and are not treated as safe deletion targets without a backing executable or bundle match
- `force_kill_app()` refuses to terminate a process whose resolved name matches a known system process, and this guard runs before the entire pgrep/AppleScript/pkill escalation ladder, so a third-party app cannot weaponize it by setting a system-like `CFBundleExecutable`
- receipt payload removal is gated by `receipt_payload_path_is_allowlisted()`, which requires a well-formed reverse-DNS bundle ID and only allows files whose basename is anchored to that bundle ID under `/Library/LaunchAgents`, `/Library/LaunchDaemons`, `/Library/PrivilegedHelperTools`, or `/private/var/db/receipts`
- apps managed by an official vendor uninstaller are excluded from Mole's own removal list, so the vendor's uninstall flow remains authoritative
- XDG-style dotdirs belonging to a standalone CLI tool that shares a name with a GUI app are preserved during uninstall, preventing collateral removal of unrelated CLI state (issue #993, for example a CLI sharing a name with `Claude.app` or `OpenCode.app`)
- batch uninstall now displays system-level remnants for review instead of deleting them; the confirmation prompt is retained and any `launchctl unload`/`bootout` runs under dry-run and `MOLE_TEST_MODE` guards

Installed-app detection is broader than a single `/Applications` scan and includes:

- `/Applications`
- `/System/Applications`
- `~/Applications`
- Homebrew Caskroom locations
- Setapp application paths

This reduces the risk of incorrectly classifying active software as orphaned data.

## Dry-Run, Confirmation, and Audit Logging

Mole exposes multiple safety controls before and during destructive actions:

- `--dry-run` previews are available for major destructive commands
- dry-run output deduplicates targets by filesystem identity (device+inode), so aliased paths and symlinks do not appear as separate items
- interactive high-risk flows require explicit confirmation before deletion
- purge marks recent projects conservatively and leaves them unselected by default
- purge configuration is written atomically (mktemp then rename) to prevent partial writes if the process is interrupted
- analyzer delete uses Finder Trash rather than direct permanent removal
- operation logs are written to `~/Library/Logs/mole/operations.log` unless disabled with `MO_NO_OPLOG=1`
- `mole_delete` Trash and permanent deletion attempts are also recorded by the file-operation layer with result status, target path, and error context where available
- `mo history` (`lib/core/history.sh`) is read-only: it reads `operations.log` and `deletions.log` to surface recent cleanup activity and performs no deletion or out-of-bounds writes
- timeouts bound external commands so stalled discovery or uninstall operations do not silently hang the entire flow

Relevant timeout behavior includes:

- orphan and Spotlight checks: 2s
- LaunchServices rebuild during uninstall: bounded 10s and 15s steps
- LaunchServices stale registration cleanup in clean: dump bounded to 10s, each unregister bounded to 3s
- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed
- project scans and sizing operations: bounded to avoid whole-home stalls

## Optimize and System Maintenance Safety

Optimize tasks are maintenance actions rather than bulk deletion, but they still touch user-visible state, so they are bounded conservatively:

- Dock Refresh no longer deletes any `*.db` under `~/Library/Application Support/Dock`. The previous implementation wiped `desktoppicture.db` and reset the user's wallpaper (#995); refreshing the Dock now relies on `killall` plus touching the plist instead.
- Spotlight orphan rule cleanup operates only in the user domain through `defaults`, runs under a dry-run guard, removes only entries whose app is confirmed no longer installed (`bundle_has_installed_app`), requires a well-formed reverse-DNS bundle ID, and never touches `System.*` or `com.apple.*` rules.
- Font Cache Rebuild (`atsutil databases -remove`) was removed because clearing the font cache could corrupt font rendering with no reliable benefit.

## Release Integrity and Continuous Security Signals

Mole treats release trust as part of its security posture, not just a packaging detail.

Repository-level signals include:

- weekly Dependabot updates for Go modules and GitHub Actions
- pre-commit hook that mirrors GitHub CI checks locally (shell syntax, shfmt, shellcheck, Go vet)
- CI checks for unsafe `rm -rf` usage patterns and core protection behavior
- targeted tests for path validation, purge boundaries, symlink behavior, dry-run flows, and destructive helpers
- macOS 14 and macOS 15 compatibility coverage for core Bats suites
- CodeQL scanning for Go and GitHub Actions workflows, with workflow permission hardening
- curated changelog-driven release notes for user-visible changes
- published SHA-256 checksums for release assets
- GitHub artifact attestations for release assets
- install-time verification of the GitHub Actions build-provenance attestation: `install.sh` runs `gh attestation verify` (with `--deny-self-hosted-runners`) on the downloaded asset when the GitHub CLI is available, and a mismatch is treated as fatal before checksums are read. This moves attestation from a release-side artifact to an install-side check.

These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify.

## Testing Coverage

There is no single `tests/security.bats` file. Instead, security-relevant behavior is covered by focused suites, including:

- `tests/core_safe_functions.bats`
- `tests/clean_core.bats`
- `tests/clean_user_core.bats`
- `tests/clean_dev_caches.bats`
- `tests/clean_system_maintenance.bats`
- `tests/clean_apps.bats`
- `tests/clean_launch_services.bats`
- `tests/file_ops_mole_delete.bats`
- `tests/purge.bats`
- `tests/installer.bats`
- `tests/optimize.bats`
- `tests/uninstall_safety.bats`
- `tests/uninstall_naming_variants.bats`
- `tests/path_validation_fuzz.bats`
- `tests/history.bats`
- `tests/core_timeout.bats`
- `cmd/analyze/*_test.go`

Key coverage areas include:

- path validation rejects empty, relative, traversal, and system paths
- symlinked directories are rejected for destructive scans
- purge protects shallow or ambiguous paths and filters nested artifacts
- dry-run flows preview actions without applying them and do not emit duplicate targets
- confirmation flows exist for high-risk interactive operations
- LaunchAgent unload-only handling, Homebrew Cask paths, and sudo-required Trash routing
- Apple Notes group containers and other Apple-owned group containers remain protected
- sudo credential prompting and session management (`tests/manage_sudo.bats`)
- purge config path discovery and write behavior (`tests/purge_config_paths.bats`)
- hint and cleanup-hint flows (`tests/clean_hints.bats`)
- stale LaunchServices unregister limited to missing apps, dry-run preview, fail-closed on dump failure, and a path-safety filter that rejects live, system, traversal, and injection paths (`tests/clean_launch_services.bats`)
- Touch ID PAM file permission enforcement (`tests/cli.bats`)
- bundle ID boundary matching and malformed-ID rejection (`tests/uninstall_safety.bats`)
- official-uninstaller exclusion and receipt payload allowlisting (`tests/uninstall_safety.bats`)
- uninstall behavior across localized and naming-variant app names (`tests/uninstall_naming_variants.bats`)
- property-style path validation fuzzing over the corpus in `tests/fuzz_corpus/` (`tests/path_validation_fuzz.bats`)
- read-only history rendering from operation logs (`tests/history.bats`)
- command timeout behavior including process-group cleanup (`tests/core_timeout.bats`)
- bash 3.2 empty-array nounset compatibility (`tests/uninstall_scan_bash32.bats`)

## Known Limitations and Future Work

- Cleanup is destructive. Most cleanup flows do not provide undo.
- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed.
- `mo uninstall` now routes more removals through Trash, but Trash availability, permissions, and volume behavior still depend on the local macOS environment.
- Generic orphan data waits 30 days before cleanup; this is conservative but heuristic.
- Claude VM orphan cleanup waits 7 days before cleanup; this is also heuristic.
- Time Machine safety windows are hour-based and intentionally conservative.
- Localized app names may still be missed in some heuristic paths, though bundle IDs are preferred where available.
- Users who want immediate removal of app data should use explicit uninstall flows rather than waiting for orphan cleanup.
- Release artifacts include checksums and attestations, but downstream package-manager trust also depends on external distribution infrastructure.
- `mo history --json` escapes strings byte by byte under `LC_ALL=C` (`history_json_escape`) for portable behavior on bash 3.2. Printable multibyte bytes are emitted verbatim, so the emitted JSON stays valid UTF-8, but the escaper does not perform Unicode-aware codepoint iteration. This is a known display-layer detail, not a correctness issue.
- Planned follow-up work includes stronger destructive-command threat modeling, more regression coverage for high-risk paths, and continued hardening of release integrity and disclosure workflow.

For reporting procedures and supported versions, see [SECURITY.md](SECURITY.md).


## /TRADEMARK.md

# Trademark Policy

The "Mole" name and logo are trademarks of the Mole project. GPL-3.0 covers the
code, not the brand. Open source licenses grant copyright, not trademark.

We want users to trust that something called "Mole" really is this project. So if
you publish a fork, please:

- Use your own name and icon, not "Mole" or the Mole logo.
- Don't imply your fork is endorsed by or affiliated with Mole.
- Don't use the Mole name to market a paid or competing product.

Mole for Mac at https://mole.fit is a separate, proprietary product with its own
reserved name and assets.

Permission requests: open an issue or reach out via https://mole.fit.


## /bin/analyze.sh

```sh path="/bin/analyze.sh" 
#!/bin/bash
# Mole - Analyze command.
# Runs the Go disk analyzer UI.
# Uses bundled analyze-go binary.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/analyze-go"
if [[ -x "$GO_BIN" ]]; then
    exec "$GO_BIN" "$@"
fi

echo "Bundled analyzer binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

```

## /bin/clean.sh

```sh path="/bin/clean.sh" 
#!/bin/bash
# Mole - Clean command.
# Runs cleanup modules with optional sudo.
# Supports dry-run and whitelist.

set -euo pipefail

export LC_ALL=C
export LANG=C

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"

source "$SCRIPT_DIR/../lib/core/sudo.sh"
source "$SCRIPT_DIR/../lib/clean/brew.sh"
source "$SCRIPT_DIR/../lib/clean/caches.sh"
source "$SCRIPT_DIR/../lib/clean/apps.sh"
source "$SCRIPT_DIR/../lib/clean/dev.sh"
source "$SCRIPT_DIR/../lib/clean/app_caches.sh"
source "$SCRIPT_DIR/../lib/clean/hints.sh"
source "$SCRIPT_DIR/../lib/clean/launch_services.sh"
source "$SCRIPT_DIR/../lib/clean/system.sh"
source "$SCRIPT_DIR/../lib/clean/user.sh"

SYSTEM_CLEAN=false
DRY_RUN=false
PROTECT_FINDER_METADATA=false
EXTERNAL_VOLUME_TARGET=""
IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false")

# Whitelist and preview belong to the invoking user even when the whole
# command runs as root. Root dry-runs stage preview content in a root-owned
# file and publish it through an invoking-user process so user-controlled
# symlinks are never opened for writing with root privileges. See #1210.
MOLE_USER_HOME="$(get_invoking_home)"
[[ -n "$MOLE_USER_HOME" ]] || MOLE_USER_HOME="$HOME"

CLEAN_PREVIEW_FINAL_FILE="$MOLE_USER_HOME/.config/mole/clean-list.txt"
CLEAN_PREVIEW_STAGING_FILE=""
EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE"
CURRENT_SECTION=""
readonly PROTECTED_SW_DOMAINS=(
    # Web editors
    "capcut.com"
    "photopea.com"
    "pixlr.com"
    # Google Workspace (offline mode)
    "docs.google.com"
    "sheets.google.com"
    "slides.google.com"
    "drive.google.com"
    "mail.google.com"
    # Code platforms (offline/PWA)
    "github.com"
    "gitlab.com"
    "codepen.io"
    "codesandbox.io"
    "replit.com"
    "stackblitz.com"
    # Collaboration tools (offline/PWA)
    "notion.so"
    "figma.com"
    "linear.app"
    "excalidraw.com"
)

declare -a WHITELIST_PATTERNS=()
WHITELIST_WARNINGS=()
if [[ -f "$MOLE_USER_HOME/.config/mole/whitelist" ]]; then
    while IFS= read -r line; do
        # shellcheck disable=SC2295
        line="${line#"${line%%[![:space:]]*}"}"
        # shellcheck disable=SC2295
        line="${line%"${line##*[![:space:]]}"}"
        [[ -z "$line" || "$line" =~ ^# ]] && continue

        [[ "$line" == ~* ]] && line="${line/#~/$MOLE_USER_HOME}"
        line="${line//\$HOME/$MOLE_USER_HOME}"
        line="${line//\$\{HOME\}/$MOLE_USER_HOME}"
        if [[ "$line" =~ \.\. ]]; then
            WHITELIST_WARNINGS+=("Path traversal not allowed: $line")
            continue
        fi

        if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then
            if [[ "$line" =~ [[:cntrl:]] ]]; then
                WHITELIST_WARNINGS+=("Invalid path format: $line")
                continue
            fi

            if [[ "$line" != /* ]]; then
                WHITELIST_WARNINGS+=("Must be absolute path: $line")
                continue
            fi
        fi

        if [[ "$line" =~ // ]]; then
            WHITELIST_WARNINGS+=("Consecutive slashes: $line")
            continue
        fi

        case "$line" in
            / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*)
                WHITELIST_WARNINGS+=("Protected system path: $line")
                continue
                ;;
        esac

        duplicate="false"
        if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
            for existing in "${WHITELIST_PATTERNS[@]}"; do
                if [[ "$line" == "$existing" ]]; then
                    duplicate="true"
                    break
                fi
            done
        fi
        [[ "$duplicate" == "true" ]] && continue
        WHITELIST_PATTERNS+=("$line")
    done < "$MOLE_USER_HOME/.config/mole/whitelist"
else
    WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}")
fi

# Expand whitelist patterns once to avoid repeated tilde expansion in hot loops.
expand_whitelist_patterns() {
    if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
        local -a EXPANDED_PATTERNS
        EXPANDED_PATTERNS=()
        for pattern in "${WHITELIST_PATTERNS[@]}"; do
            local expanded="${pattern/#\~/$MOLE_USER_HOME}"
            EXPANDED_PATTERNS+=("$expanded")
        done
        WHITELIST_PATTERNS=("${EXPANDED_PATTERNS[@]}")
    fi
}
expand_whitelist_patterns

prepare_clean_preview_file() {
    EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE"
    CLEAN_PREVIEW_STAGING_FILE=""

    if is_root_user && [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then
        ensure_mole_temp_root || return 1
        local root_temp_dir="$MOLE_RESOLVED_TMPDIR"

        CLEAN_PREVIEW_STAGING_FILE=$(umask 077 && mktemp "$root_temp_dir/mole.clean-preview.XXXXXX") || return 1
        [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1
        MOLE_TEMP_FILES+=("$CLEAN_PREVIEW_STAGING_FILE")
        EXPORT_LIST_FILE="$CLEAN_PREVIEW_STAGING_FILE"
    else
        ensure_user_file "$EXPORT_LIST_FILE"
    fi
}

run_clean_preview_as_invoking_user() {
    /usr/bin/sudo -u "$SUDO_USER" -- "$@"
}

publish_clean_preview_file() {
    [[ -n "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 0
    [[ -f "$CLEAN_PREVIEW_STAGING_FILE" && ! -L "$CLEAN_PREVIEW_STAGING_FILE" && -O "$CLEAN_PREVIEW_STAGING_FILE" ]] || return 1
    [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]] || return 1

    local final_dir
    final_dir=$(dirname "$CLEAN_PREVIEW_FINAL_FILE")
    run_clean_preview_as_invoking_user /bin/mkdir -p "$final_dir" 2> /dev/null || return 1
    if ! /bin/cat "$CLEAN_PREVIEW_STAGING_FILE" |
        run_clean_preview_as_invoking_user /usr/bin/tee "$CLEAN_PREVIEW_FINAL_FILE" > /dev/null; then
        return 1
    fi

    EXPORT_LIST_FILE="$CLEAN_PREVIEW_FINAL_FILE"
    return 0
}

if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
    for entry in "${WHITELIST_PATTERNS[@]}"; do
        if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then
            PROTECT_FINDER_METADATA=true
            break
        fi
    done
fi

# Section tracking and summary counters.
total_items=0
TRACK_SECTION=0
SECTION_ACTIVITY=0
files_cleaned=0
total_size_cleaned=0
whitelist_skipped_count=0
PROJECT_ARTIFACT_HINT_DETECTED=false
PROJECT_ARTIFACT_HINT_COUNT=0
PROJECT_ARTIFACT_HINT_TRUNCATED=false
PROJECT_ARTIFACT_HINT_EXAMPLES=()
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false
declare -a DRY_RUN_SEEN_IDENTITIES=()

# shellcheck disable=SC2329
note_activity() {
    if [[ "${TRACK_SECTION:-0}" == "1" ]]; then
        SECTION_ACTIVITY=1
    fi
}

# shellcheck disable=SC2329
register_dry_run_cleanup_target() {
    local path="$1"
    local identity
    identity=$(mole_path_identity "$path")

    if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then
        return 1
    fi

    DRY_RUN_SEEN_IDENTITIES+=("$identity")
    return 0
}

read_clean_sudo_choice() {
    local had_force_char=false
    local previous_force_char="${MOLE_READ_KEY_FORCE_CHAR:-}"
    if [[ ${MOLE_READ_KEY_FORCE_CHAR+x} ]]; then
        had_force_char=true
    fi

    export MOLE_READ_KEY_FORCE_CHAR=1
    local choice
    choice=$(read_key)

    if [[ "$had_force_char" == "true" ]]; then
        export MOLE_READ_KEY_FORCE_CHAR="$previous_force_char"
    else
        unset MOLE_READ_KEY_FORCE_CHAR
    fi

    printf '%s\n' "$choice"
}

read_clean_sudo_password_remainder() {
    local __remainder_var="$1"
    local remainder=""

    if [[ -r /dev/tty ]]; then
        IFS= read -r -s remainder < /dev/tty || true
    else
        IFS= read -r -s remainder || true
    fi

    printf -v "$__remainder_var" '%s' "$remainder"
}

prompt_for_system_clean() {
    local prompt_attempt=0
    while true; do
        echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: "

        local choice
        choice=$(read_clean_sudo_choice)

        # ESC aborts, Space skips, Enter (or any typed key, e.g. someone who
        # starts typing their password) proceeds to authentication.
        if [[ "$choice" == "QUIT" ]]; then
            echo -e " ${GRAY}Canceled${NC}"
            exit 0
        fi

        if [[ "$choice" == "SPACE" ]]; then
            echo -e " ${GRAY}Skipped${NC}"
            echo ""
            SYSTEM_CLEAN=false
            break
        elif [[ "$choice" == "ENTER" ]]; then
            printf "\r\033[K" # Clear the prompt line
            if ensure_sudo_session "System cleanup requires admin access"; then
                SYSTEM_CLEAN=true
                echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted"
                echo ""
            else
                SYSTEM_CLEAN=false
                echo ""
                echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup"
            fi
            break
        elif [[ "$choice" == CHAR:* ]]; then
            local typed_password="${choice#CHAR:}"
            local password_remainder=""
            read_clean_sudo_password_remainder password_remainder
            typed_password="${typed_password}${password_remainder}"

            printf "\r\033[K" # Clear the prompt line
            if ensure_sudo_session_with_password "$typed_password" "System cleanup requires admin access"; then
                SYSTEM_CLEAN=true
                echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted"
                echo ""
            else
                SYSTEM_CLEAN=false
                echo ""
                echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup"
            fi
            unset typed_password password_remainder
            break
        else
            prompt_attempt=$((prompt_attempt + 1))
            drain_pending_input 0.05
            if [[ $prompt_attempt -ge 2 ]]; then
                SYSTEM_CLEAN=false
                echo -e " ${GRAY}Skipped${NC}"
                echo ""
                break
            fi
            printf "\r\033[K"
            echo -e "${YELLOW}${ICON_WARNING}${NC} Press Enter to continue, or Space to skip"
        fi
    done
}

CLEANUP_DONE=false
# shellcheck disable=SC2329
cleanup() {
    local signal="${1:-EXIT}"
    local exit_code="${2:-$?}"

    if [[ "$CLEANUP_DONE" == "true" ]]; then
        return 0
    fi
    CLEANUP_DONE=true

    stop_inline_spinner 2> /dev/null || true

    cleanup_temp_files

    stop_sudo_session

    show_cursor
}

trap 'cleanup EXIT $?' EXIT
trap 'cleanup INT 130; exit 130' INT
trap 'cleanup TERM 143; exit 143' TERM

# IMPORTANT: This file overrides start_section / end_section from
# lib/core/base.sh by virtue of being sourced after it. The clean variant adds
# CURRENT_SECTION tracking, dry-run EXPORT_LIST_FILE writes, a section
# spinner stop, and idle-header recycling. See the cross-reference block in
# lib/core/base.sh and the differing purge variant in bin/purge.sh before
# changing any of these three.
#
# Idle-header recycling: an idle section used to erase its own header after
# the fact, which made all content below jump up two lines per idle section.
# Instead the header stays put and the NEXT start_section overwrites it in
# place, so the screen never moves vertically. Any output that is not a
# section header must clear a leftover idle header first via
# flush_idle_section_slot.
IDLE_SECTION_PENDING=0

flush_idle_section_slot() {
    if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then
        IDLE_SECTION_PENDING=0
        safe_clear_lines 2 || true
    fi
}

start_section() {
    TRACK_SECTION=1
    SECTION_ACTIVITY=0
    CURRENT_SECTION="$1"
    if [[ "${IDLE_SECTION_PENDING:-0}" == "1" ]]; then
        # Overwrite the previous idle section's header line in place (the
        # pending flag is only ever set on an interactive ANSI terminal).
        IDLE_SECTION_PENDING=0
        printf '\033[1A\r\033[2K%b\n' "${PURPLE_BOLD}${ICON_ARROW} $1${NC}"
    else
        echo ""
        echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}"
    fi

    if [[ "$DRY_RUN" == "true" ]]; then
        ensure_user_file "$EXPORT_LIST_FILE"
        echo "" >> "$EXPORT_LIST_FILE"
        echo "=== $1 ===" >> "$EXPORT_LIST_FILE"
    fi
}

end_section() {
    stop_section_spinner

    if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
        # On an interactive ANSI terminal, leave the header on screen and let
        # the next start_section recycle its line, so idle sections disappear
        # without the erase-and-jump. Piped output keeps the explicit fallback
        # so logs stay self-describing. MO_DEBUG interleaves stderr lines that
        # line recycling would corrupt, so keep the fallback there too.
        if [[ -t 1 && "${MO_DEBUG:-}" != "1" ]] && is_ansi_supported 2> /dev/null; then
            IDLE_SECTION_PENDING=1
        else
            echo -e "  ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean"
        fi
    else
        IDLE_SECTION_PENDING=0
    fi
    TRACK_SECTION=0
}

# shellcheck disable=SC2329
normalize_paths_for_cleanup() {
    local -a input_paths=("$@")

    local _normalized_cleanup_path=""
    _normalize_single_cleanup_path() {
        local raw_path="$1"
        local normalized="${raw_path%/}"
        [[ -z "$normalized" ]] && normalized="$raw_path"

        local gradle_caches_root="$HOME/.gradle/caches"
        case "$normalized" in
            "$gradle_caches_root"/*/groovy-dsl/*/* | "$gradle_caches_root"/*/kotlin-dsl/*/*)
                local rel version dsl_dir rest hash
                rel="${normalized#"$gradle_caches_root"/}"
                version="${rel%%/*}"
                rest="${rel#*/}"
                dsl_dir="${rest%%/*}"
                rest="${rest#*/}"
                hash="${rest%%/*}"
                if [[ -n "$version" && -n "$hash" &&
                    ("$dsl_dir" == "groovy-dsl" || "$dsl_dir" == "kotlin-dsl") ]]; then
                    _normalized_cleanup_path="$gradle_caches_root/$version/$dsl_dir/$hash"
                    return
                fi
                ;;
        esac

        _normalized_cleanup_path="$normalized"
    }

    # Fast path for large batches: O(n log n) via sort|awk instead of O(n²) bash loops.
    # Lex sort guarantees every parent path precedes its children, so a single-pass
    # awk can filter child paths by tracking only the last kept path.
    # Paths with embedded newlines cannot go through the newline-delimited pipeline;
    # they are output directly with null-byte delimiters and skipped by the sort pass.
    if [[ ${#input_paths[@]} -gt 50 ]]; then
        # The gradle-DSL collapse below is intentionally inlined (not a call to
        # _normalize_single_cleanup_path): this path runs for thousands of items
        # and per-item function-call overhead trips the large-batch time budget
        # in tests/regression.bats. Keep it in sync with that helper.
        local -a _fast_pipeline=()
        local _fast_path _fast_raw
        for _fast_path in "${input_paths[@]}"; do
            if [[ "$_fast_path" == *{{contextString}}#39;\n'* ]]; then
                printf '%s\0' "$_fast_path"
            else
                _fast_raw="$_fast_path"
                _fast_path="${_fast_path%/}"
                [[ -z "$_fast_path" ]] && _fast_path="$_fast_raw"
                local _gradle_caches_root="$HOME/.gradle/caches"
                case "$_fast_path" in
                    "$_gradle_caches_root"/*/groovy-dsl/*/* | "$_gradle_caches_root"/*/kotlin-dsl/*/*)
                        local _rel _version _dsl_dir _rest _hash
                        _rel="${_fast_path#"$_gradle_caches_root"/}"
                        _version="${_rel%%/*}"
                        _rest="${_rel#*/}"
                        _dsl_dir="${_rest%%/*}"
                        _rest="${_rest#*/}"
                        _hash="${_rest%%/*}"
                        if [[ -n "$_version" && -n "$_hash" &&
                            ("$_dsl_dir" == "groovy-dsl" || "$_dsl_dir" == "kotlin-dsl") ]]; then
                            _fast_path="$_gradle_caches_root/$_version/$_dsl_dir/$_hash"
                        fi
                        ;;
                esac
                _fast_pipeline+=("$_fast_path")
            fi
        done
        if [[ ${#_fast_pipeline[@]} -gt 0 ]]; then
            printf '%s\n' "${_fast_pipeline[@]}" |
                awk '{sub(/\/$/, ""); if ($0 != "") print}' |
                LC_ALL=C sort -u |
                awk 'BEGIN { last = "" } {
                    if (last != "" && substr($0, 1, length(last) + 1) == last "/") next
                    last = $0; print
                }' |
                while IFS= read -r _fast_path; do printf '%s\0' "$_fast_path"; done
        fi
        return
    fi

    local -a unique_paths=()

    for path in "${input_paths[@]}"; do
        local normalized
        _normalize_single_cleanup_path "$path"
        normalized="$_normalized_cleanup_path"
        local found=false
        if [[ ${#unique_paths[@]} -gt 0 ]]; then
            for existing in "${unique_paths[@]}"; do
                if [[ "$existing" == "$normalized" ]]; then
                    found=true
                    break
                fi
            done
        fi
        [[ "$found" == "true" ]] || unique_paths+=("$normalized")
    done

    # Paths with embedded newlines cannot safely go through the newline-delimited
    # sort pipeline. Collect them separately and append to result as-is.
    local -a pipeline_paths=()
    local -a passthrough_paths=()
    for path in "${unique_paths[@]}"; do
        if [[ "$path" == *{{contextString}}#39;\n'* ]]; then
            passthrough_paths+=("$path")
        else
            pipeline_paths+=("$path")
        fi
    done

    local sorted_paths
    if [[ ${#pipeline_paths[@]} -gt 0 ]]; then
        sorted_paths=$(printf '%s\n' "${pipeline_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-)
    else
        sorted_paths=""
    fi

    local -a result_paths=()
    while IFS= read -r path; do
        [[ -z "$path" ]] && continue
        local is_child=false
        if [[ ${#result_paths[@]} -gt 0 ]]; then
            for kept in "${result_paths[@]}"; do
                if [[ "$path" == "$kept" || "$path" == "$kept"/* ]]; then
                    is_child=true
                    break
                fi
            done
        fi
        [[ "$is_child" == "true" ]] || result_paths+=("$path")
    done <<< "$sorted_paths"

    # Append passthrough paths (newline-containing; not deduplicated against others).
    if [[ ${#passthrough_paths[@]} -gt 0 ]]; then
        result_paths+=("${passthrough_paths[@]}")
    fi

    if [[ ${#result_paths[@]} -gt 0 ]]; then
        printf '%s\0' "${result_paths[@]}"
    fi
}

# shellcheck disable=SC2329
get_cleanup_path_size_kb() {
    local path="$1"

    # A plain file or a symlink is a single stat. Directories and the
    # stat-unavailable case fall back to get_path_size_kb. For a regular file
    # with a zero/invalid stat we also fall back; a symlink reports 0 directly.
    if [[ -L "$path" || -f "$path" ]] && command -v stat > /dev/null 2>&1; then
        local bytes
        bytes=$(stat -f%z "$path" 2> /dev/null || echo "0")
        if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then
            echo $(((bytes + 1023) / 1024))
            return 0
        fi
        if [[ -L "$path" ]]; then
            echo 0
            return 0
        fi
    fi

    get_path_size_kb "$path"
}

# Classification helper for cleanup risk levels
# shellcheck disable=SC2329
classify_cleanup_risk() {
    local description="$1"
    local path="${2:-}"

    # HIGH RISK: System files, preference files, require sudo
    if [[ "$description" =~ [Ss]ystem || "$description" =~ [Ss]udo || "$path" =~ ^/System || "$path" =~ ^/Library ]]; then
        echo "HIGH|System files or requires admin access"
        return
    fi

    # HIGH RISK: Preference files that might affect app functionality
    if [[ "$description" =~ [Pp]reference || "$path" =~ /Preferences/ ]]; then
        echo "HIGH|Preference files may affect app settings"
        return
    fi

    # MEDIUM RISK: Installers, large files, app bundles
    if [[ "$description" =~ [Ii]nstaller || "$description" =~ [Aa]pp.*[Bb]undle || "$description" =~ [Ll]arge ]]; then
        echo "MEDIUM|Installer packages or app data"
        return
    fi

    # MEDIUM RISK: Old backups, downloads
    if [[ "$description" =~ [Bb]ackup || "$description" =~ [Dd]ownload || "$description" =~ [Oo]rphan ]]; then
        echo "MEDIUM|Backup or downloaded files"
        return
    fi

    # LOW RISK: Caches, logs, temporary files (automatically regenerated)
    if [[ "$description" =~ [Cc]ache || "$description" =~ [Ll]og || "$description" =~ [Tt]emp || "$description" =~ [Tt]humbnail ]]; then
        echo "LOW|Cache/log files, automatically regenerated"
        return
    fi

    # DEFAULT: MEDIUM
    echo "MEDIUM|User data files"
}

# shellcheck disable=SC2329
safe_clean() {
    if [[ $# -eq 0 ]]; then
        return 0
    fi

    local description
    local -a targets

    if [[ $# -eq 1 ]]; then
        description="$1"
        targets=("$1")
    else
        description="${*: -1}"
        targets=("${@:1:$#-1}")
    fi

    local -a valid_targets=()
    for target in "${targets[@]}"; do
        # Optimization: If target is a glob literal and parent dir missing, skip it.
        if [[ "$target" == *"*"* && ! -e "$target" ]]; then
            local base_path="${target%%\**}"
            local parent_dir
            if [[ "$base_path" == */ ]]; then
                parent_dir="${base_path%/}"
            else
                parent_dir="${base_path%/*}"
            fi

            if [[ ! -d "$parent_dir" ]]; then
                # debug_log "Skipping nonexistent parent: $parent_dir for $target"
                continue
            fi
        fi
        valid_targets+=("$target")
    done

    if [[ ${#valid_targets[@]} -gt 0 ]]; then
        targets=("${valid_targets[@]}")
    else
        targets=()
    fi
    if [[ ${#targets[@]} -eq 0 ]]; then
        return 0
    fi

    local removed_any=0
    local total_size_kb=0
    local total_count=0
    local skipped_count=0
    local removal_failed_count=0
    local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0}

    local show_scan_feedback=false
    if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then
        show_scan_feedback=true
        # Updates a running section spinner in place instead of restarting it.
        start_section_spinner "Scanning ${#targets[@]} items..."
    fi

    local _perf_scan_start
    debug_timer_start _perf_scan_start

    local -a existing_paths=()
    for path in "${targets[@]}"; do
        local skip=false

        if should_protect_path "$path"; then
            skip=true
            skipped_count=$((skipped_count + 1))
            log_operation "clean" "SKIPPED" "$path" "protected"
        fi

        [[ "$skip" == "true" ]] && continue

        if is_path_whitelisted "$path"; then
            skip=true
            skipped_count=$((skipped_count + 1))
            log_operation "clean" "SKIPPED" "$path" "whitelist"
        fi
        [[ "$skip" == "true" ]] && continue
        if [[ -e "$path" ]]; then
            if [[ "$DRY_RUN" == "true" ]]; then
                register_dry_run_cleanup_target "$path" || continue
            fi
            existing_paths+=("$path")
        fi
    done

    debug_timer_end "$description: path scan" _perf_scan_start

    # Keep the spinner alive between phases; the next phase swaps its text in
    # place. Under MO_DEBUG stop it so debug lines print on a clean line.
    if [[ "$show_scan_feedback" == "true" && "${MO_DEBUG:-}" == "1" ]]; then
        stop_section_spinner
    fi

    debug_log "Cleaning: $description, ${#existing_paths[@]} items"

    # Enhanced debug output with risk level and details
    if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then
        # Determine risk level for this cleanup operation
        local risk_info
        risk_info=$(classify_cleanup_risk "$description" "${existing_paths[0]}")
        local risk_level="${risk_info%%|*}"
        local risk_reason="${risk_info#*|}"

        debug_operation_start "$description"
        debug_risk_level "$risk_level" "$risk_reason"
        debug_operation_detail "Item count" "${#existing_paths[@]}"

        # Log sample of files (first 10) with details
        if [[ ${#existing_paths[@]} -le 10 ]]; then
            debug_operation_detail "Files to be removed" "All files listed below"
        else
            debug_operation_detail "Files to be removed" "Showing first 10 of ${#existing_paths[@]} files"
        fi
    fi

    if [[ $skipped_count -gt 0 ]]; then
        whitelist_skipped_count=$((whitelist_skipped_count + skipped_count))
    fi

    if [[ ${#existing_paths[@]} -eq 0 ]]; then
        # The scan spinner we started (or took over) must not outlive this
        # call; callers print rows without stopping spinners themselves.
        if [[ "$show_scan_feedback" == "true" ]]; then
            stop_section_spinner
        fi
        return 0
    fi

    if [[ ${#existing_paths[@]} -gt 1 ]]; then
        local -a normalized_paths=()
        while IFS= read -r -d '' path; do
            [[ -n "$path" ]] && normalized_paths+=("$path")
        done < <(normalize_paths_for_cleanup "${existing_paths[@]}")

        if [[ ${#normalized_paths[@]} -gt 0 ]]; then
            existing_paths=("${normalized_paths[@]}")
        else
            existing_paths=()
        fi
    fi

    local show_spinner=false
    if [[ ${#existing_paths[@]} -gt 10 ]]; then
        show_spinner=true
        local total_paths=${#existing_paths[@]}
        if [[ -t 1 ]]; then start_section_spinner "Scanning items..."; fi
    fi

    local cleaning_spinner_started=false

    local _perf_size_start
    debug_timer_start _perf_size_start

    # For larger batches, precompute sizes in parallel for better UX/stat accuracy.
    if [[ ${#existing_paths[@]} -gt 3 ]]; then
        local temp_dir
        temp_dir=$(create_temp_dir)

        local dir_count=0
        local sample_size=$((${#existing_paths[@]} > 20 ? 20 : ${#existing_paths[@]}))
        local max_sample=$((${#existing_paths[@]} * 20 / 100))
        [[ $max_sample -gt $sample_size ]] && sample_size=$max_sample

        for ((i = 0; i < sample_size && i < ${#existing_paths[@]}; i++)); do
            [[ -d "${existing_paths[i]}" ]] && ((dir_count++))
        done

        # Heuristic: mostly files -> bulk stat is faster than per-file subshells.
        if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then
            if [[ -t 1 && "$show_spinner" == "false" ]]; then
                start_section_spinner "Scanning items..."
                show_spinner=true
            fi

            local idx=0
            local _bytes
            while IFS= read -r _bytes; do
                [[ "$_bytes" =~ ^[0-9]+$ ]] || _bytes=0
                local _kb=$(((_bytes + 1023) / 1024))
                if [[ "$_kb" -gt 0 ]]; then
                    echo "$_kb 1" > "$temp_dir/result_${idx}"
                else
                    echo "0 0" > "$temp_dir/result_${idx}"
                fi
                idx=$((idx + 1))
            done < <(stat -f%z "${existing_paths[@]}" 2> /dev/null)
            while [[ $idx -lt ${#existing_paths[@]} ]]; do
                echo "0 0" > "$temp_dir/result_${idx}"
                idx=$((idx + 1))
            done
            for ((idx = 0; idx < ${#existing_paths[@]}; idx++)); do
                if [[ -d "${existing_paths[$idx]}" && ! -L "${existing_paths[$idx]}" ]]; then
                    local _dsize
                    _dsize=$(get_cleanup_path_size_kb "${existing_paths[$idx]}")
                    [[ "$_dsize" =~ ^[0-9]+$ ]] || _dsize=0
                    if [[ "$_dsize" -gt 0 ]]; then
                        echo "$_dsize 1" > "$temp_dir/result_${idx}"
                    else
                        echo "0 0" > "$temp_dir/result_${idx}"
                    fi
                fi
            done
        else
            local -a pids=()
            local idx=0
            local completed=0
            local last_progress_update
            last_progress_update=$(get_epoch_seconds)
            local total_paths=${#existing_paths[@]}

            if [[ ${#existing_paths[@]} -gt 0 ]]; then
                for path in "${existing_paths[@]}"; do
                    (
                        local size
                        size=$(get_cleanup_path_size_kb "$path")
                        [[ ! "$size" =~ ^[0-9]+$ ]] && size=0
                        local tmp_file="$temp_dir/result_${idx}.$"
                        if [[ "$size" -gt 0 ]]; then
                            echo "$size 1" > "$tmp_file"
                        else
                            echo "0 0" > "$tmp_file"
                        fi
                        mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true
                    ) &
                    pids+=($!)
                    idx=$((idx + 1))

                    if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then
                        wait "${pids[0]}" 2> /dev/null || true
                        pids=("${pids[@]:1}")
                        completed=$((completed + 1))

                        if [[ "$show_spinner" == "true" && -t 1 ]]; then
                            update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
                        fi
                    fi
                done
            fi

            if [[ ${#pids[@]} -gt 0 ]]; then
                for pid in "${pids[@]}"; do
                    wait "$pid" 2> /dev/null || true
                    completed=$((completed + 1))

                    if [[ "$show_spinner" == "true" && -t 1 ]]; then
                        update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
                    fi
                done
            fi
        fi

        debug_timer_end "$description: size calc" _perf_size_start

        local _perf_del_start
        debug_timer_start _perf_del_start

        # Read results back in original order.
        # Start spinner for cleaning phase
        if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then
            start_section_spinner "Cleaning..."
            cleaning_spinner_started=true
        fi
        idx=0
        if [[ ${#existing_paths[@]} -gt 0 ]]; then
            for path in "${existing_paths[@]}"; do
                local result_file="$temp_dir/result_${idx}"
                if [[ -f "$result_file" ]]; then
                    read -r size count < "$result_file" 2> /dev/null || true
                    local removed=0
                    if [[ "$DRY_RUN" != "true" ]]; then
                        if safe_remove "$path" true "$size"; then
                            removed=1
                        fi
                    else
                        removed=1
                    fi

                    if [[ $removed -eq 1 ]]; then
                        if [[ "$size" -gt 0 ]]; then
                            total_size_kb=$((total_size_kb + size))
                        fi
                        total_count=$((total_count + 1))
                        removed_any=1
                    else
                        if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
                            removal_failed_count=$((removal_failed_count + 1))
                        fi
                    fi
                fi
                idx=$((idx + 1))
            done
        fi

        debug_timer_end "$description: deletion" _perf_del_start

    else
        debug_timer_end "$description: size calc" _perf_size_start

        local _perf_del_start
        debug_timer_start _perf_del_start

        # Start spinner for cleaning phase (small batch)
        if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then
            start_section_spinner "Cleaning..."
            cleaning_spinner_started=true
        fi
        local idx=0
        if [[ ${#existing_paths[@]} -gt 0 ]]; then
            for path in "${existing_paths[@]}"; do
                local size_kb
                size_kb=$(get_cleanup_path_size_kb "$path")
                [[ ! "$size_kb" =~ ^[0-9]+$ ]] && size_kb=0

                local removed=0
                if [[ "$DRY_RUN" != "true" ]]; then
                    if safe_remove "$path" true "$size_kb"; then
                        removed=1
                    fi
                else
                    removed=1
                fi

                if [[ $removed -eq 1 ]]; then
                    if [[ "$size_kb" -gt 0 ]]; then
                        total_size_kb=$((total_size_kb + size_kb))
                    fi
                    total_count=$((total_count + 1))
                    removed_any=1
                else
                    if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
                        removal_failed_count=$((removal_failed_count + 1))
                    fi
                fi
                idx=$((idx + 1))
            done
        fi

        debug_timer_end "$description: deletion" _perf_del_start
    fi

    if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" || "$show_scan_feedback" == "true" ]]; then
        stop_inline_spinner
    fi

    local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0}
    # Track permission failures in debug output (avoid noisy user warnings).
    if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then
        debug_log "Permission denied while cleaning: $description"
    fi
    if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then
        debug_log "Skipped $removal_failed_count items, permission denied or in use, for: $description"
    fi

    if [[ $removed_any -eq 1 ]]; then
        # Stop spinner before output
        stop_section_spinner

        local size_human
        size_human=$(bytes_to_human "$((total_size_kb * 1024))")

        # Multi-target cleanups report the item count as part of the detail
        # column, keeping the label clean: "npm logs · 2 items, 2KB".
        local count_note=""
        if [[ ${#targets[@]} -gt 1 ]]; then
            count_note="${#targets[@]} items, "
        fi

        if [[ "$DRY_RUN" == "true" ]]; then
            local size_display
            size_display=$(colorize_human_size "$size_human")
            echo -e "  ${YELLOW}${ICON_DRY_RUN}${NC} $description${NC} · ${count_note}${size_display} ${YELLOW}dry${NC}"

            local paths_temp
            paths_temp=$(create_temp_file)

            idx=0
            if [[ ${#existing_paths[@]} -gt 0 ]]; then
                for path in "${existing_paths[@]}"; do
                    local size=0

                    if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then
                        read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true
                    else
                        size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0")
                    fi

                    [[ "$size" == "0" || -z "$size" ]] && {
                        idx=$((idx + 1))
                        continue
                    }

                    echo "$(dirname "$path")|$size|$path" >> "$paths_temp"
                    idx=$((idx + 1))
                done
            fi

            # Group dry-run paths by parent for a compact export list.
            if [[ -f "$paths_temp" && -s "$paths_temp" ]]; then
                sort -t'|' -k1,1 "$paths_temp" | awk -F'|' '
                {
                    parent = $1
                    size = $2
                    path = $3

                    parent_size[parent] += size
                    if (parent_count[parent] == 0) {
                        parent_first[parent] = path
                    }
                    parent_count[parent]++
                }
                END {
                    for (parent in parent_size) {
                        if (parent_count[parent] > 1) {
                            printf "%s|%d|%d\n", parent, parent_size[parent], parent_count[parent]
                        } else {
                            printf "%s|%d|1\n", parent_first[parent], parent_size[parent]
                        }
                    }
                }
                ' | while IFS='|' read -r display_path total_size child_count; do
                    local size_human
                    size_human=$(bytes_to_human "$((total_size * 1024))")
                    if [[ $child_count -gt 1 ]]; then
                        echo "$display_path  # $size_human, $child_count items" >> "$EXPORT_LIST_FILE"
                    else
                        echo "$display_path  # $size_human" >> "$EXPORT_LIST_FILE"
                    fi
                done
            fi
        else
            local line_color
            line_color=$(cleanup_result_color_kb "$total_size_kb")
            echo -e "  ${line_color}${ICON_SUCCESS}${NC} $description${NC} · ${count_note}${line_color}$size_human${NC}"
        fi
        files_cleaned=$((files_cleaned + total_count))
        total_size_cleaned=$((total_size_cleaned + total_size_kb))
        total_items=$((total_items + 1))
        note_activity
    fi

    return 0
}

start_cleanup() {
    # Set current command for operation logging
    export MOLE_CURRENT_COMMAND="clean"
    log_operation_session_start "clean"
    DRY_RUN_SEEN_IDENTITIES=()

    if [[ -t 1 ]]; then
        printf '\033[2J\033[H'
    fi
    printf '\n'
    if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
        echo -e "${PURPLE_BOLD}Clean External Volume${NC}"
        echo -e "${GRAY}${EXTERNAL_VOLUME_TARGET}${NC}"
        echo ""

        if [[ "$DRY_RUN" == "true" ]]; then
            echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
            echo ""
        fi
        SYSTEM_CLEAN=false
        return 0
    fi

    echo -e "${PURPLE_BOLD}Clean Your Mac${NC}"
    echo ""

    if [[ "$DRY_RUN" != "true" && -t 0 ]]; then
        echo -e "${GRAY}${ICON_WARNING} Use --dry-run to preview, --whitelist to manage protected paths${NC}"
    fi

    if [[ "$DRY_RUN" == "true" ]]; then
        echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
        echo ""

        prepare_clean_preview_file || {
            echo -e "${YELLOW}${ICON_WARNING}${NC} Unable to create a safe cleanup preview file" >&2
            return 1
        }
        cat > "$EXPORT_LIST_FILE" << EOF
# Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S')
#
# How to protect files:
# 1. Copy any path below to ~/.config/mole/whitelist
# 2. Run: mo clean --whitelist
#
# Example:
#   /Users/*/Library/Caches/com.example.app
#

EOF

        # Preview system section when sudo is already cached (no password prompt).
        if adopt_sudo_session; then
            SYSTEM_CLEAN=true
            echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included"
            echo ""
        else
            SYSTEM_CLEAN=false
            echo -e "${GRAY}${ICON_WARNING} System caches need sudo, run ${NC}sudo -v && mo clean --dry-run${GRAY} for full preview${NC}"
            echo ""
        fi
        return
    fi

    if [[ -t 0 ]]; then
        if adopt_sudo_session; then
            SYSTEM_CLEAN=true
            echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available"
            echo ""
        else
            prompt_for_system_clean
        fi
    else
        echo ""
        echo "Running in non-interactive mode"
        if adopt_sudo_session; then
            SYSTEM_CLEAN=true
            echo "  ${ICON_LIST} System-level cleanup enabled, sudo session active"
        else
            SYSTEM_CLEAN=false
            echo "  ${ICON_LIST} System-level cleanup skipped, requires sudo"
        fi
        echo "  ${ICON_LIST} User-level cleanup will proceed automatically"
        echo ""
    fi
}

perform_cleanup() {
    if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
        total_items=0
        files_cleaned=0
        total_size_cleaned=0
    fi

    local initial_free_space_kb=""
    local initial_free_space_display="Unknown"

    # Test mode skips expensive scans and returns minimal output.
    local test_mode_enabled=false
    if [[ -z "$EXTERNAL_VOLUME_TARGET" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then
        test_mode_enabled=true
        if [[ "$DRY_RUN" == "true" ]]; then
            echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
            echo ""
        fi
        echo -e "${GREEN}${ICON_LIST}${NC} User app cache"
        if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
            local -a expanded_defaults
            expanded_defaults=()
            for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do
                expanded_defaults+=("${default/#\~/$HOME}")
            done
            local has_custom=false
            for pattern in "${WHITELIST_PATTERNS[@]}"; do
                local is_default=false
                local normalized_pattern="${pattern%/}"
                for default in "${expanded_defaults[@]}"; do
                    local normalized_default="${default%/}"
                    [[ "$normalized_pattern" == "$normalized_default" ]] && is_default=true && break
                done
                [[ "$is_default" == "false" ]] && has_custom=true && break
            done
            [[ "$has_custom" == "true" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} Protected items found"
        fi
        if [[ "$DRY_RUN" == "true" ]]; then
            echo ""
            echo -e "Potential space: $(colorize_human_size "0.00GB")"
        fi
        total_items=1
        files_cleaned=0
        total_size_cleaned=0
    fi

    if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then
        if ! initial_free_space_kb=$(get_free_space_kb 2> /dev/null); then
            initial_free_space_kb=""
        fi
        initial_free_space_display=$(format_free_space_kb "$initial_free_space_kb")
        echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $initial_free_space_display"
    fi

    if [[ "$test_mode_enabled" == "true" ]]; then
        local summary_heading="Test mode complete"
        local -a summary_details
        summary_details=()
        summary_details+=("Test mode - no actual cleanup performed")
        print_summary_block "$summary_heading" "${summary_details[@]}"
        printf '\n'
        return 0
    fi

    # Pre-check TCC permissions to avoid mid-run prompts.
    if [[ -z "$EXTERNAL_VOLUME_TARGET" ]]; then
        check_tcc_permissions
    fi

    if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
        local predefined_count=0
        local custom_count=0

        for pattern in "${WHITELIST_PATTERNS[@]}"; do
            local is_predefined=false
            for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do
                local expanded_default="${default/#\~/$HOME}"
                if [[ "$pattern" == "$expanded_default" ]]; then
                    is_predefined=true
                    break
                fi
            done

            if [[ "$is_predefined" == "true" ]]; then
                predefined_count=$((predefined_count + 1))
            else
                custom_count=$((custom_count + 1))
            fi
        done

        if [[ $custom_count -gt 0 || $predefined_count -gt 0 ]]; then
            local summary=""
            [[ $predefined_count -gt 0 ]] && summary+="$predefined_count core"
            [[ $custom_count -gt 0 && $predefined_count -gt 0 ]] && summary+=" + "
            [[ $custom_count -gt 0 ]] && summary+="$custom_count custom"
            summary+=" patterns active"

            echo -e "${BLUE}${ICON_SUCCESS}${NC} Whitelist: $summary"

            if [[ "$DRY_RUN" == "true" ]]; then
                for pattern in "${WHITELIST_PATTERNS[@]}"; do
                    [[ "$pattern" == "$FINDER_METADATA_SENTINEL" ]] && continue
                    echo -e "  ${GRAY}${ICON_SUBLIST}${NC} ${GRAY}${pattern}${NC}"
                done
            fi
        fi
    fi

    if [[ -t 1 && "$DRY_RUN" != "true" ]]; then
        local fda_status=0
        has_full_disk_access
        fda_status=$?
        if [[ $fda_status -eq 1 ]]; then
            echo ""
            echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal in System Settings for best results${NC}"
        fi
    fi

    total_items=0
    files_cleaned=0
    total_size_cleaned=0

    local had_errexit=0
    [[ $- == *e* ]] && had_errexit=1

    # Allow per-section failures without aborting the full run.
    set +e

    if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then
        start_section "External volume"
        clean_external_volume_target "$EXTERNAL_VOLUME_TARGET"
        end_section
    else
        # ===== 1. System =====
        if [[ "$SYSTEM_CLEAN" == "true" ]]; then
            start_section "System"
            clean_deep_system
            clean_local_snapshots
            end_section
        fi

        if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then
            flush_idle_section_slot
            echo ""
            for warning in "${WHITELIST_WARNINGS[@]}"; do
                echo -e "  ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning"
            done
        fi

        # ===== 2. User essentials =====
        start_section "User essentials"
        clean_user_essentials
        clean_finder_metadata
        end_section

        # ===== 3. App caches (merged sandboxed and standard app caches) =====
        start_section "App caches"
        clean_app_caches
        end_section

        # ===== 4. Browsers =====
        start_section "Browsers"
        clean_browsers
        end_section

        # ===== 5. Cloud & Office =====
        start_section "Cloud & Office"
        # Force shell fallback so timeout runs in this shell context.
        # The Cloud/Office cleaners rely on helpers (safe_clean, whitelist checks)
        # defined in this script and sourced modules.
        if run_with_shell_timeout 300 run_cloud_and_office_cleanup; then
            : # completed successfully
        else
            local ret=$?
            if [[ $ret -eq 124 ]]; then
                log_warning "Cloud & Office cleanup timed out after 5 minutes, skipping remaining items"
            elif [[ $ret -eq 130 ]]; then
                return 130
            else
                log_warning "Cloud & Office cleanup failed with exit code $ret"
            fi
        fi
        end_section

        # ===== 6. Developer tools (merged CLI and GUI tooling) =====
        start_section "Developer tools"
        clean_developer_tools
        end_section

        # ===== 7. Applications =====
        start_section "Applications"
        clean_user_gui_applications
        end_section

        # ===== 8. Virtualization =====
        start_section "Virtualization"
        clean_virtualization_tools
        end_section

        # ===== 9. Application Support =====
        start_section "Application Support"
        clean_application_support_logs
        end_section

        # ===== 10. App leftovers =====
        start_section "App leftovers"
        clean_orphaned_app_data
        clean_orphaned_system_services
        clean_orphaned_container_stubs
        clean_stale_launch_services_registrations
        show_user_launch_agent_hint_notice
        show_orphan_dotdir_hint_notice
        end_section

        # ===== 11. Apple Silicon =====
        clean_apple_silicon_caches

        # ===== 12. Device backups & firmware =====
        # iOS backups are reported once, in the Large files section; a second
        # row here used a different size formatter and confused users.
        start_section "Device backups & firmware"
        clean_cached_device_firmware
        end_section

        # ===== 13. Time Machine =====
        start_section "Time Machine"
        clean_time_machine_failed_backups
        end_section

        # ===== 14. Large files =====
        start_section "Large files"
        check_large_file_candidates
        end_section

        # ===== 15. System Data clues =====
        start_section "System Data clues"
        show_system_data_hint_notice
        end_section

        # ===== 16. Project artifacts =====
        start_section "Project artifacts"
        show_project_artifact_hint_notice
        end_section
    fi

    # ===== Final summary =====
    flush_idle_section_slot
    echo ""

    local summary_heading=""
    local summary_status="success"
    if [[ "$DRY_RUN" == "true" ]]; then
        summary_heading="Dry run complete - no changes made"
    else
        summary_heading="Cleanup complete"
    fi

    local -a summary_details=()

    # Emit one "Free space" line, with the measured delta in parentheses when
    # available. $1 is the free space in KB captured before cleanup started.
    # Caller appends each printed line to summary_details.
    emit_free_space_summary() {
        local initial_kb="$1"
        if [[ "$DRY_RUN" == "true" ]]; then
            printf 'Free space: %s\n' "$(get_free_space)"
            return 0
        fi

        local final_kb
        if ! final_kb=$(get_free_space_kb 2> /dev/null); then
            final_kb=""
        fi
        local delta_note=""
        if [[ "$initial_kb" =~ ^[0-9]+$ && "$final_kb" =~ ^[0-9]+$ && "$initial_kb" -ne "$final_kb" ]]; then
            delta_note=" ($(format_free_space_delta_kb "$((final_kb - initial_kb))"))"
        fi
        printf 'Free space: %s%s\n' "$(format_free_space_kb "$final_kb")" "$delta_note"
    }

    if [[ $total_size_cleaned -gt 0 ]]; then
        local freed_size_human
        freed_size_human=$(bytes_to_human_kb "$total_size_cleaned")

        if [[ "$DRY_RUN" == "true" ]]; then
            local stats="Potential space: $(colorize_human_size "$freed_size_human")"
            [[ $files_cleaned -gt 0 ]] && stats+=" | Items: $files_cleaned"
            [[ $total_items -gt 0 ]] && stats+=" | Categories: $total_items"
            summary_details+=("$stats")

            {
                echo ""
                echo "# ============================================"
                echo "# Summary"
                echo "# ============================================"
                echo "# Potential cleanup: ${freed_size_human}"
                echo "# Items: $files_cleaned"
                echo "# Categories: $total_items"
            } >> "$EXPORT_LIST_FILE"

        else
            local summary_line="Tracked cleanup: ${GREEN}${freed_size_human}${NC}"

            if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then
                summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items"
            elif [[ $files_cleaned -gt 0 ]]; then
                summary_line+=" | Items cleaned: $files_cleaned"
            elif [[ $total_items -gt 0 ]]; then
                summary_line+=" | Categories: $total_items"
            fi

            summary_details+=("$summary_line")

            # Movie comparison only if >= 1GB
            if ((total_size_cleaned >= MOLE_ONE_GIB_KB)); then
                local freed_gb=$((total_size_cleaned / MOLE_ONE_GIB_KB))
                local movies=$((freed_gb * 10 / 45))

                if [[ $movies -gt 0 ]]; then
                    if [[ $movies -eq 1 ]]; then
                        summary_details+=("Equivalent to ~$movies 4K movie of storage.")
                    else
                        summary_details+=("Equivalent to ~$movies 4K movies of storage.")
                    fi
                fi
            fi

            local free_space_line
            while IFS= read -r free_space_line; do
                summary_details+=("$free_space_line")
            done < <(emit_free_space_summary "$initial_free_space_kb")
        fi
    else
        summary_status="info"
        if [[ "$DRY_RUN" == "true" ]]; then
            summary_details+=("No significant reclaimable space detected, system already clean.")
        else
            summary_details+=("System was already clean; no additional space freed.")
        fi
        local free_space_line
        while IFS= read -r free_space_line; do
            summary_details+=("$free_space_line")
        done < <(emit_free_space_summary "$initial_free_space_kb")
    fi

    if [[ "$DRY_RUN" == "true" && $total_size_cleaned -gt 0 ]]; then
        if publish_clean_preview_file; then
            summary_details+=("Detailed file list: ${GRAY}$CLEAN_PREVIEW_FINAL_FILE${NC}")
            summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules")
        else
            summary_details+=("Cleanup preview file could not be written safely")
        fi
    elif [[ "$DRY_RUN" == "true" ]]; then
        publish_clean_preview_file || true
    fi

    if [[ $had_errexit -eq 1 ]]; then
        set -e
    fi

    # Log session end with summary
    log_operation_session_end "clean" "$files_cleaned" "$total_size_cleaned"

    print_summary_block "$summary_heading" "${summary_details[@]}"
    printf '\n'
}

run_with_shell_timeout() {
    local duration="$1"
    shift || true
    # Functions (for example safe_clean) are available only in the current shell.
    # Force the shell fallback path so timeout can execute shell functions directly.
    MO_TIMEOUT_BIN="" MO_TIMEOUT_PERL_BIN="" run_with_timeout "$duration" "$@"
}

# shellcheck disable=SC2329  # Invoked indirectly via run_with_timeout fallback.
run_cloud_and_office_cleanup() {
    clean_cloud_storage
    clean_office_applications
}

main() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            "--help" | "-h")
                show_clean_help
                exit 0
                ;;
            "--debug")
                export MO_DEBUG=1
                ;;
            "--dry-run" | "-n")
                DRY_RUN=true
                export MOLE_DRY_RUN=1
                ;;
            "--external")
                shift
                if [[ $# -eq 0 ]]; then
                    echo "Missing path for --external" >&2
                    exit 1
                fi
                EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1
                ;;
            "--whitelist")
                source "$SCRIPT_DIR/../lib/manage/whitelist.sh"
                manage_whitelist "clean"
                exit 0
                ;;
            "--select" | "--categories" | "--exclude")
                echo "mo clean $1 was removed in this release." >&2
                echo "Use 'mo clean --dry-run' to preview cleanup and 'mo clean --whitelist' to protect paths." >&2
                exit 1
                ;;
            -*)
                echo "Unknown option for mo clean: $1" >&2
                echo "Run 'mo clean --help' for usage." >&2
                exit 1
                ;;
            *)
                echo "Unexpected argument for mo clean: $1" >&2
                echo "Run 'mo clean --help' for usage." >&2
                exit 1
                ;;
        esac
        shift
    done

    start_cleanup
    hide_cursor
    perform_cleanup
    show_cursor
    exit 0
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
    main "$@"
fi

```

## /bin/completion.sh

```sh path="/bin/completion.sh" 
#!/bin/bash

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

source "$ROOT_DIR/lib/core/common.sh"
source "$ROOT_DIR/lib/core/commands.sh"

command_names=()
for entry in "${MOLE_COMMANDS[@]}"; do
    command_names+=("${entry%%:*}")
done
command_words="${command_names[*]}"
clean_option_words="--dry-run -n --external --whitelist --debug --help -h"
analyze_option_words="--json --help -h"
history_option_words="--json --limit --help -h"
purge_option_words="--paths --dry-run -n --include-empty --debug --help -h"

emit_zsh_subcommands() {
    for entry in "${MOLE_COMMANDS[@]}"; do
        printf "        '%s:%s'\n" "${entry%%:*}" "${entry#*:}"
    done
}

emit_fish_completions() {
    local cmd="$1"
    for entry in "${MOLE_COMMANDS[@]}"; do
        local name="${entry%%:*}"
        local desc="${entry#*:}"
        printf 'complete -f -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
    done

    printf '\n'
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l dry-run -s n -d "Preview cleanup without making changes"\n' "$cmd"
    printf 'complete -c %s -n "__fish_seen_subcommand_from clean" -l external -r -a "(__fish_complete_directories)" -d "Clean OS metadata from an external volume"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l whitelist -d "Manage protected paths"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l debug -d "Show detailed logs"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l help -s h -d "Show help"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l json -d "Output analysis as JSON"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l help -s h -d "Show help"\n' "$cmd"
    printf 'complete -c %s -n "__fish_seen_subcommand_from analyze analyse; and not __fish_seen_argument -l json -l help -s h" -a "(__fish_complete_directories)" -d "Path to analyze"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l json -d "Output history as JSON"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l limit -r -d "Limit recent entries"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l help -s h -d "Show help"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l paths -d "Edit custom scan directories"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l dry-run -s n -d "Preview purge actions without making changes"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l include-empty -d "Show zero-size project artifact directories"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l debug -d "Show detailed logs"\n' "$cmd"
    printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l help -s h -d "Show help"\n' "$cmd"
    printf '\n'
    printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
    printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
    printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
}

remove_stale_completion_entries() {
    local config_file="$1"
    local success_message="$2"

    if [[ ! -f "$config_file" ]] || ! grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
        return 1
    fi

    local original_mode=""
    local temp_file
    original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
    temp_file="$(mktemp)"
    grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
    mv "$temp_file" "$config_file"
    [[ -n "$original_mode" ]] && chmod "$original_mode" "$config_file" 2> /dev/null || true
    [[ -n "$success_message" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $success_message"
    return 0
}

if [[ $# -gt 0 ]]; then
    normalized_args=()
    for arg in "$@"; do
        case "$arg" in
            "--dry-run" | "-n")
                export MOLE_DRY_RUN=1
                ;;
            *)
                normalized_args+=("$arg")
                ;;
        esac
    done
    if [[ ${#normalized_args[@]} -gt 0 ]]; then
        set -- "${normalized_args[@]}"
    else
        set --
    fi
fi

# Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then
    if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
        echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified"
        echo ""
    fi

    # Detect current shell
    current_shell="${SHELL##*/}"
    if [[ -z "$current_shell" ]]; then
        current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')"
    fi

    completion_name=""
    if command -v mole > /dev/null 2>&1; then
        completion_name="mole"
    elif command -v mo > /dev/null 2>&1; then
        completion_name="mo"
    fi

    # Fish uses a separate install path: write to ~/.config/fish/completions/ so
    # both `mole` and `mo` load completions independently on terminal startup.
    if [[ "$current_shell" == "fish" ]]; then
        fish_dir="${HOME}/.config/fish/completions"
        mole_file="${fish_dir}/mole.fish"
        mo_file="${fish_dir}/mo.fish"
        config_fish="${HOME}/.config/fish/config.fish"

        if [[ -z "$completion_name" ]]; then
            # Clean up any stale config.fish entries even when mole is not in PATH
            if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
                remove_stale_completion_entries "$config_fish" "Removed stale completion entries from config.fish" || true
            fi
            log_error "mole not found in PATH, install Mole before enabling completion"
            exit 1
        fi

        if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
            echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would write Fish completions to:${NC}"
            echo "  $mole_file"
            echo "  $mo_file"
            echo ""
            echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
            exit 0
        fi

        # Remove stale config.fish source-based entries (previous install method)
        if remove_stale_completion_entries "$config_fish" "Removed stale source-based entries from config.fish"; then
            echo ""
        fi

        # Prompt only on first install; silently update if files exist
        if [[ ! -f "$mole_file" ]]; then
            echo ""
            echo -e "${GRAY}Will write Fish completions to:${NC}"
            echo "  $mole_file"
            echo "  $mo_file"
            echo ""
            echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}fish${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
            IFS= read -r -s -n1 key || key=""
            drain_pending_input
            echo ""

            case "$key" in
                {{contextString}}#39;\e' | [Qq] | [Nn])
                    echo -e "${YELLOW}Cancelled${NC}"
                    exit 0
                    ;;
                "" | {{contextString}}#39;\n' | {{contextString}}#39;\r' | [Yy]) ;;
                *)
                    log_error "Invalid key"
                    exit 1
                    ;;
            esac
        fi

        mkdir -p "$fish_dir"
        "$completion_name" completion fish > "$mole_file"
        # mo.fish sources mole.fish so Fish loads mo completions on `mo<Tab>`
        printf '# Mole completions for mo (alias) -- auto-generated, do not edit\n' > "$mo_file"
        printf 'source %s\n' "$mole_file" >> "$mo_file"

        if [[ -f "$mole_file" ]]; then
            echo -e "${GREEN}${ICON_SUCCESS}${NC} Fish completions written to $fish_dir"
        fi
        echo ""
        exit 0
    fi

    case "$current_shell" in
        bash)
            config_file="${HOME}/.bashrc"
            [[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile"
            # shellcheck disable=SC2016
            completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi'
            ;;
        zsh)
            config_file="${HOME}/.zshrc"
            # shellcheck disable=SC2016
            completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
            ;;
        *)
            log_error "Unsupported shell: $current_shell"
            echo "  mole completion <bash|zsh|fish>"
            exit 1
            ;;
    esac

    if [[ -z "$completion_name" ]]; then
        if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
            if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
                echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}"
                echo ""
            else
                original_mode=""
                original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
                temp_file="$(mktemp)"
                grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
                mv "$temp_file" "$config_file"
                if [[ -n "$original_mode" ]]; then
                    chmod "$original_mode" "$config_file" 2> /dev/null || true
                fi
                echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
                echo ""
            fi
        fi
        log_error "mole not found in PATH, install Mole before enabling completion"
        exit 1
    fi

    # Check if already installed and normalize to latest line
    if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
        if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
            echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}"
            echo ""
            exit 0
        fi

        original_mode=""
        original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
        temp_file="$(mktemp)"
        grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
        mv "$temp_file" "$config_file"
        if [[ -n "$original_mode" ]]; then
            chmod "$original_mode" "$config_file" 2> /dev/null || true
        fi
        {
            echo ""
            echo "# Mole shell completion"
            echo "$completion_line"
        } >> "$config_file"
        echo ""
        echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file"
        echo ""
        exit 0
    fi

    # Prompt user for installation
    echo ""
    echo -e "${GRAY}Will add to ${config_file}:${NC}"
    echo "  $completion_line"
    echo ""
    if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
        echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
        exit 0
    fi

    echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
    IFS= read -r -s -n1 key || key=""
    drain_pending_input
    echo ""

    case "$key" in
        {{contextString}}#39;\e' | [Qq] | [Nn])
            echo -e "${YELLOW}Cancelled${NC}"
            exit 0
            ;;
        "" | {{contextString}}#39;\n' | {{contextString}}#39;\r' | [Yy]) ;;
        *)
            log_error "Invalid key"
            exit 1
            ;;
    esac

    # Create config file if it doesn't exist
    if [[ ! -f "$config_file" ]]; then
        mkdir -p "$(dirname "$config_file")"
        touch "$config_file"
    fi

    # Remove previous Mole completion lines to avoid duplicates
    if [[ -f "$config_file" ]]; then
        original_mode=""
        original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
        temp_file="$(mktemp)"
        grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
        mv "$temp_file" "$config_file"
        if [[ -n "$original_mode" ]]; then
            chmod "$original_mode" "$config_file" 2> /dev/null || true
        fi
    fi

    # Add completion line
    {
        echo ""
        echo "# Mole shell completion"
        echo "$completion_line"
    } >> "$config_file"

    echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file"
    echo ""
    echo ""
    echo -e "${GRAY}To activate now:${NC}"
    echo -e "  ${GREEN}source $config_file${NC}"
    exit 0
fi

case "$1" in
    bash)
        cat << EOF
_mole_completions()
{
    local cur_word prev_word subcommand
    cur_word="\${COMP_WORDS[\$COMP_CWORD]}"
    prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}"
    subcommand="\${COMP_WORDS[1]}"

    if [ "\$COMP_CWORD" -eq 1 ]; then
        COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") )
    else
        case "\$subcommand" in
            clean)
                case "\$prev_word" in
                    --external)
                        COMPREPLY=( \$(compgen -d -- "\$cur_word") )
                        ;;
                    *)
                        COMPREPLY=( \$(compgen -W "$clean_option_words" -- "\$cur_word") )
                        ;;
                esac
                ;;
            analyze|analyse)
                if [[ "\$cur_word" == -* ]]; then
                    COMPREPLY=( \$(compgen -W "$analyze_option_words" -- "\$cur_word") )
                else
                    COMPREPLY=( \$(compgen -f -- "\$cur_word") )
                fi
                ;;
            history)
                COMPREPLY=( \$(compgen -W "$history_option_words" -- "\$cur_word") )
                ;;
            purge)
                COMPREPLY=( \$(compgen -W "$purge_option_words" -- "\$cur_word") )
                ;;
            completion)
                COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") )
                ;;
            *)
                COMPREPLY=()
                ;;
        esac
    fi
}

complete -F _mole_completions mole mo
EOF
        ;;
    zsh)
        printf '#compdef mole mo\n\n'
        printf '_mole() {\n'
        printf '    local -a subcommands\n'
        printf '    subcommands=(\n'
        emit_zsh_subcommands
        printf '    )\n'
        printf '    if (( CURRENT == 2 )); then\n'
        printf "        _describe 'subcommand' subcommands\n"
        printf '        return\n'
        printf '    fi\n'
        printf "    case \"\$words[2]\" in\n"
        printf '        clean)\n'
        printf '            _arguments \\\n'
        printf "                '--dry-run[Preview cleanup without making changes]' \\\\\n"
        printf "                '-n[Preview cleanup without making changes]' \\\\\n"
        printf "                '--external[Clean OS metadata from an external volume]:path:_files -/' \\\\\n"
        printf "                '--whitelist[Manage protected paths]' \\\\\n"
        printf "                '--debug[Show detailed logs]' \\\\\n"
        printf "                '(-h --help)'{-h,--help}'[Show help]'\n"
        printf '            ;;\n'
        printf '        analyze|analyse)\n'
        printf '            _arguments \\\n'
        printf "                '--json[Output analysis as JSON]' \\\\\n"
        printf "                '(-h --help)'{-h,--help}'[Show help]' \\\\\n"
        printf "                '*:path:_files'\n"
        printf '            ;;\n'
        printf '        history)\n'
        printf '            _arguments \\\n'
        printf "                '--json[Output history as JSON]' \\\\\n"
        printf "                '--limit[Limit recent entries]:limit:' \\\\\n"
        printf "                '(-h --help)'{-h,--help}'[Show help]'\n"
        printf '            ;;\n'
        printf '        purge)\n'
        printf '            _arguments \\\n'
        printf "                '--paths[Edit custom scan directories]' \\\\\n"
        printf "                '--dry-run[Preview purge actions without making changes]' \\\\\n"
        printf "                '-n[Preview purge actions without making changes]' \\\\\n"
        printf "                '--include-empty[Show zero-size project artifact directories]' \\\\\n"
        printf "                '--debug[Show detailed logs]' \\\\\n"
        printf "                '(-h --help)'{-h,--help}'[Show help]'\n"
        printf '            ;;\n'
        printf '        completion)\n'
        printf "            _arguments '1:shell:(bash zsh fish)'\n"
        printf '            ;;\n'
        printf '        *)\n'
        printf "            _describe 'subcommand' subcommands\n"
        printf '            ;;\n'
        printf '    esac\n'
        printf '}\n\n'
        printf 'compdef _mole mole mo\n'
        ;;
    fish)
        printf '# Completions for mole\n'
        emit_fish_completions mole
        printf '\n# Completions for mo (alias)\n'
        emit_fish_completions mo
        printf '\nfunction __fish_mole_no_subcommand\n'
        printf '    for i in (commandline -opc)\n'
        # shellcheck disable=SC2016
        printf '        if contains -- $i %s\n' "$command_words"
        printf '            return 1\n'
        printf '        end\n'
        printf '    end\n'
        printf '    return 0\n'
        printf 'end\n\n'
        printf 'function __fish_see_subcommand_path\n'
        printf '    string match -q -- "completion" (commandline -opc)[1]\n'
        printf 'end\n'
        ;;
    *)
        cat << 'EOF'
Usage: mole completion [bash|zsh|fish]

Setup shell tab completion for mole and mo commands.

Auto-install:
  mole completion              # Auto-detect shell and install
  mole completion --dry-run    # Preview config changes without writing files

Manual install:
  mole completion bash         # Generate bash completion script
  mole completion zsh          # Generate zsh completion script
  mole completion fish         # Generate fish completion script

Examples:
  # Auto-install (recommended)
  mole completion

  # Manual install - Bash
  eval "$(mole completion bash)"

  # Manual install - Zsh
  eval "$(mole completion zsh)"

  # Manual install - Fish
  mole completion fish | source
EOF
        exit 1
        ;;
esac

```

## /bin/history.sh

```sh path="/bin/history.sh" 
#!/bin/bash
# Mole - History command.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

source "$ROOT_DIR/lib/core/history.sh"

HISTORY_JSON=false
HISTORY_LIMIT="$MOLE_HISTORY_DEFAULT_LIMIT"

show_history_help() {
    echo "Usage: mo history [OPTIONS]"
    echo ""
    echo "Review recent Mole operation and deletion activity."
    echo ""
    echo "Options:"
    echo "  --json           Output history as JSON"
    echo "  --limit N        Show the most recent N entries, 1-200"
    echo "  -h, --help       Show this help message"
}

main() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            "--json")
                HISTORY_JSON=true
                ;;
            "--limit")
                shift
                if [[ $# -eq 0 ]]; then
                    echo "Missing value for --limit" >&2
                    exit 1
                fi
                if ! HISTORY_LIMIT=$(history_parse_limit "$1"); then
                    echo "Invalid value for --limit: $1" >&2
                    exit 1
                fi
                ;;
            "--help" | "-h")
                show_history_help
                exit 0
                ;;
            -*)
                echo "Unknown option for mo history: $1" >&2
                echo "Run 'mo history --help' for usage." >&2
                exit 1
                ;;
            *)
                echo "Unexpected argument for mo history: $1" >&2
                echo "Run 'mo history --help' for usage." >&2
                exit 1
                ;;
        esac
        shift
    done

    history_load_operations "$(history_operations_log_file)"
    history_load_deletions "$(history_deletions_log_file)"

    if [[ "$HISTORY_JSON" == "true" ]]; then
        history_render_json "$HISTORY_LIMIT"
    else
        history_render_text "$HISTORY_LIMIT"
    fi
}

main "$@"

```

## /bin/installer.sh

```sh path="/bin/installer.sh" 
#!/bin/bash
# Mole - Installer command
# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip

set -euo pipefail

# shellcheck disable=SC2154
# External variables set by menu_paginated.sh and environment
declare MOLE_SELECTION_RESULT
declare MOLE_INSTALLER_SCAN_MAX_DEPTH

export LC_ALL=C
export LANG=C
export MOLE_CURRENT_COMMAND="${MOLE_CURRENT_COMMAND:-installer}"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"

cleanup() {
    if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
        leave_alt_screen
        IN_ALT_SCREEN=0
    fi
    show_cursor
    cleanup_temp_files
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; exit 130' INT TERM

# Scan configuration
readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2
readonly INSTALLER_SCAN_PATHS=(
    "$HOME/Downloads"
    "$HOME/Desktop"
    "$HOME/Documents"
    "$HOME/Public"
    "$HOME/Library/Downloads"
    "/Users/Shared"
    "/Users/Shared/Downloads"
    "$HOME/Library/Caches/Homebrew"
    "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
    "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
    "$HOME/Library/Application Support/Telegram Desktop"
    "$HOME/Downloads/Telegram Desktop"
)
readonly MAX_ZIP_ENTRIES=50
readonly INSTALLER_EXIT_INCOMPLETE=3
ZIP_LIST_CMD=()
IN_ALT_SCREEN=0

if command -v zipinfo > /dev/null 2>&1; then
    ZIP_LIST_CMD=(zipinfo -1)
elif command -v unzip > /dev/null 2>&1; then
    ZIP_LIST_CMD=(unzip -Z -1)
fi

TERMINAL_WIDTH=0

# Check for installer payloads inside ZIP - check first N entries for installer patterns
is_installer_zip() {
    local zip="$1"
    local cap="$MAX_ZIP_ENTRIES"

    [[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1

    if ! "${ZIP_LIST_CMD[@]}" "$zip" 2> /dev/null |
        head -n "$cap" |
        awk '
            /\.(app|pkg|dmg|xip)(\/|$)/ { found=1; exit 0 }
            END { exit found ? 0 : 1 }
        '; then
        return 1
    fi

    return 0
}

handle_candidate_file() {
    local file="$1"

    [[ -L "$file" ]] && return 0 # Skip symlinks explicitly
    case "$file" in
        *.dmg | *.pkg | *.mpkg | *.iso | *.xip)
            echo "$file"
            ;;
        *.zip)
            [[ -r "$file" ]] || return 0
            if is_installer_zip "$file" 2> /dev/null; then
                echo "$file"
            fi
            ;;
    esac
}

scan_installers_in_path() {
    local path="$1"
    local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}"

    [[ -d "$path" ]] || return 0

    local file

    if command -v fd > /dev/null 2>&1; then
        while IFS= read -r file; do
            handle_candidate_file "$file"
        done < <(
            fd --no-ignore --hidden --type f --max-depth "$max_depth" \
                -e dmg -e pkg -e mpkg -e iso -e xip -e zip \
                . "$path" 2> /dev/null || true
        )
    else
        while IFS= read -r file; do
            handle_candidate_file "$file"
        done < <(
            find "$path" -maxdepth "$max_depth" -type f \
                \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
                -o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \
                2> /dev/null || true
        )
    fi
}

scan_all_installers() {
    for path in "${INSTALLER_SCAN_PATHS[@]}"; do
        scan_installers_in_path "$path"
    done
}

# Initialize stats
declare -i total_deleted=0
declare -i total_size_freed_kb=0
declare -i total_delete_failed=0

# Global arrays for installer data
declare -a INSTALLER_PATHS=()
declare -a INSTALLER_SIZES=()
declare -a INSTALLER_SOURCES=()
declare -a DISPLAY_NAMES=()
declare -a INSTALLER_DELETE_PATHS=()
declare -a INSTALLER_DELETE_SIZES=()
declare -a INSTALLER_DELETE_IDENTITIES=()
declare -a INSTALLER_DELETE_FAILURES=()

# Get source directory display name - for example "Downloads" or "Desktop"
get_source_display() {
    local file_path="$1"
    local dir_path="${file_path%/*}"

    # Match against known paths and return friendly names
    case "$dir_path" in
        "$HOME/Downloads"*) echo "Downloads" ;;
        "$HOME/Desktop"*) echo "Desktop" ;;
        "$HOME/Documents"*) echo "Documents" ;;
        "$HOME/Public"*) echo "Public" ;;
        "$HOME/Library/Downloads"*) echo "Library" ;;
        "/Users/Shared"*) echo "Shared" ;;
        "$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
        "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
        "$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
        *"Telegram Desktop"*) echo "Telegram" ;;
        *) echo "${dir_path##*/}" ;;
    esac
}

get_terminal_width() {
    if [[ $TERMINAL_WIDTH -le 0 ]]; then
        TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80)
    fi
    echo "$TERMINAL_WIDTH"
}

# Format installer display with alignment - similar to purge command
format_installer_display() {
    local filename="$1"
    local size_str="$2"
    local source="$3"

    # Terminal width for alignment
    local terminal_width
    terminal_width=$(get_terminal_width)
    local fixed_width=24 # Reserve for size and source
    local available_width=$((terminal_width - fixed_width))

    # Bounds check: 20-40 chars for filename
    [[ $available_width -lt 20 ]] && available_width=20
    [[ $available_width -gt 40 ]] && available_width=40

    # Truncate filename if needed
    local truncated_name
    truncated_name=$(truncate_by_display_width "$filename" "$available_width")
    local current_width
    current_width=$(get_display_width "$truncated_name")

    # Get byte count for printf width calculation
    local old_lc="${LC_ALL:-}"
    export LC_ALL=C
    local byte_count=${#truncated_name}
    if [[ -n "$old_lc" ]]; then
        export LC_ALL="$old_lc"
    else
        unset LC_ALL
    fi

    local padding=$((available_width - current_width))
    local printf_width=$((byte_count + padding))

    # Format: "filename  size | source"
    printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
}

# Collect all installers with their metadata
collect_installers() {
    # Clear previous results
    INSTALLER_PATHS=()
    INSTALLER_SIZES=()
    INSTALLER_SOURCES=()
    DISPLAY_NAMES=()

    # Start scanning with spinner
    if [[ -t 1 ]]; then
        start_inline_spinner "Scanning for installers..."
    fi

    # Start debug session
    debug_operation_start "Collect Installers" "Scanning for redundant installer files"

    # Scan all paths, deduplicate, and sort results
    local -a all_files=()

    while IFS= read -r file; do
        [[ -z "$file" ]] && continue
        all_files+=("$file")
        debug_file_action "Found installer" "$file"
    done < <(scan_all_installers | sort -u)

    if [[ -t 1 ]]; then
        stop_inline_spinner
    fi

    if [[ ${#all_files[@]} -eq 0 ]]; then
        if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then
            echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
        fi
        return 1
    fi

    # Calculate sizes with spinner
    if [[ -t 1 ]]; then
        start_inline_spinner "Calculating sizes..."
    fi

    # Process each installer
    for file in "${all_files[@]}"; do
        # Calculate file size
        local file_size=0
        if [[ -f "$file" ]]; then
            file_size=$(get_file_size "$file")
        fi

        # Get source directory
        local source
        source=$(get_source_display "$file")

        # Format human readable size
        local size_human
        size_human=$(bytes_to_human "$file_size")

        # Get display filename - strip Homebrew hash prefix if present
        local display_name
        display_name=$(basename "$file")
        if [[ "$source" == "Homebrew" ]]; then
            # Homebrew names often look like: sha256--name--version
            # Strip the leading hash if it matches [0-9a-f]{64}--
            if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then
                display_name="${BASH_REMATCH[1]}"
            fi
        fi

        # Format display with alignment
        local display
        display=$(format_installer_display "$display_name" "$size_human" "$source")

        # Store installer data in parallel arrays
        INSTALLER_PATHS+=("$file")
        INSTALLER_SIZES+=("$file_size")
        INSTALLER_SOURCES+=("$source")
        DISPLAY_NAMES+=("$display")
    done

    if [[ -t 1 ]]; then
        stop_inline_spinner
    fi
    return 0
}

# Installer selector with Select All / Invert support
select_installers() {
    local -a items=("$@")
    local total_items=${#items[@]}
    local clear_line={{contextString}}#39;\r\033[2K'

    if [[ $total_items -eq 0 ]]; then
        return 1
    fi

    # Calculate items per page based on terminal height
    _get_items_per_page() {
        local term_height=24
        if [[ -t 0 ]] || [[ -t 2 ]]; then
            term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
        fi
        if [[ -z "$term_height" || $term_height -le 0 ]]; then
            if command -v tput > /dev/null 2>&1; then
                term_height=$(tput lines 2> /dev/null || echo "24")
            else
                term_height=24
            fi
        fi
        local reserved=6
        local available=$((term_height - reserved))
        if [[ $available -lt 3 ]]; then
            echo 3
        elif [[ $available -gt 50 ]]; then
            echo 50
        else
            echo "$available"
        fi
    }

    local items_per_page=$(_get_items_per_page)
    local cursor_pos=0
    local top_index=0

    # Initialize selection (all unselected by default)
    local -a selected=()
    for ((i = 0; i < total_items; i++)); do
        selected[i]=false
    done

    local original_stty=""
    if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
        original_stty=$(stty -g 2> /dev/null || echo "")
    fi

    restore_terminal() {
        trap - EXIT INT TERM
        if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
            leave_alt_screen
            IN_ALT_SCREEN=0
        fi
        show_cursor
        if [[ -n "${original_stty:-}" ]]; then
            stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
        fi
    }

    handle_interrupt() {
        restore_terminal
        exit 130
    }

    draw_menu() {
        items_per_page=$(_get_items_per_page)

        local max_top_index=0
        if [[ $total_items -gt $items_per_page ]]; then
            max_top_index=$((total_items - items_per_page))
        fi
        if [[ $top_index -gt $max_top_index ]]; then
            top_index=$max_top_index
        fi
        if [[ $top_index -lt 0 ]]; then
            top_index=0
        fi

        local visible_count=$((total_items - top_index))
        [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
        if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
            cursor_pos=$((visible_count - 1))
        fi
        if [[ $cursor_pos -lt 0 ]]; then
            cursor_pos=0
        fi

        printf "\033[H"

        # Calculate selected size and count
        local selected_size=0
        local selected_count=0
        for ((i = 0; i < total_items; i++)); do
            if [[ ${selected[i]} == true ]]; then
                selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
                ((selected_count++))
            fi
        done
        local selected_human
        selected_human=$(bytes_to_human "$selected_size")

        # Show position indicator if scrolling is needed
        local scroll_indicator=""
        if [[ $total_items -gt $items_per_page ]]; then
            local current_pos=$((top_index + cursor_pos + 1))
            scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
        fi

        printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}, ${selected_human}, ${selected_count} selected${NC}\n" "$scroll_indicator"
        printf "%s\n" "$clear_line"

        # Calculate visible range
        local end_index=$((top_index + visible_count))

        # Draw only visible items
        for ((i = top_index; i < end_index; i++)); do
            local checkbox="$ICON_EMPTY"
            [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
            local rel_pos=$((i - top_index))
            if [[ $rel_pos -eq $cursor_pos ]]; then
                printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
            else
                printf "%s  %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
            fi
        done

        # Fill empty slots
        local items_shown=$visible_count
        for ((i = items_shown; i < items_per_page; i++)); do
            printf "%s\n" "$clear_line"
        done

        printf "%s\n" "$clear_line"
        printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}  |  Space Select  |  Enter Confirm  |  A All  |  I Invert  |  Q Quit${NC}\n" "$clear_line"
    }

    trap restore_terminal EXIT
    trap handle_interrupt INT TERM
    stty -echo -icanon intr ^C 2> /dev/null || true
    hide_cursor
    if [[ -t 1 ]]; then
        printf "\033[2J\033[H" >&2
    fi

    # Main loop
    while true; do
        draw_menu

        IFS= read -r -s -n1 key || key=""
        case "$key" in
            {{contextString}}#39;\x1b')
                IFS= read -r -s -n1 -t 1 key2 || key2=""
                if [[ "$key2" == "[" ]]; then
                    IFS= read -r -s -n1 -t 1 key3 || key3=""
                    case "$key3" in
                        A) # Up arrow
                            if [[ $cursor_pos -gt 0 ]]; then
                                ((cursor_pos--))
                            elif [[ $top_index -gt 0 ]]; then
                                ((top_index--))
                            fi
                            ;;
                        B) # Down arrow
                            local absolute_index=$((top_index + cursor_pos))
                            local last_index=$((total_items - 1))
                            if [[ $absolute_index -lt $last_index ]]; then
                                local visible_count=$((total_items - top_index))
                                [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
                                if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
                                    ((cursor_pos++))
                                elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
                                    ((top_index++))
                                fi
                            fi
                            ;;
                    esac
                else
                    # ESC alone
                    restore_terminal
                    return 1
                fi
                ;;
            " ") # Space - toggle current item
                local idx=$((top_index + cursor_pos))
                if [[ ${selected[idx]} == true ]]; then
                    selected[idx]=false
                else
                    selected[idx]=true
                fi
                ;;
            "a" | "A") # Select all
                for ((i = 0; i < total_items; i++)); do
                    selected[i]=true
                done
                ;;
            "i" | "I") # Invert selection
                for ((i = 0; i < total_items; i++)); do
                    if [[ ${selected[i]} == true ]]; then
                        selected[i]=false
                    else
                        selected[i]=true
                    fi
                done
                ;;
            "q" | "Q" | {{contextString}}#39;\x03') # Quit or Ctrl-C
                restore_terminal
                return 1
                ;;
            "" | {{contextString}}#39;\n' | {{contextString}}#39;\r') # Enter - confirm
                MOLE_SELECTION_RESULT=""
                for ((i = 0; i < total_items; i++)); do
                    if [[ ${selected[i]} == true ]]; then
                        [[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
                        MOLE_SELECTION_RESULT+="$i"
                    fi
                done
                restore_terminal
                return 0
                ;;
        esac
    done
}

# Show menu for user selection
show_installer_menu() {
    if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
        return 1
    fi

    echo ""

    MOLE_SELECTION_RESULT=""
    if ! select_installers "${DISPLAY_NAMES[@]}"; then
        return 1
    fi

    return 0
}

reset_installer_delete_results() {
    total_deleted=0
    total_size_freed_kb=0
    total_delete_failed=0
    INSTALLER_DELETE_FAILURES=()
}

reset_installer_delete_plan() {
    INSTALLER_DELETE_PATHS=()
    INSTALLER_DELETE_SIZES=()
    INSTALLER_DELETE_IDENTITIES=()
}

record_installer_delete_failure() {
    local file_path="$1"
    local reason="$2"

    INSTALLER_DELETE_FAILURES+=("$file_path ($reason)")
    total_delete_failed=$((total_delete_failed + 1))
}

installer_file_size_bytes() {
    local file_path="$1"
    local file_size

    file_size=$(get_file_size "$file_path" 2> /dev/null || echo "")
    [[ "$file_size" =~ ^[0-9]+$ ]] || return 1
    printf '%s\n' "$file_size"
}

build_installer_delete_plan() {
    reset_installer_delete_plan

    local idx
    for idx in "$@"; do
        if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
            record_installer_delete_failure "$idx" "stale selection"
            continue
        fi

        local file_path="${INSTALLER_PATHS[$idx]}"
        local file_size="${INSTALLER_SIZES[$idx]:-0}"
        if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
            file_size=0
        fi

        INSTALLER_DELETE_PATHS+=("$file_path")
        INSTALLER_DELETE_SIZES+=("$file_size")
        INSTALLER_DELETE_IDENTITIES+=("$(mole_path_identity "$file_path")")
    done

    [[ ${#INSTALLER_DELETE_PATHS[@]} -gt 0 ]]
}

execute_installer_delete_plan() {
    local plan_index
    for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do
        local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}"
        local planned_size="${INSTALLER_DELETE_SIZES[$plan_index]}"
        local planned_identity="${INSTALLER_DELETE_IDENTITIES[$plan_index]}"

        if [[ ! -e "$file_path" && ! -L "$file_path" ]]; then
            record_installer_delete_failure "$file_path" "missing"
            continue
        fi

        local current_identity
        current_identity=$(mole_path_identity "$file_path")
        if [[ "$current_identity" != "$planned_identity" ]]; then
            record_installer_delete_failure "$file_path" "changed since scan"
            continue
        fi

        local current_size
        if ! current_size=$(installer_file_size_bytes "$file_path"); then
            record_installer_delete_failure "$file_path" "size unavailable"
            continue
        fi
        if [[ "$current_size" != "$planned_size" ]]; then
            record_installer_delete_failure "$file_path" "changed since scan"
            continue
        fi

        if mole_delete "$file_path" false; then
            if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] || [[ ! -e "$file_path" && ! -L "$file_path" ]]; then
                total_size_freed_kb=$((total_size_freed_kb + ((current_size + 1023) / 1024)))
                total_deleted=$((total_deleted + 1))
            else
                record_installer_delete_failure "$file_path" "still exists"
            fi
        else
            record_installer_delete_failure "$file_path" "delete failed"
        fi
    done

    if [[ $total_delete_failed -gt 0 ]]; then
        return "$INSTALLER_EXIT_INCOMPLETE"
    fi
    return 0
}

# Delete selected installers
delete_selected_installers() {
    reset_installer_delete_results

    # Parse selection indices
    local -a selected_indices=()
    if [[ -n "$MOLE_SELECTION_RESULT" ]]; then
        IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT"
    fi

    if [[ ${#selected_indices[@]} -eq 0 ]]; then
        return 1
    fi

    if ! build_installer_delete_plan "${selected_indices[@]}"; then
        if [[ $total_delete_failed -gt 0 ]]; then
            return "$INSTALLER_EXIT_INCOMPLETE"
        fi
        return 1
    fi

    local confirm_size=0
    local plan_index
    for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_SIZES[@]}; plan_index++)); do
        confirm_size=$((confirm_size + ${INSTALLER_DELETE_SIZES[$plan_index]}))
    done

    local confirm_human
    confirm_human=$(bytes_to_human "$confirm_size")

    # Show files to be deleted
    echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
    for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do
        local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}"
        local file_size="${INSTALLER_DELETE_SIZES[$plan_index]}"
        local size_human
        size_human=$(bytes_to_human "$file_size")
        echo -e "  ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}"
    done

    # Confirm deletion
    echo ""
    echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#INSTALLER_DELETE_PATHS[@]} installers, ${confirm_human}  ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "

    IFS= read -r -s -n1 confirm || confirm=""
    case "$confirm" in
        {{contextString}}#39;\e' | q | Q)
            return 1
            ;;
        "" | {{contextString}}#39;\n' | {{contextString}}#39;\r')
            printf "\r\033[K" # Clear prompt line
            echo ""           # Single line break
            ;;
        *)
            return 1
            ;;
    esac

    # Delete each selected installer with spinner
    if [[ -t 1 ]]; then
        start_inline_spinner "Removing installers..."
    fi

    local delete_status=0
    execute_installer_delete_plan || delete_status=$?

    if [[ -t 1 ]]; then
        stop_inline_spinner
    fi

    return "$delete_status"
}

# Perform the installers cleanup
perform_installers() {
    # Enter alt screen for scanning and selection
    if [[ -t 1 ]]; then
        enter_alt_screen
        IN_ALT_SCREEN=1
        printf "\033[2J\033[H" >&2
    fi

    # Collect installers
    if ! collect_installers; then
        if [[ -t 1 ]]; then
            leave_alt_screen
            IN_ALT_SCREEN=0
        fi
        printf '\n'
        echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
        printf '\n'
        return 2 # Nothing to clean
    fi

    # Show menu
    if ! show_installer_menu; then
        if [[ -t 1 ]]; then
            leave_alt_screen
            IN_ALT_SCREEN=0
        fi
        return 1 # User cancelled
    fi

    # Leave alt screen before deletion (so confirmation and results are on main screen)
    if [[ -t 1 ]]; then
        leave_alt_screen
        IN_ALT_SCREEN=0
    fi

    # Delete selected
    local delete_status=0
    delete_selected_installers || delete_status=$?
    if [[ $delete_status -ne 0 ]]; then
        return "$delete_status"
    fi

    return 0
}

show_summary() {
    local summary_heading="Installers cleaned"
    local -a summary_details=()
    local dry_run_mode="${MOLE_DRY_RUN:-0}"

    if [[ "$dry_run_mode" == "1" ]]; then
        summary_heading="Dry run complete - no changes made"
    elif [[ $total_delete_failed -gt 0 ]]; then
        summary_heading="Installer cleanup incomplete"
    fi

    if [[ $total_deleted -gt 0 ]]; then
        local freed_mb
        freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')

        if [[ "$dry_run_mode" == "1" ]]; then
            summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}")
        else
            summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}")
            if [[ $total_delete_failed -eq 0 ]]; then
                summary_details+=("Your Mac is cleaner now!")
            fi
        fi
    else
        summary_details+=("No installers were removed")
    fi

    if [[ $total_delete_failed -gt 0 ]]; then
        local failure_label="installers"
        [[ $total_delete_failed -eq 1 ]] && failure_label="installer"
        summary_details+=("Failed to remove ${YELLOW}$total_delete_failed${NC} $failure_label")

        local failure_count=${#INSTALLER_DELETE_FAILURES[@]}
        local failure_limit=5
        if [[ $failure_count -lt $failure_limit ]]; then
            failure_limit=$failure_count
        fi

        local failure_index
        for ((failure_index = 0; failure_index < failure_limit; failure_index++)); do
            local failure_detail="${INSTALLER_DELETE_FAILURES[$failure_index]}"
            summary_details+=("${ICON_WARNING} $failure_detail")
        done

        if [[ $failure_count -gt $failure_limit ]]; then
            summary_details+=("${ICON_WARNING} $((failure_count - failure_limit)) more failed")
        fi
    fi

    print_summary_block "$summary_heading" "${summary_details[@]}"
    printf '\n'
}

main() {
    for arg in "$@"; do
        case "$arg" in
            "--help" | "-h")
                show_installer_help
                exit 0
                ;;
            "--debug")
                export MO_DEBUG=1
                ;;
            "--dry-run" | "-n")
                export MOLE_DRY_RUN=1
                ;;
            *)
                echo "Unknown option: $arg"
                exit 1
                ;;
        esac
    done

    if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
        echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed"
        printf '\n'
    fi

    hide_cursor
    perform_installers
    local exit_code=$?
    show_cursor

    case $exit_code in
        0)
            show_summary
            ;;
        "$INSTALLER_EXIT_INCOMPLETE")
            show_summary
            return 1
            ;;
        1)
            printf '\n'
            ;;
        2)
            # Already handled by collect_installers
            ;;
    esac

    return 0
}

# Only run main if not in test mode
if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then
    main "$@"
fi

```

## /bin/status.sh

```sh path="/bin/status.sh" 
#!/bin/bash
# Mole - Status command.
# Runs the Go system status panel.
# Shows live system metrics.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_BIN="$SCRIPT_DIR/status-go"
if [[ -x "$GO_BIN" ]]; then
    exec "$GO_BIN" "$@"
fi

echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2
exit 1

```

## /cmd/analyze/cleanable.go

```go path="/cmd/analyze/cleanable.go" 
//go:build darwin

package main

import (
	"io"
	"os"
	"path/filepath"
	"strings"
)

const (
	cacheDirTagFileName  = "CACHEDIR.TAG"
	cacheDirTagSignature = "Signature: 8a477f597d28d172789f06886806bc55"
)

// isCleanableDir marks paths safe to delete manually (not handled by mo clean).
func isCleanableDir(path string) bool {
	if path == "" {
		return false
	}

	// Exclude paths mo clean already handles.
	if isHandledByMoClean(path) {
		return false
	}

	baseName := filepath.Base(path)

	// CACHEDIR.TAG marks the whole directory tree as regenerable cache.
	if hasValidCacheDirTag(path) {
		return true
	}

	// Project dependencies and build outputs are safe.
	if projectDependencyDirs[baseName] {
		return true
	}

	return false
}

func hasValidCacheDirTag(path string) bool {
	tagPath := filepath.Join(path, cacheDirTagFileName)
	info, err := os.Lstat(tagPath)
	if err != nil || !info.Mode().IsRegular() {
		return false
	}

	file, err := os.Open(tagPath)
	if err != nil {
		return false
	}
	defer func() {
		_ = file.Close()
	}()

	buf := make([]byte, len(cacheDirTagSignature))
	if _, err := io.ReadFull(file, buf); err != nil {
		return false
	}

	return string(buf) == cacheDirTagSignature
}

// isHandledByMoClean checks if a path is cleaned by mo clean.
func isHandledByMoClean(path string) bool {
	for _, fragment := range moCleanHandledPathFragments {
		if strings.Contains(path, fragment) {
			return true
		}
	}

	return false
}

var moCleanHandledPathFragments = []string{
	"/Library/Caches/",
	"/Library/Logs/",
	"/Library/Saved Application State/",
	"/.Trash/",
	"/Library/DiagnosticReports/",
}

// Project dependency and build directories.
var projectDependencyDirs = map[string]bool{
	// JavaScript/Node.
	"node_modules":     true,
	"bower_components": true,
	".yarn":            true,
	".pnpm-store":      true,

	// Python.
	"venv":               true,
	".venv":              true,
	"virtualenv":         true,
	"__pycache__":        true,
	".pytest_cache":      true,
	".mypy_cache":        true,
	".ruff_cache":        true,
	".tox":               true,
	".eggs":              true,
	"htmlcov":            true,
	".ipynb_checkpoints": true,

	// Ruby.
	"vendor":  true,
	".bundle": true,

	// Java/Kotlin/Scala.
	".gradle": true,
	"out":     true,

	// Build outputs.
	"build":         true,
	"dist":          true,
	"target":        true,
	".next":         true,
	".nuxt":         true,
	".output":       true,
	".parcel-cache": true,
	".turbo":        true,
	".vite":         true,
	".nx":           true,
	"coverage":      true,
	".coverage":     true,
	".nyc_output":   true,

	// Frontend framework outputs.
	".angular":    true,
	".svelte-kit": true,
	".astro":      true,
	".docusaurus": true,

	// Apple dev.
	"DerivedData": true,
	"Pods":        true,
	".build":      true,
	"Carthage":    true,
	".dart_tool":  true,

	// Other tools.
	".terraform": true,
}

```

## /cmd/analyze/cleanable_test.go

```go path="/cmd/analyze/cleanable_test.go" 
//go:build darwin

package main

import (
	"os"
	"path/filepath"
	"testing"
)

func writeCacheDirTag(t testing.TB, dir string, content string) {
	t.Helper()
	if err := os.WriteFile(filepath.Join(dir, cacheDirTagFileName), []byte(content), 0o644); err != nil {
		t.Fatalf("write cache dir tag: %v", err)
	}
}

func TestIsCleanableDirAcceptsValidCacheDirTag(t *testing.T) {
	dir := t.TempDir()
	writeCacheDirTag(t, dir, cacheDirTagSignature+"\n# https://bford.info/cachedir/")

	if !isCleanableDir(dir) {
		t.Fatalf("expected valid CACHEDIR.TAG directory to be cleanable")
	}
}

func TestIsCleanableDirRejectsInvalidCacheDirTag(t *testing.T) {
	tests := map[string]string{
		"wrong signature": "Signature: invalid",
		"short file":      cacheDirTagSignature[:len(cacheDirTagSignature)-1],
	}

	for name, content := range tests {
		t.Run(name, func(t *testing.T) {
			dir := t.TempDir()
			writeCacheDirTag(t, dir, content)

			if isCleanableDir(dir) {
				t.Fatalf("expected invalid CACHEDIR.TAG directory to stay non-cleanable")
			}
		})
	}
}

func TestIsCleanableDirRejectsSymlinkCacheDirTag(t *testing.T) {
	dir := t.TempDir()
	realTag := filepath.Join(dir, "real-tag")
	if err := os.WriteFile(realTag, []byte(cacheDirTagSignature), 0o644); err != nil {
		t.Fatalf("write real tag: %v", err)
	}
	if err := os.Symlink(realTag, filepath.Join(dir, cacheDirTagFileName)); err != nil {
		t.Fatalf("symlink cache dir tag: %v", err)
	}

	if isCleanableDir(dir) {
		t.Fatalf("expected symlink CACHEDIR.TAG directory to stay non-cleanable")
	}
}

```

## /cmd/analyze/live_config.go

```go path="/cmd/analyze/live_config.go" 
//go:build darwin

package main

import (
	"os"
	"strings"
)

const liveSortModeEnv = "MOLE_ANALYZE_LIVE_SORT"

func liveScanSortModeFromEnv() liveSortMode {
	switch strings.ToLower(strings.TrimSpace(os.Getenv(liveSortModeEnv))) {
	case "continuous":
		return liveSortContinuous
	default:
		return liveSortFreezeOnMove
	}
}

func nextLiveSortMode(mode liveSortMode) liveSortMode {
	if mode == liveSortContinuous {
		return liveSortFreezeOnMove
	}
	return liveSortContinuous
}

func liveSortModeLabel(mode liveSortMode) string {
	if mode == liveSortFreezeOnMove {
		return "freeze-on-move"
	}
	return "continuous"
}

```

## /cmd/analyze/main_stub.go

```go path="/cmd/analyze/main_stub.go" 
//go:build !darwin

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Fprintln(os.Stderr, "analyze is only supported on macOS")
	os.Exit(1)
}

```


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!