DEV Community

Lily
Lily

Posted on Originally published at dev.to

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

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]",
  ...
}
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 with date +%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.

② 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"
Enter fullscreen mode Exit fullscreen mode

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]"
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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.

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>
Enter fullscreen mode Exit fullscreen mode

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 → 同上
Enter fullscreen mode Exit fullscreen mode

On 7/6 and earlier, each slot just leaves:

skip: before 2026-07-07
Enter fullscreen mode Exit fullscreen mode

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 (消滅)
Enter fullscreen mode Exit fullscreen mode

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%d comparison 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/ → when mv crosses 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's 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.

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 jq rewrite — "cp → jq > tmp → jq verify → mv" is the minimum safe four-step configuration
  • launchctl unload $PLIST after 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)