DEV Community

Lily
Lily

Posted on • Originally published at dev.to

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

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.

The problem: writing it in cron means it "keeps running after its job is done"

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

Design: date guard + jq surgery + notification + self-destruct

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

The script itself

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

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

① Date guard

if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
  log "skip: before 2026-07-07"; exit 0
fi
Enter fullscreen mode Exit fullscreen mode

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.

② jq surgery that touches exactly one key

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

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.

③ Self-destruct

launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
Enter fullscreen mode Exit fullscreen mode

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

Assembling the plist (three triggers a day)

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

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

Registration and verification commands

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

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

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

Pitfalls I hit

  • Under set -uo pipefail, a missing model key kills the script with an unbound variable → have '.model // empty' return an empty string. Even if grep -qi gets an empty string, it's false and bails, so that's fine.
  • If jq's .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.
  • Running launchctl unload in an already-unloaded state exits with an error → absorbed with || true.
  • launchd's default PATH can't find jq → explicitly set /opt/homebrew/bin in the plist's EnvironmentVariables.PATH.
  • The osascript notification doesn't show up → when calling it from launchd, use the full path /usr/bin/osascript. >/dev/null 2>&1 || true keeps a failed notification from halting the whole script.

Summary

  • A date guard (integer comparison of date +%Y%m%d) neutralizes catch-up firing and early launches.
  • jq's atomic rewrite via .tmp keeps settings.json from being corrupted.
  • osascript notification makes the unattended completion visible to a human.
  • Placing launchctl unload "$PLIST" on the final line makes it a one-time-only job on either path — success or no-op.

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*

Top comments (0)