「Claude Code環境」シリーズ。前回 launchdのマルチスロットリトライ設計 で「朝昼夜3回冪等発火」パターンを書いた。今回はその応用で、**Fable 5の終了(2026-07-07)に備えて仕込んでいた「使い捨て移行ジョブ」**の実装を公開する。
settings.jsonのmodelフィールドをjqで書き換え、デスクトップ通知を飛ばし、最後にlaunchctl unloadで自分の登録を外して消える。起動・実行・自滅を1スクリプトで完結させる設計だ。モデル移行に限らず「特定日に一度だけ設定を変えて消えるジョブ」として汎用で使えるパターンなので、実コードを全部出す。
困りごと:モデルEOLは静かに壊れる
Claude Codeの~/.claude/settings.jsonに"model": "claude-fable-5"を書いた環境で、EOL当日以降もその設定が残り続けると、静かに意図しない挙動になる。エラーは出ず、フォールバックや別モデルで動いているかもしれないが、自分がどのモデルを使っているか分からなくなる。
手動でsettings.jsonを開いて書き換えるのが最もシンプルだが、「当日に確実に」「忘れずに」「一度だけ」を人間が揃えるのは案外難しい。launchdに委譲して自動化した。
設計:3ステップで完結させる
1. 日付ガード → 7/7より前なら即 exit 0(catch-up発火対策)
2. settings.json パッチ → バックアップ → jq書き換え → JSON検証 → mv
3. 通知 + self-unload → osascript で通知 → launchctl unload で自分を外す
「使い捨て」の核は ステップ3でジョブ自身を登録解除すること。これで次回以降の発火がない。ただし plist ファイルは消さず~/Library/LaunchAgents/に残す。再びlaunchctl loadすれば復帰できるようにしておく(スクリプトのコメントにも「plistは残す=再登録可能」と書いている)。
日付ガード:早期発火を無害化する
launchdには、macが起動したときに「本来発火するはずだった過去のスロットを遡って実行する」catch-upがある。7/7以前に登録した場合、mac再起動のタイミングで前倒し発火する可能性がある。最初の一行でこれを止める。
# 7/7より前なら何もしない(catch-up発火対策)
if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
log "skip: before 2026-07-07"; exit 0
fi
date +%Y%m%dは20260707形式の整数を返すので、数値比較で判定できる。7/7当日以降に初めて実際の処理が走る。
この日付ガードがないと、7/7前に登録してmacを再起動するたびにスクリプトが走る。catch-upはStartCalendarIntervalジョブ特有の挙動なので、定時ジョブを組む際は必ず意識する。
settings.jsonのパッチ:アトミックな4ステップ
モデル書き換えは4ステップのアトミック操作にしている。
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"
else
log "no-op: model is already '$current'"
fi
各ステップの意図:
-
jq -r '.model // empty'―.modelキーが存在しない場合は空文字を返す。null文字列を後段に流さない -
grep -qi 'fable'― 大文字小文字を問わずfableを含む場合だけ書き換える。すでに別モデルに移行済みならelseに落ちてno-opログだけ書いて終わる(冪等) -
cp "$SETTINGS" "$SETTINGS.bak-model-transition"― 書き換え前のバックアップ。失敗時の復旧用 -
jq . "$SETTINGS.tmp" > /dev/null― 書き換えた.tmpがJSON的に正しいか検証してからmvする。壊れたJSONを本番パスに置かない
スクリプト冒頭でset -uo pipefailを宣言しているので、&&チェーンのどこかが失敗すればmvは走らない。
jq . file > /dev/nullによる検証を挟む理由は、jqの出力が何らかの理由で壊れたJSONになった場合に、.tmp→本番へのmvを止めるため。--argjsonを使わず文字列リテラルを埋め込む今回の構造では実際には起きにくいが、習慣として入れておくと他の書き換えスクリプトへの転用時に安全だ。
通知とセルフunload
書き換えが完了したらosascriptでデスクトップ通知を送る。
/usr/bin/osascript -e 'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"' \
>/dev/null 2>&1 || true
|| trueを付けているのは、通知が失敗してもジョブ全体をエラー扱いにしたくないため。通知はあくまで人間への報告であり、処理の本体ではない。
役目を終えたら自分をunloadする。
# 役目を終えたらジョブを外す(plistは残す=再登録可能)
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"
PLIST変数はスクリプト冒頭でPLIST="$HOME/Library/LaunchAgents/com.shun.model-transition-0707.plist"と定義してある。launchctl unloadはplistファイルを削除しないので、~/Library/LaunchAgents/にファイルは残ったままだ。次回launchctl load "$PLIST"を打てば即座に再登録できる。
plistの配線:3スロット冪等発火
<?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>
6:50・12:50・20:50の1日3スロット。前回の記事で書いたマルチスロット設計の直接応用だ。1回目が完走してself-unloadすれば2回目以降は発火しない。macがスリープで6:50を逃しても、12:50・20:50で拾える。
StandardErrorPathをplistで設定しているので、スクリプト内でstderrのリダイレクトを書かなくてよい。デバッグ時は~/.claude/logs/model-transition.errを見る。
踏んだ落とし穴
-
jqがpathに無くてexit 127 → plistのEnvironmentVariables.PATHに/opt/homebrew/binが無いと、Homebrew入りのjqが見つからない。ターミナルから手動実行は成功するのに、launchdだけ壊れる典型パターン。Apple Siliconの定番罠 - 日付ガードを入れる前のテストでcatch-up発火 → 7/7前に動作確認でロードしたところ、mac再起動のcatch-upが走って7/7より早く移行が完了してしまった。日付ガードは後付けではなく最初から必須
-
.modelキーが存在しないsettings.jsonで誤動作 →jq -r '.model'だとnull文字列が返り、grep -qi 'fable'が不一致で済むが、将来的なキー名変更や欠落を考えて// emptyで空文字フォールバックを明示した -
self-unload後に次スロットで再発火するか心配した →
launchctl unloadを実行した時点でそのジョブのスケジュールは消える。同じlaunchctl loadなしには再発火しない -
stderrの行方が分からなくなる →
StandardErrorPathを設定していないと、launchdはstderrを/dev/nullか予測不能な場所に捨てる。デバッグ不能になるので必ず設定する
汎用パターンとして
このジョブはFable 5のEOL固有に見えるが、以下の3要件が揃えばそのまま転用できる。
| 要件 | 本ジョブでの実装 |
|---|---|
| 特定日以降に一度だけ実行 |
date +%Y%m%d比較で日付ガード |
| 冪等(何度走っても同じ結果) | 現在値チェックしてから書き換え |
| 完了後に自動的に消える | launchctl unload "$PLIST" |
応用例:
- 特定日以降にconfigの機能フラグをON/OFFする
- 期限切れのAPIエンドポイントを新URLに書き換える
- バージョンアップに伴うJSON設定のスキーマ移行
「一度だけ実行してほしいcron」は、cronやlaunchdに永続登録すると毎回発火してしまい冪等性の確保が面倒になる。self-unloadで「走ったら消える」設計にする方が、永続ジョブのdedupe処理を書くより単純だ。
まとめ
-
日付ガード(
date +%Y%m%d比較)でcatch-up発火を無害化する ―― 最初から入れる -
jqパッチはバックアップ→書き換え→JSON検証→mvのアトミック4ステップ -
現在値チェック(
grep -qi 'fable')で冪等にする。移行済み環境でも安全に再実行できる - **
launchctl unload "$PLIST"**でself-unload ―― plistは残して再登録可能にする - plistのマルチスロット(6:50/12:50/20:50)でスリープ時のskipに備える
- Homebrew入りの
jqを使うなら**plistのPATHに/opt/homebrew/bin**を忘れない
次回は、このような使い捨てジョブも含めたlaunchd全体の死活を一コマンドでチェックする automation-health.sh の話を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!