💥 使い捨てlaunchdジョブでClaude Codeを自動移行 ― 役目を終えたら自己消滅するスクリプト — リーダー×
💥

使い捨てlaunchdジョブでClaude Codeを自動移行 ― 役目を終えたら自己消滅するスクリプト

#claudecode#launchd#macos#automation2026-07-10 · 約8

前回のステータスラインにレート制限を表示する話から続く「Claude Code環境」シリーズです。今回はモデル移行のone-shotジョブ ―― 完了したら自分のplistを launchctl unload で外す「使い捨てジョブ」パターンの話です。

Fable 5は2026-07-07に終了します。Claude Codeの settings.jsonmodel: 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-5claude-fable-5FABLE どれでも通すためです。'.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 pipefailmodel キーがないと 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サービスを量産しています

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