Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .mole-cli-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.30.0
1.43.1
4 changes: 2 additions & 2 deletions MoleUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.4;
MARKETING_VERSION = 0.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI;
PRODUCT_NAME = "Mole UI";
SDKROOT = macosx;
Expand Down Expand Up @@ -470,7 +470,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.4;
MARKETING_VERSION = 0.1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI;
PRODUCT_NAME = "Mole UI";
SDKROOT = macosx;
Expand Down
2 changes: 1 addition & 1 deletion MoleUI/.mole-cli-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.30.0
1.43.1
76 changes: 76 additions & 0 deletions Resources/mole/.claude/agents/bash32-portability-reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
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 example: `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=()` in `lib/manage/whitelist.sh`.
**Fix**: guard the expansion with `[[ ${#arr[@]} -gt 0 ]] && ...`.

How to find: grep the diff for `"${[A-Z_]*\[@\]}"` and check whether the surrounding array is initialized as possibly empty.

### 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.
70 changes: 70 additions & 0 deletions Resources/mole/.claude/agents/safety-reviewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
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, and operation logging. 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.

## 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`.
- 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.

## 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.
32 changes: 32 additions & 0 deletions Resources/mole/.claude/hooks/format-on-edit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/bash
# Format the just-edited file with the project's configured formatters.
# Invoked by .claude/settings.json as a PostToolUse hook on Edit/MultiEdit/Write.
# Stdin is the Claude Code hook payload (JSON). Failures must not block the edit.

set -u

# Extract file path from hook payload. Tolerate missing jq.
if ! command -v jq > /dev/null 2>&1; then
exit 0
fi

FILE=$(jq -r '.tool_input.file_path // empty' 2> /dev/null || true)
[[ -z "$FILE" ]] && exit 0
[[ ! -f "$FILE" ]] && exit 0

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

exit 0
15 changes: 15 additions & 0 deletions Resources/mole/.claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh"
}
]
}
]
}
}
101 changes: 101 additions & 0 deletions Resources/mole/.claude/skills/release-notes/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
name: release-notes
description: Publish curated release notes for a Mole `V<version>` tag. Encodes the V1.37.0+ bilingual format, the gh release edit (not create) flow, sponsors graphql query, 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. **Sponsors**. Run `scripts/sponsors.sh` from this skill dir.
6. **Contributors in this range**. `git log <previous-tag>..V<version> --pretty='%an' | sort -u`. Exclude `tw93` and bots.
7. **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 CLAUDE.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 V1.37.0+ shape. Compare against a recent release if unsure:
`gh release view V1.37.0 --repo tw93/Mole --json body --jq .body`.

Structure:

```
## What's new in V<version> <emoji>

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

## V<version> 更新内容 <emoji>

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

## Thanks 💖

Sponsors: <@handle1> <@handle2> ...
Contributors: <@handle1> <@handle2> ...

> Mole · macOS cleanup · https://github.com/tw93/Mole
```

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

- **No em dash anywhere**. Use commas, periods, colons, semicolons, or parentheses.
- **No emoji except the version emoji** in the two section headers and `💖` in the Thanks header.
- **No inline PR refs, no inline `@handle` thanks**. PRs and people belong in the closing 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.
- **Verify every command mentioned in the notes actually exists in HEAD**. CLAUDE.md cites `mo check / mo doctor` as a case where a removed command nearly shipped as a "feature".
- **Pick icons that match the action, not the category**. Broom (🧹) implies "safe to delete" — never use it on rows like iOS Backups, Xcode Archives, Old Downloads where that's a false promise. Eyes (👀) is the safe "look here" choice.

## 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: `bash 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

- `scripts/sponsors.sh` — fetches the 30 most recent sponsors via `gh api graphql`. Uses the minimal query that works on a token without `read:user` scope.
- `scripts/post-reactions.sh <tag>` — adds the six reactions (`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`) to the release.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/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)"
24 changes: 24 additions & 0 deletions Resources/mole/.claude/skills/release-notes/scripts/sponsors.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/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'
Loading