⏱️ 将来の破壊的変更を今仕込む ― 完了後に自分を消す launchd 時限ジョブ — リーダー×
⏱️

将来の破壊的変更を今仕込む ― 完了後に自分を消す launchd 時限ジョブ

#claudecode#launchd#自動化#macOS2026-07-11 · 約7

前作「launchd ワンショットジョブで設定移行を自動化した」の続きです。今回は廃止日が分かっている外部イベント(Fable 5 の提供終了 = 2026-07-07)に備えて、今日仕込んで当日だけ発火し、完了後に自分を外すlaunchd ジョブをどう設計したかを書きます。

「廃止日にいちいち手で直すの面倒だな」と思ったのが起点です。かといって毎朝スクリプトを実行して無駄に JSON を書き換えるのも嫌。結果、日付ゲート・バックアップ付き jq 書き換え・自己 unload の3点セットにたどり着きました。

困りごと:廃止を知った日に「当日だけ動くジョブ」を仕込みたい

~/.claude/settings.json には現在こう書いてあります。

{
  "model": "claude-fable-5[1m]",
  ...
}

Fable 5 が 2026-07-07 に終了すると分かった時点で、この "model" を手動で書き換えるカレンダー予定を作るのは単純すぎて忘れます。一方で「毎回起動するたびに日付チェックするデーモン」にするのも大げさ。欲しいのは一度設定したら放置でき、当日が来たら動いて、その後は消えるジョブです。

launchd には StartCalendarInterval で時刻指定発火ができます。しかし「7/7 の9時に1回だけ」という書き方はできず、繰り返しか日付固定の組み合わせが必要です。複数スロットを指定して冪等性で吸収する、というのが macOS launchd での定石になります。

実装:3点セット

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

# ② バックアップ付き jq 書き換え
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

# ③ 自己 unload
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"

3点を順番に解説します。

① 日付ゲートで早期発火を弾く

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

date +%Y%m%d は数値文字列として整数比較できます。20260706 < 20260707 → skip。これだけ。

なぜこれが重要かというと、plist は今日 launchctl load した瞬間から発火し始めます。登録直後の午前6:50スロットに当日発火しても空振りさせる必要があります。日付ゲートがなければ、仕込んだその日に「モデルが fable でないのに書き換えを試みる」という誤爆が起きます。

date +%Y%m%d の数値比較は macOS の /bin/bash でそのまま動きます。-lt は算術比較なので文字列長が同じなら辞書順でも整数順でも同じ結果になります。

② バックアップ付き jq 書き換え

cp "$SETTINGS" "$SETTINGS.bak-model-transition"
jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" \
  && jq . "$SETTINGS.tmp" > /dev/null \
  && mv "$SETTINGS.tmp" "$SETTINGS"

3ステップに分かれています。

ステップ目的
cp ... .bak-model-transition書き換え前の原本を残す
jq '.model = "opus"' > .tmp一時ファイルに書き出す
jq . .tmp > /dev/null生成された JSON が壊れていないか検証
mv .tmp settings.json検証が通ってから原本と置き換え

直接 jq ... settings.json > settings.json と書くと、シェルがリダイレクト先を開いた瞬間に元ファイルが空になります。一時ファイル経由はリダイレクト破壊を防ぐ基本パターンです。&& 連結で検証失敗時は mv を実行しない点も重要です。

grep -qi 'fable' で大文字小文字を問わず判定しているのは、"claude-fable-5[1m]" や将来的な別表記にも対応するためです。settings.json に実際に入っている値はこれです。

"model": "claude-fable-5[1m]"

書き換え後は "opus" だけになります(モデルIDではなくエイリアス。CLAUDE.md の「スクリプトにモデルIDをハードコードしない」ポリシーに従っています)。

③ 成功後に自分を消す

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

launchctl unload <plist> でそのジョブをデーモンから外します。plist ファイル自体は残るので、必要なら launchctl load で再登録できます。

2>/dev/null || true をつけているのは、既に unload 済みの場合にエラーで abort しないためです。後述の冪等設計と合わせて「何回呼ばれても安全」を担保しています。

launchctl unload はジョブが実行中でも即座にデタッチします。スクリプト末尾で呼んでいるのはこのため ―― 書き換えを終えてから外さないと、処理途中で自分が切られます。

plist:1日3スロットでも問題ない理由

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

6:50・12:50・20:50 の3スロットです。「なぜ1回じゃないか」というと、launchd は Mac が sleep 中のスロットをスキップするためです。朝スロットを寝て過ごした場合でも昼か夜に拾えるようにしています。

発火フロー(7/7当日)は次の通りです。

6:50  → 日付ゲート通過 → fable 検出 → opus に書き換え → unload → ジョブ消滅
12:50 → ジョブが存在しないので発火しない(unload済み)
20:50 → 同上

7/6以前の各スロットは:

skip: before 2026-07-07

とログに残して即 exit 0。書き換えは一切しません。

図解するとこうなります。

7/5         7/6         7/7
6:50  skip  6:50  skip  6:50  書換+unload ←ここで終了
12:50 skip  12:50 skip  12:50 (消滅)
20:50 skip  20:50 skip  20:50 (消滅)

冪等性のおかげで「複数スロット設定して早期発火を日付ゲートで弾く」が成立します。

踏んだ落とし穴

  • date +%Y%m%d を文字列比較 < で書いていた → bash の [[ ]] 内では文字列辞書順になるので -lt に変えた。8桁ゼロパディングが揃っていれば実害は出ないが、意図が明確な算術比較を使う
  • tmpファイルを /tmp/ に置いていたmv でファイルシステムをまたぐとリネームが失敗することがある。同じディレクトリ($HOME/.claude/)に置くことで同一 fs を保証
  • launchctl unload の引数にラベルを渡していた → ラベル(com.shun.model-transition-0707)ではなく plist のフルパスを渡さないと「No such process」になった
  • StandardErrorPath しか設定していなかった → スクリプト内の log() は独自ログファイルに書くが、スクリプト自体がシンタックスエラーで死んだ場合の出力先として StandardErrorPath は必要

まとめ

  • 日付ゲート [ "$(date +%Y%m%d)" -lt YYYYMMDD ] で仕込み日からの早期発火を全部 skip にする
  • バックアップ付き jq 書き換えは「cp → jq > tmp → jq 検証 → mv」の4ステップが最小安全構成
  • 成功後に launchctl unload $PLIST でジョブを外す。plist は残るので再登録は可能
  • plist の複数スロットは sleep 対策。冪等性があるから安心して多重設定できる

廃止日が決まっているものは「気づいた日に仕込んで忘れる」のが最良です。カレンダーに予定を入れるより確実で、cronよりキャンセルが楽です。

次回は、このジョブが残したログをどう読んで移行が成功したか確認するか ―― あるいは失敗した場合のバックアップからのリカバリ手順を書くかもしれません。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!