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

> Source: <https://dev.to/bokuwalily/a-launchd-job-that-fires-once-on-deprecation-day-then-deletes-itself-and-the-4-pitfalls-i-hit-3p3f>
> Published: 2026-08-29 00:00:04+00:00

This is a follow-up to my earlier post, "[Automating a config migration with a one-shot launchd job](https://zenn.dev/bokuwalily/articles/one-shot-launchd-migration)." 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).

``` bash
#!/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.

```
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 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.

`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 skip`jq`

rewrite`launchctl 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](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
