Auto-Migrating Claude Code with a Throwaway launchd Job — a Script That Deletes Itself When Its Work Is Done A developer created a self-deleting launchd job that automatically migrates Claude Code's model setting from Fable 5 to Opus on the shutdown date of 2026-07-07. The script runs exactly once, updates settings.json with jq, sends a notification, then unloads itself via launchctl to avoid permanent no-op processes. This continues my "Claude Code environment" series that started with showing rate limits live in the status line https://zenn.dev/bokuwalily/articles/statusline-live-limits . This time it's a one-shot model-migration job — the "throwaway job" pattern, where the job removes its own plist with launchctl unload once it's finished . Fable 5 shuts down on 2026-07-07. If your Claude Code settings.json has model: fable-5 , it will keep pointing at that stale ID from that day onward. You could just run jq by hand, but missing the shutdown moment, or being mid-task and forgetting , is almost guaranteed to happen. So I handed it off to launchd. Writing a job that runs jq '.model = "opus"' ~/.claude/settings.json every day is trivial, but the day after the switch is done, a no-op process runs every single day. Having something written down permanently for a task you only do once just feels wrong. The ideal is a job that "runs exactly once on 07-07 and then disappears." It breaks into four steps. | Step | Purpose | |---|---| | Date guard | Neutralize catch-up firing and early launches | | jq surgery | Replace only the model key in settings.json | | osascript notification | Let a human know the unattended run happened | launchctl unload | De-register the job to make it single-use | Here is the full text of ~/.claude/scripts/model-transition-0707.sh . bash /bin/bash model-transition-0707.sh — 2026-07-07にFable 5が終了するため、settings.jsonのmodelをopusへ自動切替 冪等: 7/7以降かつ model が fable の時だけ書き換える。成功したら自分のplistをunload。 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"; } 7/7より前なら何もしない(catch-up発火対策) if "$ date +%Y%m%d " -lt 20260707 ; then log "skip: before 2026-07-07"; exit 0 fi 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 役目を終えたらジョブを外す(plistは残す=再登録可能) launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " if "$ date +%Y%m%d " -lt 20260707 ; then log "skip: before 2026-07-07"; exit 0 fi launchd will sometimes catch-up-fire scheduled jobs when the Mac boots. There's also the case where the first firing arrives right after you launchctl load . By comparing date +%Y%m%d as an integer against 20260707 , no matter when it's launched, it always bails out at zero cost if the date is before 07-07. cp "$SETTINGS" "$SETTINGS.bak-model-transition" jq '.model = "opus"' "$SETTINGS" "$SETTINGS.tmp" && jq . "$SETTINGS.tmp" /dev/null && mv "$SETTINGS.tmp" "$SETTINGS" Going through .tmp is so that if jq fails it won't corrupt the original file. After writing out, it verifies syntax with jq . and then does an atomic mv . Since there's a backup, worst case you can restore with cp "$SETTINGS.bak-model-transition" "$SETTINGS" . The reason for matching case-insensitively with grep -qi 'fable' is to catch any of fable-5 , claude-fable-5 , or FABLE . Using '.model // empty' makes it return an empty string instead of null when the model key itself is absent i.e. the default is in use , which prevents an unbound variable error under set -uo pipefail . launchctl unload "$PLIST" 2 /dev/null || true log "done job unloaded " Whether it switched the model or it was already opus no-op , whichever path it takes, it always reaches this line at the end. The || true absorbs the error when it's already been unloaded. :::message launchctl unload does not delete the plist file. ~/Library/LaunchAgents/com.shun.model-transition-0707.plist stays in place, so you can re-register it anytime with launchctl load . What's "throwaway" is only the job's registration state. ::: ~/Library/LaunchAgents/com.shun.model-transition-0707.plist home path shown with ~ notation :