# Auto-Migrating Claude Code with a Throwaway launchd Job — a Script That Deletes Itself When Its Work Is Done

> Source: <https://dev.to/bokuwalily/auto-migrating-claude-code-with-a-throwaway-launchd-job-a-script-that-deletes-itself-when-its-e7k>
> Published: 2026-07-18 11:00:05+00:00

This continues my "Claude Code environment" series that started with [showing rate limits live in the status line](https://zenn.dev/bokuwalily/articles/statusline-live-limits). This time it's a one-shot model-migration job — the **"throwaway job" pattern, where the job removes its own plist with launchctl unload once it's finished**.

Fable 5 shuts down on 2026-07-07. If your Claude Code `settings.json`

has `model: fable-5`

, it will keep pointing at that stale ID from that day onward. You could just run `jq`

by hand, but **missing the shutdown moment, or being mid-task and forgetting**, is almost guaranteed to happen. So I handed it off to launchd.

Writing a job that runs `jq '.model = "opus"' ~/.claude/settings.json`

every day is trivial, but the day after the switch is done, a no-op process runs every single day. **Having something written down permanently for a task you only do once** just feels wrong.

The ideal is a job that "runs exactly once on 07-07 and then disappears."

It breaks into four steps.

| Step | Purpose |
|---|---|
| Date guard | Neutralize catch-up firing and early launches |
| jq surgery | Replace only the `model` key in settings.json |
| osascript notification | Let a human know the unattended run happened |
`launchctl unload` |
De-register the job to make it single-use |

Here is the full text of `~/.claude/scripts/model-transition-0707.sh`

.

``` bash
#!/bin/bash
# model-transition-0707.sh — 2026-07-07にFable 5が終了するため、settings.jsonのmodelをopusへ自動切替
# 冪等: 7/7以降かつ model が fable の時だけ書き換える。成功したら自分のplistをunload。
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"; }

# 7/7より前なら何もしない（catch-up発火対策）
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

# 役目を終えたらジョブを外す（plistは残す＝再登録可能）
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
  log "skip: before 2026-07-07"; exit 0
fi
```

launchd will sometimes catch-up-fire scheduled jobs when the Mac boots. There's also the case where the first firing arrives right after you `launchctl load`

. By comparing `date +%Y%m%d`

as an integer against `20260707`

, no matter when it's launched, it always bails out at zero cost if the date is before 07-07.

```
cp "$SETTINGS" "$SETTINGS.bak-model-transition"
jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" && jq . "$SETTINGS.tmp" > /dev/null && mv "$SETTINGS.tmp" "$SETTINGS"
```

Going through `.tmp`

is so that if `jq`

fails it won't corrupt the original file. After writing out, it verifies syntax with `jq .`

and then does an atomic `mv`

. Since there's a backup, worst case you can restore with `cp "$SETTINGS.bak-model-transition" "$SETTINGS"`

.

The reason for matching case-insensitively with `grep -qi 'fable'`

is to catch any of `fable-5`

, `claude-fable-5`

, or `FABLE`

. Using `'.model // empty'`

makes it return an empty string instead of null when the `model`

key itself is absent (i.e. the default is in use), which prevents an unbound variable error under `set -uo pipefail`

.

```
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
```

Whether it switched the model or it was already opus (`no-op`

), whichever path it takes, it always reaches this line at the end. The `|| true`

absorbs the error when it's already been unloaded.

:::message

`launchctl unload`

does not delete the plist file. `~/Library/LaunchAgents/com.shun.model-transition-0707.plist`

stays in place, so you can re-register it anytime with `launchctl load`

. What's "throwaway" is only the job's registration state.

:::

`~/Library/LaunchAgents/com.shun.model-transition-0707.plist`

(home path shown with `~`

notation):

```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.shun.model-transition-0707</string>
  <key>ProgramArguments</key><array>
    <string>/bin/bash</string>
    <string>~/.claude/scripts/model-transition-0707.sh</string>
  </array>
  <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>
  <key>EnvironmentVariables</key><dict>
    <key>PATH</key><string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
  </dict>
  <key>StandardErrorPath</key><string>~/.claude/logs/model-transition.err</string>
</dict></plist>
```

Three times a day (06:50 / 12:50 / 20:50) are specified via the `StartCalendarInterval`

array. Even if you miss the morning firing on 07-07, you can catch it at noon or in the evening. Because of the date guard, everything before 07-07 is skipped, and on and after 07-07 the first run does the switch and immediately self-destructs, so no later firings arrive.

Making `StartCalendarInterval`

an array lets you bundle multiple times into a single plist. Be careful not to confuse it with the seconds-interval `StartInterval`

.

:::message

The paths written in `ProgramArguments`

need to be full paths, because launchd does not expand `~`

. The `~`

notation above is for explanation; the actual plist needs to contain the paths with `$HOME`

already expanded.

:::

```
# 登録
launchctl load ~/Library/LaunchAgents/com.shun.model-transition-0707.plist

# 確認
launchctl list | grep model-transition

# 手動テスト（07-07より前なら "skip: before 2026-07-07" でログに記録されて終わる）
~/.claude/scripts/model-transition-0707.sh
tail -5 ~/.claude/logs/model-transition.log
```

If you want to try the actual switch locally, temporarily comment out the date-guard line in the script and run it (a backup is taken in `settings.json.bak-model-transition`

).

The log from a normal firing at 06:50 on 07-07:

``` php
[2026-07-07 06:50:02] switched model: claude-fable-5 -> opus
[2026-07-07 06:50:02] done (job unloaded)
```

It's done in two lines, and because the job is gone, the later 12:50 and 20:50 don't fire.

`set -uo pipefail`

, a missing `model`

key kills the script with an unbound variable`'.model // empty'`

return an empty string. Even if `grep -qi`

gets an empty string, it's false and bails, so that's fine.`.tmp`

is left behind by a kill, the next launch references the old contents`mv`

is only reached after the syntax check, so it normally isn't left behind, but if you're worried you could add a guard at the top that deletes `.tmp`

if it exists at launch.`launchctl unload`

in an already-unloaded state exits with an error`|| true`

.`/opt/homebrew/bin`

in the plist's `EnvironmentVariables.PATH`

.`/usr/bin/osascript`

. `>/dev/null 2>&1 || true`

keeps a failed notification from halting the whole script.`date +%Y%m%d`

) neutralizes catch-up firing and early launches.`.tmp`

`launchctl unload "$PLIST"`

A "time-limited one-shot job" isn't limited to model migration — it's a pattern usable for any work with a fixed end: deprecations, campaign periods, migration schedules, and the like. You can handle the next migration just by changing the plist's label, date, and path.

Next time I plan to write about managing these launchd jobs as they accumulate — how to take stock of a `~/Library/LaunchAgents/`

cluttered with scripts.

*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)*
