DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Retiring a Claude Code Model Unattended with launchd: A Self-Destructing Job That Auto-Patches settings.json

This is another entry in my "Claude Code environment" series. Last time, in Multi-slot launchd retry design, I wrote about the "idempotent morning/noon/night triple-fire" pattern. This time I'm applying it to something concrete: the "disposable migration job" I set up ahead of Fable 5's end of life (2026-07-07), and I'm publishing the full implementation.

It rewrites the model field in settings.json with jq, fires a desktop notification, and finally removes its own registration with launchctl unload and disappears. The design wraps launch, execution, and self-destruction into a single script. This isn't specific to model migration — it's a general-purpose pattern for "a job that changes a setting exactly once on a specific day and then vanishes," so I'll show all of the actual code.

The problem: model EOL breaks silently

If your environment has "model": "claude-fable-5" written in Claude Code's ~/.claude/settings.json, and that setting lingers past the EOL date, you quietly end up with unintended behavior. No error is raised — it might be running on a fallback or a different model — but you lose track of which model you're actually using.

Opening settings.json by hand and editing it is the simplest approach, but getting a human to line up "on the exact day," "without forgetting," and "exactly once" is surprisingly hard. I delegated it to launchd and automated it.

Design: complete in three steps

1. Date guard        → if before 7/7, exit 0 immediately (catch-up firing defense)
2. Patch settings.json → backup → jq rewrite → JSON validation → mv
3. Notify + self-unload → notify via osascript → remove self with launchctl unload
Enter fullscreen mode Exit fullscreen mode

The core of "disposable" is that step 3 unregisters the job itself. This ensures there's no firing from that point forward. However, the plist file is not deleted — it stays in ~/Library/LaunchAgents/. I keep it so that running launchctl load again can bring it back (a comment in the script even notes "keep the plist = re-registerable").

Date guard: neutralizing early firing

launchd has a catch-up behavior where, when a Mac boots up, it retroactively runs past slots that "should have fired." If you register the job before 7/7, there's a chance it fires early at the moment of a Mac reboot. The very first line stops this.

# Do nothing if before 7/7 (catch-up firing defense)
if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
  log "skip: before 2026-07-07"; exit 0
fi
Enter fullscreen mode Exit fullscreen mode

date +%Y%m%d returns an integer in 20260707 format, so you can decide with a numeric comparison. The actual processing only runs for the first time on or after 7/7.

Without this date guard, the script runs every time you register it before 7/7 and reboot the Mac. Catch-up is behavior specific to StartCalendarInterval jobs, so always keep it in mind when building scheduled jobs.

Patching settings.json: an atomic four-step operation

The model rewrite is done as an atomic four-step operation.

current=$(jq -r '.model // empty' "$SETTINGS")
if echo "$current" | grep -qi 'fable'; then
  cp "$SETTINGS" "$SETTINGS.bak-model-transition"
  jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" \
    && jq . "$SETTINGS.tmp" > /dev/null \
    && mv "$SETTINGS.tmp" "$SETTINGS"
  log "switched model: $current -> opus"
else
  log "no-op: model is already '$current'"
fi
Enter fullscreen mode Exit fullscreen mode

The intent of each step:

  • jq -r '.model // empty' — returns an empty string if the .model key doesn't exist. This keeps the string null from flowing downstream.
  • grep -qi 'fable' — rewrites only when the value contains fable, case-insensitively. If it has already been migrated to another model, it falls into the else branch, writes only a no-op log, and exits (idempotent).
  • cp "$SETTINGS" "$SETTINGS.bak-model-transition" — a backup taken before the rewrite, for recovery on failure.
  • jq . "$SETTINGS.tmp" > /dev/null — validates that the rewritten .tmp is valid JSON before the mv. This keeps broken JSON from being placed on the production path.

Since the script declares set -uo pipefail at the top, if any part of the && chain fails, the mv doesn't run.

The reason for inserting jq . file > /dev/null validation is to stop the mv from .tmp to production if jq's output somehow becomes broken JSON. With this structure — which embeds a string literal instead of using --argjson — it's actually unlikely to happen, but keeping it as a habit makes it safe when reusing the pattern in other rewrite scripts.

Notification and self-unload

Once the rewrite is complete, it sends a desktop notification via osascript.

/usr/bin/osascript -e 'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"' \
  >/dev/null 2>&1 || true
Enter fullscreen mode Exit fullscreen mode

The || true is there because I don't want a failed notification to mark the whole job as an error. The notification is purely a report to a human, not the main body of the work.

Once its job is done, it unloads itself.

# Once the job is done, remove it (keep the plist = re-registerable)
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
Enter fullscreen mode Exit fullscreen mode

The PLIST variable is defined at the top of the script as PLIST="$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist". launchctl unload does not delete the plist file, so the file remains in ~/Library/LaunchAgents/. Running launchctl load "$PLIST" next time re-registers it instantly.

Wiring the plist: idempotent triple-slot firing

<?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>com.shun.model-transition-0707</string>
  <key>ProgramArguments</key><array>
    <string>/bin/bash</string>
    <string>~/.claude/scripts/model-transition-0707.sh</string>
  </array>
  <key>StartCalendarInterval</key><array>
    <dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>50</integer></dict>
    <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>50</integer></dict>
    <dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>50</integer></dict>
  </array>
  <key>EnvironmentVariables</key><dict>
    <key>PATH</key>
    <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
  </dict>
  <key>StandardErrorPath</key><string>~/.claude/logs/model-transition.err</string>
</dict></plist>
Enter fullscreen mode Exit fullscreen mode

Three slots a day, at 6:50, 12:50, and 20:50. It's a direct application of the multi-slot design from my previous article. Once the first run completes and self-unloads, the second and later runs don't fire. Even if the Mac is asleep and misses 6:50, 12:50 and 20:50 can pick it up.

Because StandardErrorPath is set in the plist, you don't need to write stderr redirection inside the script. When debugging, look at ~/.claude/logs/model-transition.err.

Pitfalls I hit

  • jq not on PATH, exit 127 → if /opt/homebrew/bin isn't in the plist's EnvironmentVariables.PATH, the Homebrew-installed jq isn't found. This is the classic pattern where manual runs from the terminal succeed but only launchd breaks. A staple Apple Silicon trap.
  • Catch-up firing during a test before adding the date guard → when I loaded it before 7/7 for a functionality check, a catch-up ran on Mac reboot and the migration completed earlier than 7/7. The date guard isn't an afterthought — it's mandatory from the start.
  • Misbehavior on a settings.json without the .model key → with jq -r '.model', the string null is returned; grep -qi 'fable' would fail to match and be fine, but considering future key-name changes or omissions, I made the empty-string fallback explicit with // empty.
  • Worried whether it would re-fire on the next slot after self-unload → the moment launchctl unload runs, that job's schedule is gone. It won't re-fire without another launchctl load.
  • Losing track of where stderr goes → without setting StandardErrorPath, launchd discards stderr to /dev/null or some unpredictable place. That makes debugging impossible, so always set it.

As a general-purpose pattern

This job looks specific to Fable 5's EOL, but it can be reused as-is whenever these three requirements line up.

Requirement Implementation in this job
Run exactly once on or after a specific day date guard via date +%Y%m%d comparison
Idempotent (same result no matter how many times it runs) check current value before rewriting
Automatically disappear once complete launchctl unload "$PLIST"

Application examples:

  • Toggle a config feature flag ON/OFF on or after a specific day
  • Rewrite an expired API endpoint to a new URL
  • Schema migration of JSON settings that accompanies a version upgrade

A "cron that should run only once" fires every time when persistently registered with cron or launchd, making idempotency tedious to guarantee. Designing it to "disappear once it runs" via self-unload is simpler than writing dedupe handling for a persistent job.

Summary

  • Date guard (date +%Y%m%d comparison) neutralizes catch-up firing — include it from the start.
  • jq patching is an atomic four-step operation: backup → rewrite → JSON validation → mv.
  • Current-value check (grep -qi 'fable') makes it idempotent. It's safe to re-run even in an already-migrated environment.
  • launchctl unload "$PLIST" for self-unload — keep the plist so it can be re-registered.
  • Multi-slot plist (6:50 / 12:50 / 20:50) guards against slots skipped during sleep.
  • If you use the Homebrew-installed jq, don't forget /opt/homebrew/bin in the plist's PATH.

Next time, I plan to write about automation-health.sh, which checks the liveness of the entire launchd setup — including disposable jobs like this one — with a single command.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)