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.
The problem: on the day I learn about a deprecation, I want to plant a job that runs only on the deprecation day
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.
The implementation: the three-part set
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
# ② バックアップ付き 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.
① A date gate to block early firings
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%dworks as-is under macOS's/bin/bash.-ltis an arithmetic comparison, so as long as the strings are the same length, lexicographic and integer ordering give the same result.
② A 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).
③ Deleting itself after success
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 unloaddetaches 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.
The plist: why three slots a day is fine
<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.
Pitfalls I hit
-
I'd written the
date +%Y%m%dcomparison with a string<→ inside bash's[[ ]], 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. -
I'd put the temp file in
/tmp/→ whenmvcrosses filesystems, the rename can fail. Putting it in the same directory ($HOME/.claude/) guarantees the same fs. -
I'd passed the label as the argument to
launchctl unload→ you have to pass the plist's full path, not the label (com.shun.model-transition-0707), or you get "No such process." -
I'd only set
StandardErrorPath→ the script'slog()writes to its own log file, butStandardErrorPathis still needed as the destination for output when the script itself dies on a syntax error.
Summary
-
A date gate,
[ "$(date +%Y%m%d)" -lt YYYYMMDD ], turns every firing between setup day and the target date into a skip -
A backed-up
jqrewrite — "cp → jq > tmp → jq verify → mv" is the minimum safe four-step configuration -
launchctl unload $PLISTafter success detaches the job. The plist remains, so re-registering is possible - Multiple plist slots are sleep insurance. Idempotency is what makes over-specifying them safe
For 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*
Top comments (0)