cd /news/developer-tools/auto-migrating-claude-code-with-a-th… · home topics developer-tools article
[ARTICLE · art-64556] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer created a self-deleting launchd job that automatically migrates Claude Code's model setting from the deprecated Fable 5 to Opus on July 7, 2026. The script uses a date guard, jq for atomic file editing, and removes itself via launchctl unload after execution.

read5 min views54 publishedJul 18, 2026

This continues my "Claude Code environment" series that started with showing rate limits live in the status line. 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

.

#!/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)"
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

~/.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:

[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 contentsmv

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 · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 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/auto-migrating-claud…] indexed:0 read:5min 2026-07-18 ·