使い捨てlaunchdジョブでClaude Codeを自動移行 ― 役目を終えたら自己消滅するスクリプト
前回のステータスラインにレート制限を表示する話から続く「Claude Code環境」シリーズです。今回はモデル移行のone-shotジョブ ―― 完了したら自分のplistを launchctl unload で外す「使い捨てジョブ」パターンの話です。
Fable 5は2026-07-07に終了します。Claude Codeの settings.json に model: fable-5 を書いていた場合、その日以降も古いIDを参照し続けます。手動でjqを叩けば済む話ですが、起動を逃す・作業中で忘れるはほぼ必ず起きます。だからlaunchdに任せた。
困りごと:cronで書くと「役目を終えたあとも動き続ける」
毎日 jq '.model = "opus"' ~/.claude/settings.json を回すジョブを書くのは簡単ですが、切り替えが終わった翌日からは何もしない処理が毎日走ります。一度しかやらないことをずっと書いてあるのは気持ち悪い。
理想は「07-07に1回だけ動いて、終わったら消える」ジョブです。
設計:日付ガード + jq外科手術 + 通知 + 自己消滅
4つのステップに分けます。
| ステップ | 目的 |
|---|---|
| 日付ガード | catch-up発火・早期起動を無害化 |
| jq外科手術 | settings.json の model キーだけ置き換える |
| osascript通知 | 無人実行を人間に知らせる |
launchctl unload | ジョブを登録から外して使い捨てにする |
スクリプト本体
~/.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)"
① 日付ガード
if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
log "skip: before 2026-07-07"; exit 0
fi
launchdはmacが起動したときにスケジュール済みジョブをcatch-upで発火させることがあります。また launchctl load した直後に最初の発火が来るケースも。date +%Y%m%d を整数として 20260707 と比較することで、どのタイミングで起動されても07-07より前なら必ずゼロコストで抜けます。
② jqで1キーだけ触る外科手術
cp "$SETTINGS" "$SETTINGS.bak-model-transition"
jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" && jq . "$SETTINGS.tmp" > /dev/null && mv "$SETTINGS.tmp" "$SETTINGS"
.tmp 経由にするのは、jq がコケても元ファイルを壊さないためです。書き出し後に jq . で構文確認してからatomicに mv します。バックアップがあるので最悪でも cp "$SETTINGS.bak-model-transition" "$SETTINGS" で戻せます。
grep -qi 'fable' でcase-insensitiveにマッチしているのは fable-5・claude-fable-5・FABLE どれでも通すためです。'.model // empty' にしているのは model キー自体がないとき(デフォルト使用中)にnullを返させず空文字にするためで、set -uo pipefail 環境でunbound variableになるのを防ぎます。
③ 自己消滅
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
モデルを切り替えた場合も、すでにopusだった場合(no-op)も、どちらのパスを通っても最後に必ずこの行に到達します。|| true はすでにunload済みのときのエラーを吸収します。
launchctl unloadはplistファイルを削除しません。~/Library/LaunchAgents/com.shun.model-transition-0707.plistはそのまま残るのでlaunchctl loadでいつでも再登録できます。「使い捨て」なのはジョブの登録状態だけです。
plistの組み方(1日3回トリガー)
~/Library/LaunchAgents/com.shun.model-transition-0707.plist(ホームパスは ~ 表記):
<?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>
1日3回(06:50 / 12:50 / 20:50)を StartCalendarInterval の配列で指定しています。07-07当日に朝の発火を逃しても昼・夜で拾えます。日付ガードがあるので07-07より前はすべてskip、07-07以降は1回目で切り替えて即自己消滅するため、以降の発火は来ません。
StartCalendarInterval を配列にすることで複数時刻を1つのplistにまとめられます。秒間隔の StartInterval と混同しやすいので注意してください。
ProgramArgumentsに書くパスはlaunchdが~を展開しないためフルパスが必要です。上記の~表記は説明用で、実際のplistには$HOMEの展開後のパスを書く必要があります。
登録・確認コマンド
# 登録
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
実際の切り替えをローカルで試したい場合は、スクリプトの日付ガード行を一時コメントアウトして実行します(settings.json.bak-model-transition にバックアップが取られます)。
07-07当日の06:50に正常発火したときのログ:
[2026-07-07 06:50:02] switched model: claude-fable-5 -> opus
[2026-07-07 06:50:02] done (job unloaded)
2行で完了し、以降の12:50・20:50はジョブが消えているので発火しません。
踏んだ落とし穴
set -uo pipefailでmodelキーがないと unbound variable で死ぬ →'.model // empty'で空文字を返させる。grep -qiに空文字が来ても偽で抜けるのでOK- jqの
.tmpがkillで残ると次回起動時に古い中身を参照する →mvは構文確認後にのみ到達するので通常は残らないが、不安なら起動時に.tmpがあれば削除するガードを頭に入れてもよい - すでにunload済みの状態で
launchctl unloadするとエラー終了 →|| trueで吸収済み - launchdのデフォルトPATHではjqが見つからない → plistの
EnvironmentVariables.PATHに/opt/homebrew/binを明示する - osascriptの通知が来ない → launchdから叩く場合は
/usr/bin/osascriptのフルパスを使う。>/dev/null 2>&1 || trueで通知失敗がスクリプト全体を止めないようにする
まとめ
- 日付ガード(
date +%Y%m%d整数比較)でcatch-up発火・早期起動を無害化する - jqの
.tmp経由アトミック書き換えでsettings.jsonを壊さない - osascript通知で無人完了を人間に見えるようにする
launchctl unload "$PLIST"を最終行に置けば、成功・no-opどちらのパスでも一度きりのジョブになる
「期限付きのone-shotジョブ」は、モデル移行に限らず廃止・キャンペーン期間・移行スケジュールなど、終わりが決まっている作業全般に使えるパターンです。plistのラベルと日付とパスを変えるだけで次の移行に対応できます。
次回は、こうしたlaunchdジョブが増えてきたときの管理 ―― スクリプトが散らかった ~/Library/LaunchAgents/ をどう棚卸しするかの話を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!