This is a follow-up to my earlier post, "Automating a config migration with a one-shot launchd job." 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).
#!/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)"
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 withdate +%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 <plist>
detaches that job from the daemon. The plist file itself stays on disk, so you can re-register it with launchctl load
if you need to.
The 2>/dev/null || true
is there so an already-unloaded state doesn't abort with an error. Combined with the idempotent design described below, it guarantees the script is safe no matter how many times it's called.
Warning
launchctl unload
detaches the job immediately, even while it's running. That's exactly why the call sits at the end of the script — if you unload before finishing the rewrite, you cut yourself off mid-operation.
<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>
Three slots: 6:50, 12:50, and 20:50. Why not just one? Because launchd skips slots that fall while the Mac is asleep. If I sleep through the morning slot, the midday or evening one can still pick it up.
The firing flow on 7/7 looks like this:
6:50 → 日付ゲート通過 → fable 検出 → opus に書き換え → unload → ジョブ消滅
12:50 → ジョブが存在しないので発火しない(unload済み)
20:50 → 同上
On 7/6 and earlier, each slot just leaves:
skip: before 2026-07-07
in the log and exits 0 immediately. No rewrite at all.
Drawn out, it looks like this:
7/5 7/6 7/7
6:50 skip 6:50 skip 6:50 書換+unload ←ここで終了
12:50 skip 12:50 skip 12:50 (消滅)
20:50 skip 20:50 skip 20:50 (消滅)
Idempotency is what makes "configure multiple slots and reject early firings with the date gate" work.
date +%Y%m%d
comparison with a string <
[[ ]]
, that's lexicographic ordering, so I switched to -lt
. With consistent 8-digit zero padding there's no actual harm, but use the arithmetic comparison that states the intent clearly./tmp/
mv
crosses filesystems, the rename can fail. Putting it in the same directory ($HOME/.claude/
) guarantees the same fs.launchctl unload
com.shun.model-transition-0707
), or you get "No such process."StandardErrorPath
log()
writes to its own log file, but StandardErrorPath
is still needed as the destination for output when the script itself dies on a syntax error.[ "$(date +%Y%m%d)" -lt YYYYMMDD ]
, turns every firing between setup day and the target date into a skipjq
rewritelaunchctl unload $PLIST
after successFor anything with a fixed deprecation date, the best move is to plant it the day you find out and then forget about it. It's more reliable than a calendar entry, and easier to cancel than cron.
Next time I might write about how to read the logs this job leaves behind to confirm the migration succeeded — or, if it failed, the recovery procedure from the backup.
What deprecation date do you currently have sitting in a calendar reminder instead of in a script?
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*