Planting a Future Breaking Change Today: A launchd Timer Job That Deletes Itself When Done A developer created a self-destructing launchd timer job that automatically updates a Claude configuration file on the Fable 5 end-of-life date (2026-07-07) and then unloads itself. The job uses a date gate, a jq rewrite with backup, and self-unload to ensure it runs exactly once on the target day without manual intervention. This is a follow-up to my earlier post, " Automating a config migration with a one-shot launchd job https://zenn.dev/bokuwalily/articles/one-shot-launchd-migration ." Some breaking changes come with a known expiration date, and you can prepare for them long before they land. This time the external event was the end-of-life of Fable 5 2026-07-07 , and I'll walk through how I designed a launchd job you set up today, that fires only on the target day, and that removes itself once it's done. The whole thing started with the thought, "manually fixing this on the shutdown day is going to be annoying." But I also didn't want to run a script every morning that needlessly rewrites JSON. What I landed on was a three-part set: a date gate, a jq rewrite with a backup, and self-unload. Right now, ~/.claude/settings.json looks like this: { "model": "claude-fable-5 1m ", ... } The moment I learned Fable 5 would end on 2026-07-07, creating a calendar reminder to manually rewrite this "model" felt too flimsy — I'll forget. On the other hand, making "a daemon that checks the date every time it boots" is overkill. What I wanted was a job I could set once and leave alone, that runs when the day arrives, and then disappears. launchd can fire at a specified time via StartCalendarInterval . But you can't express "just once at 9:00 on 7/7"; you need a combination of recurring and date-fixed slots. Specifying multiple slots and absorbing the redundancy with idempotency is the standard trick on macOS launchd. Here's the full ~/.claude/scripts/model-transition-0707.sh comments omitted . bash /bin/bash set -uo pipefail SETTINGS="$HOME/.claude/settings.json" LOG="$HOME/.claude/logs/model-transition.log" PLIST="$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist" log { echo " $ date '+%F %T' $ " "$LOG"; } ① 日付ゲート if "$ date +%Y%m%d " -lt 20260707 ; then log "skip: before 2026-07-07"; exit 0 fi ② バックアップ付き jq 書き換え 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" /usr/bin/osascript -e \ 'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"' \ /dev/null 2 &1 || true else log "no-op: model is already '$current'" fi ③ 自己 unload launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " Let me explain the three parts in order. if "$ date +%Y%m%d " -lt 20260707 ; then log "skip: before 2026-07-07"; exit 0 fi date +%Y%m%d can be compared as an integer since it's a numeric string. 20260706 < 20260707 → skip. That's all. Why this matters: the plist starts firing the instant you launchctl load it today. Even if the 6:50 AM slot fires on the same day you register it, you need it to be a no-op. Without the date gate, on the very day you plant it you'd hit "trying to rewrite even though the model isn't fable" — a misfire. date +%Y%m%d numeric comparison works as-is in macOS's /bin/bash . -lt is an arithmetic comparison, so when the string lengths are equal, lexicographic order and integer order give the same result. cp "$SETTINGS" "$SETTINGS.bak-model-transition" jq '.model = "opus"' "$SETTINGS" "$SETTINGS.tmp" \ && jq . "$SETTINGS.tmp" /dev/null \ && mv "$SETTINGS.tmp" "$SETTINGS" This splits into three steps. | Step | Purpose | |---|---| cp ... .bak-model-transition | Keep the original before rewriting | jq '.model = "opus"' .tmp | Write out to a temp file | jq . .tmp /dev/null | Verify the generated JSON isn't broken | mv .tmp settings.json | Replace the original only after verification passes | If you write jq ... settings.json settings.json directly, the original file is truncated to empty the moment the shell opens the redirect target. Going through a temp file is the basic pattern for avoiding redirect destruction. It's also important that the && chaining means mv isn't run if verification fails. Using grep -qi 'fable' for a case-insensitive check is so it handles "claude-fable-5 1m " and any future variant spellings. The value actually sitting in settings.json is this: "model": "claude-fable-5 1m " After the rewrite it becomes just "opus" an alias, not a model ID. This follows the "don't hardcode model IDs in scripts" policy from CLAUDE.md . launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " launchctl unload