cd /news/developer-tools/a-launchd-job-that-fires-once-on-dep… · home topics developer-tools article
[ARTICLE · art-114778] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

A launchd Job That Fires Once on Deprecation Day, Then Deletes Itself — and the 4 Pitfalls I Hit

A developer created a self-deleting launchd job for macOS that automatically updates the Claude model configuration when Fable 5 shuts down on 2026-07-07. The job uses a date gate, a jq-based JSON rewrite with backup, and a self-unload mechanism, addressing four pitfalls encountered during implementation.

read6 min views1 publishedAug 29, 2026

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*

── more in #developer-tools 4 stories · sorted by recency
── more on @launchd 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-launchd-job-that-f…] indexed:0 read:6min 2026-08-29 ·