| name | launchd-auto-cleanup |
| description | Create macOS launchd cleanup agents with dry-run verification. Writes cleanup script + plist installer, verifies with --dry-run, then installs and runs live. |
| when_to_use | When user wants to automate recurring cleanup of a directory, cache, or temp files on macOS via launchd |
| triggers | ["automate cleanup","launchd cleanup job","schedule cleanup","cleanup job","auto clean","recurring cleanup","create launchd agent","install launchd plist"] |
| allowed-tools | Read, Write, Edit, Bash |
| context | inline |
launchd-auto-cleanup
Contract
This skill guarantees:
- Every generated cleanup script has a
--dry-run flag and runs a dry-run before any live deletion
- The generated script passes
validate-cleanup-script.sh (no hardcoded paths, uses system tools only, has safety guards)
- The plist installer uses
StartCalendarInterval (not StartInterval) for human-readable schedules
- The install sequence runs
launchctl bootout (with || true) before bootstrap to handle reinstalls
validate-cleanup-script.sh is always run before reporting done
10-Item Checklist Status
| # | Item | Status |
|---|
| 1 | SKILL.md | ✓ present |
| 2 | Code (validate-cleanup-script.sh) | ✓ present |
| 3 | Unit tests (test_validate.sh) | ✓ present |
| 4 | Integration tests | N/A — scripts are deterministic, LLM generates them; no live endpoints |
| 5 | LLM evals | N/A — cleanup targets vary by user; contract is structural not semantic |
| 6 | Resolver trigger | pending — no RESOLVER.md found at ~/.claude/skills/RESOLVER.md |
| 7 | Resolver trigger eval | pending (depends on #6) |
| 8 | check-resolvable | pending (depends on #6) |
| 9 | E2E test | pending — requires macOS launchd context |
| 10 | Brain filing | N/A — skill does not write to brain |
Items 4–5 are intentionally N/A: the skill's output is structurally deterministic (a bash script); quality is verified by validate-cleanup-script.sh and dry-run, not LLM evals. Items 6–9 are pending RESOLVER.md infrastructure.
Phases
Phase 1: Audit Target
Before writing any files, gather:
- Target path: directory, file pattern, or cache location to clean (e.g.
~/.cache/opencode/bin/, /tmp/*.log)
- Retention rule: age threshold in days (e.g. "older than 30 days") or size threshold
- Schedule: human schedule (e.g. "every Sunday at 3am", "daily at 2am")
- Label: reverse-DNS launchd label (e.g.
com.user.cleanup-opencode-bin)
- Script path: where to install the cleanup script (e.g.
~/.local/bin/cleanup-opencode-bin.sh)
Ask the user for any missing items before writing files.
Phase 2: Write Cleanup Script
Write <script_path> with:
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
TARGET_DIR="$HOME/<relative/path>"
RETENTION_DAYS=<N>
LOG_PREFIX="[cleanup-<target>]"
if [[ ! -d "$TARGET_DIR" ]]; then
echo "$LOG_PREFIX Target dir does not exist: $TARGET_DIR — skipping"
exit 0
fi
echo "$LOG_PREFIX Starting. DRY_RUN=$DRY_RUN"
echo "$LOG_PREFIX Target: $TARGET_DIR (older than ${RETENTION_DAYS} days)"
mapfile -t CANDIDATES < <(
/usr/bin/find "$TARGET_DIR" -maxdepth 1 -mindepth 1 \
-mtime +"$RETENTION_DAYS" 2>/dev/null
)
if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
echo "$LOG_PREFIX No candidates found — nothing to do"
exit 0
fi
echo "$LOG_PREFIX Found ${#CANDIDATES[@]} candidate(s):"
item ;
;
0
item ;
[[ -e ]];
/bin/rm -rf
Key rules for the script body:
- Use
$HOME not ~ in variable assignments (both work in bash but $HOME is explicit and safe in all contexts)
- Use absolute paths for all external commands:
/usr/bin/find, /bin/rm, /usr/bin/wc, etc.
-maxdepth 1 -mindepth 1 to avoid deleting the target dir itself
- Always check
[[ -d "$TARGET_DIR" ]] before operating
- Log every action to stdout (plist's
StandardOutPath captures it)
Phase 3: Write Install Script
Write install-<label>.sh:
#!/usr/bin/env bash
set -euo pipefail
LABEL="<com.user.label>"
SCRIPT_SRC="$(cd "$(dirname "$0")" && pwd)/cleanup-<target>.sh"
SCRIPT_DST="$HOME/.local/bin/cleanup-<target>.sh"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
LOG_DIR="$HOME/Library/Logs"
/bin/mkdir -p "$HOME/.local/bin"
/usr/bin/install -m 0755 "$SCRIPT_SRC" "$SCRIPT_DST"
/bin/cat > "$PLIST" << PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${SCRIPT_DST}</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>0</integer>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>${LOG_DIR}/${LABEL}.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/${LABEL}.error.log</string>
</dict>
</plist>
PLIST_EOF
echo "Installed plist: $PLIST"
echo "Installed script: $SCRIPT_DST"
/bin/launchctl bootout "gui/$(/usr/bin/id -u)/${LABEL}" 2>/dev/null ||
/bin/launchctl bootstrap
Phase 4: Dry-Run Verify
Run the cleanup script with --dry-run and review output:
bash <script_path> --dry-run
Expected output:
- Lists candidate files/dirs it would delete
- Ends with "DRY RUN — no deletions performed"
- Zero exit code
If dry-run output shows unexpected targets (e.g. the opencode bin/ itself, not just its contents), fix find flags (-mindepth 1) before proceeding.
Also run the validator:
~/.claude/skills/launchd-auto-cleanup/validate-cleanup-script.sh <script_path>
Do not proceed to Phase 5 until both pass.
Phase 5: Install and Run Live
bash <install_script_path>
Verify agent is loaded:
/bin/launchctl list | grep <label>
Run live once to confirm no errors:
bash <script_path>
Check log:
tail -20 ~/Library/Logs/<label>.log
Output Format
Report at completion:
launchd-auto-cleanup complete
Script: ~/.local/bin/<name>.sh
Plist: ~/Library/LaunchAgents/<label>.plist
Schedule: <human schedule>
Dry-run: PASS (<N> candidates listed)
Validate: PASS
Live run: PASS (deleted N items) | PASS (nothing to delete)
Agent: loaded (launchctl list shows <label>)
Dry-Run Gate Protocol
Every cleanup script must be verified with --dry-run before any live run. This is a hard gate — not a suggestion.
What a compliant dry-run looks like
[2026-05-22 04:05:01] === Section 1: ~/.gemini/tmp/ ===
[2026-05-22 04:05:01] gemini tmp: before 3.0G ($HOME/.gemini/tmp)
[2026-05-22 04:05:01] gemini tmp: [dry-run] would delete contents of $HOME/.gemini/tmp
...
[2026-05-22 04:05:02] === DRY-RUN complete — no files deleted ===
Key signals to check in dry-run output:
- Every intended target appears in the output
- No unexpected targets (especially parent dirs, binaries, or active data dirs)
- "DRY-RUN complete" or "DRY RUN — no deletions performed" at the end
- Exit code 0
Red flags that require stopping and fixing before live run
| Red flag | Root cause | Fix |
|---|
| Target dir itself listed as candidate | Missing -mindepth 1 in find | Add -mindepth 1 to exclude the root |
Binary or bin/ subdir listed | Over-broad find scope | Use explicit SAFE_SUBDIRS allowlist |
| Protected path appears | No protection check | Add guard: [[ "$item" == *"/.codex/sessions"* ]] && continue |
| No output at all | Wrong path, wrong mtime, dir empty | Verify with ls $TARGET manually |
| Non-zero exit | Script syntax/logic error | Fix before proceeding |
Dry-run enforcement in validate-cleanup-script.sh
The validator (~/.claude/skills/launchd-auto-cleanup/validate-cleanup-script.sh) checks that --dry-run is present in the script source. Run it before installing any script:
~/.claude/skills/launchd-auto-cleanup/validate-cleanup-script.sh <script_path>
If the validator passes but dry-run output looks wrong, the validator cannot catch runtime behavior — review the dry-run output manually.
Safety Guard Checklist
Every generated cleanup script MUST satisfy all of the following. validate-cleanup-script.sh checks the mechanical ones:
Common launchd Failure Patterns
1. StartInterval vs StartCalendarInterval
StartInterval takes seconds (integer) — fires every N seconds from boot
StartCalendarInterval takes a dict (Hour, Minute, Weekday, Day, Month) — cron-style
- Use
StartCalendarInterval for human schedules ("every Sunday at 3am")
- Common mistake: using
StartInterval 604800 (weekly) looks right but fires from boot time, not at a fixed clock time
2. RunAtLoad trap
RunAtLoad: true fires the script on every login AND on the schedule
- Omit
RunAtLoad unless you explicitly want the job to run at login/bootstrap
- If present accidentally, it causes unexpected runs on every reboot
3. PATH is minimal
- launchd agent PATH:
/usr/bin:/bin:/usr/sbin:/sbin only
brew, nvm, npm, node, python3 (homebrew), rtk — all unavailable
- Fix: use absolute paths for every external command in the script
4. Script not executable
chmod +x is not sufficient for cross-machine installs
- Use
/usr/bin/install -m 0755 src dst in the install script
5. Log dir must exist
StandardOutPath and StandardErrorPath dirs must exist before the agent runs
~/Library/Logs/ always exists on macOS — safe default
6. bootout before bootstrap for reinstalls
- If agent is already loaded,
launchctl bootstrap errors
- Pattern:
launchctl bootout "gui/$(id -u)/<label>" 2>/dev/null || true before bootstrap
7. GUI vs system agents
- User agents:
~/Library/LaunchAgents/ bootstrapped under gui/<uid>
- System agents:
/Library/LaunchDaemons/ bootstrapped under system
- Never use
launchctl load/unload (deprecated in macOS 10.11+)
8. The opencode bin/ pattern (real incident)
- Target was
~/.cache/opencode/bin/ (directory contents, not the dir itself)
- Script used
find $TARGET -mtime +30 with no -mindepth 1
- Dry-run showed
~/.cache/opencode/bin itself as a candidate (find with depth 0 includes the root)
- Fix: always add
-mindepth 1 to exclude the target directory from results