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 Fable 5 to Opus on the shutdown date of 2026-07-07. The script runs exactly once, updates settings.json with jq, sends a notification, then unloads itself via launchctl to avoid permanent no-op processes.

read5 min views1 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 ·