A launchd Job That Fires Once on Deprecation Day, Then Deletes Itself — and the 4 Pitfalls I Hit A developer created a self-deleting launchd job for macOS that automatically updates the Claude model configuration when Fable 5 shuts down on 2026-07-07. The job uses a date gate, a jq-based JSON rewrite with backup, and a self-unload mechanism, addressing four pitfalls encountered during implementation. 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 ." This time the trigger is an external event with a known end-of-life date Fable 5 shutting down on 2026-07-07 , and the question is how to design a launchd job you can set up today, have it fire only on that day, and have it remove itself once it's done . Some deprecations come with a date stamped on them, and hand-editing a config on that exact day is the kind of chore you forget. But I also didn't want a script waking up every morning to rewrite the same JSON for no reason. What I landed on was a three-part set: a date gate, a backed-up jq rewrite, and a self-unload . Right now ~/.claude/settings.json says this: { "model": "claude-fable-5 1m ", ... } The moment I found out Fable 5 ends on 2026-07-07, putting a calendar reminder to hand-edit that "model" value felt too flimsy — I'd forget it. On the other hand, a daemon that checks the date on every launch is overkill. What I wanted was a job I could set once and stop thinking about, that fires when the day arrives and disappears afterward . launchd can fire at specified times via StartCalendarInterval . But there's no way to express "exactly once at 9:00 on 7/7" — you only get recurrence or fixed date components. The standard macOS launchd move is to specify multiple slots and absorb the duplication with idempotency. Here's ~/.claude/scripts/model-transition-0707.sh in full comments trimmed . 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's walk through 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 produces a numeric string you can compare as an integer. 20260706 < 20260707 → skip. That's all there is to it. Why does this matter? Because the plist starts firing the instant you launchctl load it today. If the 6:50 AM slot comes around right after registration, that firing needs to be a no-op. Without the date gate, you'd get a misfire on the very day you plant the job: it would try to rewrite the model even though the value isn't fable yet. Note Numeric comparison with date +%Y%m%d works as-is under macOS's /bin/bash . -lt is an arithmetic comparison, so as long as the strings are the same length, lexicographic and integer ordering give the same result. jq rewrite with a backup cp "$SETTINGS" "$SETTINGS.bak-model-transition" jq '.model = "opus"' "$SETTINGS" "$SETTINGS.tmp" \ && jq . "$SETTINGS.tmp" /dev/null \ && mv "$SETTINGS.tmp" "$SETTINGS" This breaks into three steps. | Step | Purpose | |---|---| cp ... .bak-model-transition | Keep the original as it was before the rewrite | jq '.model = "opus"' .tmp | Write out to a temp file | jq . .tmp /dev/null | Verify the generated JSON isn't corrupt | 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 also matters that the && chaining means mv never runs if verification fails. The reason I test with grep -qi 'fable' — case-insensitive — is to cover "claude-fable-5 1m " as well as any future variant spelling. Here's the value actually sitting in settings.json: "model": "claude-fable-5 1m " After the rewrite it's just "opus" an alias, not a model ID — this follows the "don't hardcode model IDs in scripts" policy from my CLAUDE.md . launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " launchctl unload