---
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:
1. Every generated cleanup script has a `--dry-run` flag and runs a dry-run before any live deletion
2. The generated script passes `validate-cleanup-script.sh` (no hardcoded paths, uses system tools only, has safety guards)
3. The plist installer uses `StartCalendarInterval` (not `StartInterval`) for human-readable schedules
4. The install sequence runs `launchctl bootout` (with `|| true`) before `bootstrap` to handle reinstalls
5. `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:

```bash
#!/usr/bin/env bash
# cleanup-<target>.sh — Auto-generated by launchd-auto-cleanup skill
# Usage: cleanup-<target>.sh [--dry-run]
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)"

# Find candidates
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):"
for item in "${CANDIDATES[@]}"; do
  echo "  $item"
done

if $DRY_RUN; then
  echo "$LOG_PREFIX DRY RUN — no deletions performed"
  exit 0
fi

for item in "${CANDIDATES[@]}"; do
  if [[ -e "$item" ]]; then
    /bin/rm -rf "$item"
    echo "$LOG_PREFIX Deleted: $item"
  fi
done

echo "$LOG_PREFIX Done"
```

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

```bash
#!/usr/bin/env bash
# install-<label>.sh — Installs launchd agent for cleanup-<target>
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"

# Install script
/bin/mkdir -p "$HOME/.local/bin"
/usr/bin/install -m 0755 "$SCRIPT_SRC" "$SCRIPT_DST"

# Write plist
/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"

# Load agent (bootout first to handle reinstall)
/bin/launchctl bootout "gui/$(/usr/bin/id -u)/${LABEL}" 2>/dev/null || true
/bin/launchctl bootstrap "gui/$(/usr/bin/id -u)" "$PLIST"
echo "Agent loaded: $LABEL"
```

### Phase 4: Dry-Run Verify

Run the cleanup script with `--dry-run` and review output:

```bash
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:
```bash
~/.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
bash <install_script_path>
```

Verify agent is loaded:
```bash
/bin/launchctl list | grep <label>
```

Run live once to confirm no errors:
```bash
bash <script_path>
```

Check log:
```bash
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:

```bash
~/.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:

- [ ] `set -euo pipefail` at top of script
- [ ] `--dry-run` flag handled and respected (no deletions when set)
- [ ] All paths use `$HOME` not `~` in variable assignments
- [ ] All external commands use absolute paths: `/usr/bin/find`, `/bin/rm`, `/usr/bin/wc`, etc.
- [ ] No homebrew tools (`brew`, `nvm`, `npm`, `node`, `python3` via homebrew) — launchd PATH is `/usr/bin:/bin:/usr/sbin:/sbin` only
- [ ] Existence check before `rm -rf` (check `[[ -e "$item" ]]` in the deletion loop)
- [ ] Directory existence check before operating on target
- [ ] Never delete `~/.codex/sessions`, `~/.claude/projects`, or any path modified in last 14 days without `APPROVED` env var set
- [ ] `.git/` guard: only delete a directory if you intend to delete a git clone; add `[[ -d "$item/.git" ]]` guard for git-clone targets
- [ ] Log every deletion to stdout (caller redirects to log file via plist)
- [ ] `-mindepth 1` in find to avoid deleting the target directory itself

---

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