AGENT SKILL

launchd-auto-cleanup

Create macOS launchd cleanup agents with dry-run verification. Writes cleanup script + plist installer, verifies with --dry-run, then installs and runs live.

License 许可
MIT
最近更新
2026-11-14
Stars
★ 6
Forks
0

01 前言 Front Matter

name
launchd-auto-cleanup
context
inline
triggers
[ "automate cleanup", "launchd cleanup job", "schedule cleanup", "cleanup job", "auto clean", "recurring cleanup", "create launchd agent", "install launchd plist" ]
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
allowed-tools
Read, Write, Edit, Bash

02 资源文件 5

03 SKILL.md 正文

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:

#!/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:

#!/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 <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:

  • [ ] 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

04 同仓库更多 Skills