```
├── .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.