This continues my "Claude Code environment" series that started with showing rate limits live in the status line. 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.
The problem: writing it in cron means it "keeps running after its job is done"
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."
Design: date guard + jq surgery + notification + self-destruct
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 |
The script itself
Here is the full text of ~/.claude/scripts/model-transition-0707.sh.
#!/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)"
① Date guard
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.
② jq surgery that touches exactly one key
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.
③ Self-destruct
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.
:::
Assembling the plist (three triggers a day)
~/Library/LaunchAgents/com.shun.model-transition-0707.plist (home path shown with ~ notation):
<?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>
Three times a day (06:50 / 12:50 / 20:50) are specified via the StartCalendarInterval array. Even if you miss the morning firing on 07-07, you can catch it at noon or in the evening. Because of the date guard, everything before 07-07 is skipped, and on and after 07-07 the first run does the switch and immediately self-destructs, so no later firings arrive.
Making StartCalendarInterval an array lets you bundle multiple times into a single plist. Be careful not to confuse it with the seconds-interval StartInterval.
:::message
The paths written in ProgramArguments need to be full paths, because launchd does not expand ~. The ~ notation above is for explanation; the actual plist needs to contain the paths with $HOME already expanded.
:::
Registration and verification commands
# 登録
launchctl load ~/Library/LaunchAgents/com.shun.model-transition-0707.plist
# 確認
launchctl list | grep model-transition
# 手動テスト(07-07より前なら "skip: before 2026-07-07" でログに記録されて終わる)
~/.claude/scripts/model-transition-0707.sh
tail -5 ~/.claude/logs/model-transition.log
If you want to try the actual switch locally, temporarily comment out the date-guard line in the script and run it (a backup is taken in settings.json.bak-model-transition).
The log from a normal firing at 06:50 on 07-07:
[2026-07-07 06:50:02] switched model: claude-fable-5 -> opus
[2026-07-07 06:50:02] done (job unloaded)
It's done in two lines, and because the job is gone, the later 12:50 and 20:50 don't fire.
Pitfalls I hit
-
Under
set -uo pipefail, a missingmodelkey kills the script with an unbound variable → have'.model // empty'return an empty string. Even ifgrep -qigets an empty string, it's false and bails, so that's fine. -
If jq's
.tmpis left behind by a kill, the next launch references the old contents →mvis only reached after the syntax check, so it normally isn't left behind, but if you're worried you could add a guard at the top that deletes.tmpif it exists at launch. -
Running
launchctl unloadin an already-unloaded state exits with an error → absorbed with|| true. -
launchd's default PATH can't find jq → explicitly set
/opt/homebrew/binin the plist'sEnvironmentVariables.PATH. -
The osascript notification doesn't show up → when calling it from launchd, use the full path
/usr/bin/osascript.>/dev/null 2>&1 || truekeeps a failed notification from halting the whole script.
Summary
-
A date guard (integer comparison of
date +%Y%m%d) neutralizes catch-up firing and early launches. -
jq's atomic rewrite via
.tmpkeeps settings.json from being corrupted. - osascript notification makes the unattended completion visible to a human.
- Placing
launchctl unload "$PLIST"on the final line makes it a one-time-only job on either path — success or no-op.
A "time-limited one-shot job" isn't limited to model migration — it's a pattern usable for any work with a fixed end: deprecations, campaign periods, migration schedules, and the like. You can handle the next migration just by changing the plist's label, date, and path.
Next time I plan to write about managing these launchd jobs as they accumulate — how to take stock of a ~/Library/LaunchAgents/ cluttered with scripts.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)