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.
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
.
#!/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
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
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):
<?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.
:::
launchctl load ~/Library/LaunchAgents/com.shun.model-transition-0707.plist
launchctl list | grep model-transition
~/.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.
set -uo pipefail
, a missing model
key kills the script with an unbound variable'.model // empty'
return an empty string. Even if grep -qi
gets an empty string, it's false and bails, so that's fine..tmp
is left behind by a kill, the next launch references the old contentsmv
is 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 .tmp
if it exists at launch.launchctl unload
in an already-unloaded state exits with an error|| true
./opt/homebrew/bin
in the plist's EnvironmentVariables.PATH
./usr/bin/osascript
. >/dev/null 2>&1 || true
keeps a failed notification from halting the whole script.date +%Y%m%d
) neutralizes catch-up firing and early launches..tmp
launchctl unload "$PLIST"
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*