🔌 🔌 30日間触ってないプラグインをClaudeが自動で無効化する仕組み — リーダー×
🔌

🔌 30日間触ってないプラグインをClaudeが自動で無効化する仕組み

#automation#claudecode#副業2026-07-29 · 約34

月商120万になって気づいたのは、「環境を育てる」ことが「仕事をする」ことより先に来るということです。

なぜこの仕組みが効くのか

Claude Codeでプラグインを追加するのは一瞬です。「context7を入れよう」「playwright入れよう」「exposeも試してみよう」。気づくと有効プラグインが50本を超えていました。問題はそこから始まります。

Claude Codeを起動するたびに、system-reminderのブロックにdeferred tools欄が展開されます。接続中のMCPサーバーが持つすべてのツール名が列挙される箇所です。プラグインが多いほど、この欄が膨らみます。playwright単体でもbrowser_clickからbrowser_wait_forまで30個近いツール名が並びます。

これはコンテキストウィンドウに対する静かな課税です。毎セッションの入力トークンの先頭数千字が、実際の作業とは無関係なツール名の羅列に消えていきます。セッション序盤の応答精度が下がる、長い対話の末に「あの情報」が押し出される、そういう症状になって現れます。

半年で自律環境を構築してきた経験から断言できますが、「使っていないプラグインを有効にし続けること」は、机に使わない工具を並べ続けるのとは訳が違います。工具は空間を食うだけですが、プラグインは毎セッションの思考コストを食います。

この問題の根本は手動管理の限界にあります。50本のプラグインのうち、どれを「最後に使ったのがいつか」を人間が把握するのは現実的ではありません。だから仕組みを作りました。30日間、MCP呼び出しとSkill呼び出しが共にゼロだったプラグインを、毎週日曜の朝に自動でdisableにする仕組みです。

コンテキスト課税の実態

system-reminderに展開されるdeferred toolsは、実際にはこういう形で現れます。

The following deferred tools are now available via ToolSearch.
Their schemas are NOT loaded — calling them directly will fail
with InputValidationError.
Use ToolSearch with query "select:<name>[,<name>...]" to load
tool schemas before calling them:
mcp__plugin_playwright_playwright__browser_click
mcp__plugin_playwright_playwright__browser_close
mcp__plugin_playwright_playwright__browser_console_messages
...(30行以上続く)

playwrightだけでこれです。chrome-devtools、expo、sequential-thinkingが加わると、列挙行だけで100行を超えます。1行あたり平均50文字としても5000文字、軽く4000トークン近くが毎セッションの固定コストになります。これを丸ごと削れるのが、使っていないプラグインの無効化です。

「作業」より「環境」を先に整える理由

月10万の大学生だったころは、副業の稼ぎを最大化するために「もっと多くの作業をすること」に集中していました。掛け持ちで月60万まで伸ばせましたが、そこには明確な天井がありました。時間は有限で、体力も有限です。

会社都合で解雇されて収入がゼロになり、そこから半年で再構築する過程で考え方が変わりました。Claude Codeという自律環境を手に入れたとき、鍵は「いかに良い作業をするか」ではなく「いかに良い環境を維持するか」にあると理解しました。

プラグインの自動管理もその一環です。毎週10分かけてどのプラグインを消すか手で判断するよりも、その判断ロジックをスクリプトに書いて、週次で自律実行させるほうが確実です。人間が介在しないからこそ、忘れません。遠慮しません。休みません。


全体の流れ

全体は3つのコンポーネントで構成されています。

セッションJSONL群 (~/.claude/projects/)
      │
      ▼ (毎日 09:30)
┌─────────────────────────────┐
│  com.shun.plugin-usage      │  ← LaunchAgent①
│  plugin-usage.sh 14         │
│  → plugin-audit-latest.md   │
└─────────────────────────────┘
                                   ← レポートを人間が読む(任意)

セッションJSONL群 (~/.claude/projects/)
      │
      ▼ (毎週日曜 06:45)
┌─────────────────────────────┐
│  com.shun.plugin-auto-disable│  ← LaunchAgent②
│  plugin-auto-disable.sh apply│
│  → settings.json 書き換え   │
│  → キャッシュ削除           │
└─────────────────────────────┘

LaunchAgent①が毎朝レポートを生成し、LaunchAgent②が週次でdisableを実行します。どちらも/bin/zsh経由で走り、~/.claude/logs/に結果を吐きます。

plugin-usage.sh ― セッションログをjqで集計する

レポート生成スクリプトの核心は、セッションJSONLからtool_useイベントを正確に数えることです。

find "$LOG_DIR" -maxdepth 1 -name "*.jsonl" -mtime -"$DAYS" -print0 2>/dev/null \
  | xargs -0 cat 2>/dev/null \
  | jq -R -r 'fromjson?
      | select(.type=="assistant")
      | .message.content[]?
      | select(.type=="tool_use")
      | if .name=="Skill"
        then ((.input.skill // "") | select(contains(":")) | split(":")[0])
        else (.name | select(startswith("mcp__plugin_"))
              | sub("^mcp__plugin_";"") | split("_")[0]) end' 2>/dev/null \
  | sort | uniq -c | sort -rn > "$TMP"

このjqパイプラインは3つのことをしています。

まずtype=="assistant"に絞ります。 Claudeが実際にツールを呼んだ記録はassistantメッセージの中にあります。tool_resultなど別タイプのイベントは除外します。

次にtool_useイベントのみを対象にします。 .message.content[]?で各contentブロックを展開し、select(.type=="tool_use")でフィルタします。これが「grep実装との最大の差」です(後述)。

最後にプラグイン名を正規化します。 Skillツールの場合はinput.skill:で分割して先頭部分(プラグイン名)を取ります。MCPツールの場合はmcp__plugin_プレフィックスを剥がし、次のアンダースコアまでを取ります。mcp__plugin_playwright_playwright__browser_clickならplaywrightが得られます。

集計後、settings.jsonenabledPluginsからenabled=trueのプラグイン一覧を取り出し、集計に現れなかったものをcomm -23で差分抽出して「Dormant」として表示します。

ENABLED_LIST=$(jq -r '.enabledPlugins // {}
  | to_entries[]
  | select(.value)
  | .key' "$SETTINGS" 2>/dev/null \
  | awk -F@ '{print $1}' | sort -u)

comm -23 <(echo "$ENABLED_LIST") <(echo "$USED_LIST") | head -60

awk -F@ '{print $1}'でスコープ部分(@scope)を落としていることも重要です。context7@1.0.0のようにバージョン付きで登録されているプラグインでも、名前部分だけで正しく突合できます。

このスクリプトはLaunchAgent経由で毎日09:30に走り、結果を~/.claude/scripts/plugin-audit-latest.mdに上書きします。plistの中身は次のとおりです。

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>9</integer>
  <key>Minute</key>
  <integer>30</integer>
</dict>

Weekdayキーがないのは意図的で、これで毎日実行になります。レポートを見たいときはこのmdを開けばいいだけです。

plugin-auto-disable.sh ― 30日ゼロ呼び出しを自動でdisable

実際にsettings.jsonを書き換えるスクリプトは、3つの安全レイヤーを持っています。

レイヤー1: Protectedリスト

使用頻度が低くても絶対に消してはいけないプラグインを明示的に除外します。

PROTECTED=(
  remember plugin-dev hookify skill-creator session-report
  security-guidance superpowers context7 explanatory-output-style
  learning-output-style code-review feature-dev claude-md-management
  # LSPs: Claude Code が透過的に呼び出す可能性。tool_use では現れない
  typescript-lsp pyright-lsp php-lsp ruby-lsp rust-analyzer-lsp swift-lsp
  # Process tools: ad-hoc に呼ばれる可能性
  code-simplifier code-modernization ralph-loop agent-sdk-dev mcp-server-dev
  playground commit-commands pr-review-toolkit
  # 既知の誤検出(過去のセッションでトラブル)
  azure-cosmos-db-assistant
)

LSP系は特に重要です。typescript-lsppyright-lspはClaude Codeが内部で透過的に呼び出すため、セッションログのtool_useイベントには現れません。使用ログがゼロでも有効であり続けなければならないプラグインです。

レイヤー2: キャッシュサイズフィルタ

30日無呼び出しの候補が見つかっても、キャッシュサイズがMIN_CACHE_MB=5MB未満のものは対象外にします。

for p in "${CANDIDATES[@]}"; do
  size=$(du -sm \
    "~/.claude/plugins/cache/claude-plugins-official/$p" \
    2>/dev/null | awk '{print $1}')
  size="${size:-0}"
  [ "$size" -lt "$MIN_CACHE_MB" ] && continue
  SIZED+=("${size}\t${p}")
done

キャッシュが小さいプラグインはsystem-reminderへの影響も小さいので、見送っても問題ありません。逆に言えば、大きなキャッシュを持つプラグインを優先的に落とすことで、1回の実行で最大のコンテキスト削減効果が得られます。sort -rnでサイズ降順に並べて上位から選ぶのはそのためです。

レイヤー3: 週次上限

1回のapply実行で無効化するのは最大WEEKLY_MAX=5本です。

SELECTED=()
if [ "${#SIZED[@]}" -gt 0 ]; then
  while IFS=$'\t' read -r sz p; do
    SELECTED+=("$p")
    [ "${#SELECTED[@]}" -ge "$WEEKLY_MAX" ] && break
  done < <(printf '%b\n' "${SIZED[@]}" | sort -rn)
fi

一度に大量を落とすと「あのプラグインいつ消えた?」の追跡が困難になります。週5本ずつ落として、~/.claude/logs/plugin-auto-disable.logに記録を残す運用です。

実際のdisable操作は別スクリプトplugin-disable.shに委譲しています。settings.jsonの書き換えは1箇所で集中管理するためです。

このスクリプトはLaunchAgentで毎週日曜06:45に自動実行されます。

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>6</integer>
  <key>Minute</key>
  <integer>45</integer>
  <key>Weekday</key>
  <integer>0</integer>
</dict>

Weekday0が日曜です。朝6:45なのはMacが起動している時間帯でかつ作業前だからです。launchdの仕様上、Mac起動中でないとスケジュールは実行されないため、起動していない時間に設定すると翌週まで実行されません。

ログの場所は~/.claude/logs/plugin-auto-disable.logと、plistのStandardOutPath/StandardErrorPathに指定したcom.shun.plugin-auto-disable.logの2箇所に書き出されます(スクリプト内でtee -a "$LOGFILE"しているため)。出力例はこういう形です。

[2026-07-13 06:45:01] auto-disable run (mode=apply, days=30)
[2026-07-13 06:45:03] dormant candidates: 12
[2026-07-13 06:45:03] selected for action (>=5MB, max 5): 5
  [APPLY] expo (47MB cache)
  [APPLY] sequential-thinking (23MB cache)
  [APPLY] drawio-skill (18MB cache)
  [APPLY] agent-eval (12MB cache)
  [APPLY] benchmarks (9MB cache)
[2026-07-13 06:45:07] applied disable for 5 plugin(s)

47MB + 23MB + 18MB + 12MB + 9MB = 109MBのキャッシュが1回の週次実行で解放され、次回セッションから該当プラグインのdeferred tools列挙が消えます。

実装の詳細

jqパイプラインが「幽霊」を殺す理由

前半で引用したjqパイプラインの一行一行には、それぞれ意図があります。見落としやすい部分を順に掘ります。

まず fromjson? の末尾の ? です。これはjqの「エラー抑制演算子」で、JSONとして解析できない行を黙って捨てます。Claude Codeのセッションファイルは .jsonl(JSONLines)形式ですが、書き込み途中でMacがスリープした場合など、末尾が中途半端に切れた行が存在することがあります。fromjson?なし)だと1行の解析エラーでパイプライン全体が止まります。?があるだけで数十万行のJSONLを黙って走り抜けてくれます。

次に select(.type=="assistant") です。JSONL内のイベントは type フィールドで user / assistant / tool_result などに分かれます。ツールを呼んだ記録はassistantメッセージの中にしかありません。tool_resultにはツールの応答が入っていますが、そこには type=="tool_use" ブロックはありません。このフィルタを入れないと、後段のcontent展開が混乱します。

.message.content[]? で配列を展開し、select(.type=="tool_use") で実際のツール呼び出しブロックだけを取り出します。ここが旧実装との決定的な差で、後述する失敗の原因でもあります。

Skillツール側の処理はこうなっています。

if .name=="Skill"
then ((.input.skill // "") | select(contains(":")) | split(":")[0])

select(contains(":")) は「コロンを含む文字列だけを通す」フィルタです。Claude Codeのスキルは context7:query-docs のようにプラグイン名とスキル名をコロンで繋いだ形式で呼ばれます。built-inのskillや不正な値にはコロンがないため、これで除外します。// "" はnull安全で、input.skillが存在しない場合に空文字を返してselect側で弾かせます。

MCPツール側は次のとおりです。

.name | select(startswith("mcp__plugin_"))
     | sub("^mcp__plugin_";"") | split("_")[0]

mcp__plugin_playwright_playwright__browser_click というツール名から playwright を取り出す処理です。sub(置換)でプレフィックスを落とし、残り playwright_playwright__browser_click をアンダースコアで分割して先頭要素を取ります。プラグイン名に内部でアンダースコアが含まれるケースは現状ありませんが、このパターンが崩れるとしたらここです。

集計後、comm -23 でdormant(使用ゼロ)のプラグインを差分抽出します。

comm -23 <(echo "$ENABLED_LIST") <(echo "$USED_LIST")

comm -23 は「ファイル1にだけある行」を出力するコマンドです。ただし両方のリストがソート済みであることが前提です。このためENABLED_LISTを構築する時点で sort -u を必ず通し、USED_LISTも集計後に sort -u で重複除去しています。ここを怠ると comm の出力が壊れます。

awk -F@ '{print $1}'context7@1.0.0 のようにバージョン付きで登録されているプラグインから名前部分だけを取り出すためのものです。Claude Codeのsettings.jsonでは enabledPlugins のキーが context7@1.0.0 形式になることがあります。@ を区切り文字にして第1フィールドだけを取れば、使用ログ側の context7 と正しく突合できます。

settings.json書き換えにpython3を使う理由

plugin-auto-disable.sh は、有効なプラグイン一覧の抽出にpython3を使っています。

ENABLED=$(python3 -c "import json; print('\n'.join(
  k.split('@')[0]
  for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items()
  if v))")

同じことをjqで書くと jq -r '.enabledPlugins // {} | to_entries[] | select(.value) | .key' になります(plugin-usage.shはこちら)。どちらでもよいのですが、auto-disable.shを書いた時点でjqのバージョンによって select(.value) の挙動がfalsy値(0や空文字)で微妙に変わる可能性を避けたく、python3の if v で明示的にfalsyを落とす書き方にしました。設定の書き換えスクリプトで「誤った対象を選ぶ」リスクは最小にしたいという判断です。

実際のdisable操作は plugin-disable.sh に委譲しています。

"$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}"

settings.json の書き換えロジックを1スクリプトに集中させることで、2箇所から同時に書いてJSONを壊すことを防いでいます。週次のauto-disableも、手動で個別にdisableするときも、書き換えの窓口は常にここだけです。

LaunchAgent配線の肝

launchdはユーザーのシェル環境を一切継承しません.zshrc.zprofile も読みません。つまり、ターミナルで jqpython3node が動いても、launchd経由では /usr/bin:/bin しかPATHにない状態で動きます。Homebrewのjqもnvmのnodeも、明示的に教えないと見つかりません。

これが EnvironmentVariables の役割です。両plistに次の設定が入っています。

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

nvmのbinを先頭に置いているのは、システムのnodeとバージョンが混在したときに必ずnvm側が勝つためです。Homebrew bin がその次に来ます。

ログの書き出しに >>> を使い分けていることも重要な設計選択です。plugin-usage.plistのProgramArgumentsはこうなっています。

<string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>

&gt; はXMLエスケープの > です。毎回上書き>)します。audit-latest.mdは「今この瞬間の状態」を見るためのファイルなので、前回のレポートが残っていても意味がありません。一方、plugin-auto-disable.plistのコマンドは >>追記します。いつ何が無効化されたかの履歴が必要だからです。

ProcessType: Background はlaunchdに「UIへのアクセスは不要、バックグラウンドで動かしてよい」と伝えます。Interactiveにするとユーザーセッションがアクティブでないと動かないケースがあります。完全無人の週次処理ならBackgroundが正しい設定です。

また両plistとも StandardOutPath / StandardErrorPath を指定していますが、ProgramArguments内の 2>&1 リダイレクトのほうが優先されます。plist側のパスには実質なにも書き出されません。これは冗長ですが、もし将来コマンド側のリダイレクトを外したとき、plist側がフォールバックとして機能します。意図的な二重構造です。


私が詰まった話

失敗① grepが幽霊を数えていた

最初にplugin-usage.shを書いたときはjqを使わず、grepで全JSONLを走らせていました。

# 旧実装(動かない)
grep -rh "mcp__plugin_${plugin_name}" ~/.claude/projects/ | wc -l

しばらく動かして集計結果を確認したとき、不審な数字が出ました。terraformが使用ランキング1位になっていたのです。私はterraformを一度も使ったことがありません。しかも有効なプラグイン数より使用カウントが多い「負のDormant数」が出ていました。存在するはずのないマイナスです。

原因に気づいたのはJSONLの中身を直接確認したときでした。Claude Codeはセッション開始時、「deferred toolsが利用可能」というsystem-reminderを毎回送ります。そこには有効プラグインが持つ全ツール名の一覧が平文で書かれています。

mcp__plugin_terraform_terraform__workspace_list
mcp__plugin_terraform_terraform__resource_read
…(以下続く)

grepはこの列挙テキストを拾っていました。JSONLの1行はセッション全体の1イベントを表すJSONオブジェクトです。その中のtextフィールドにsystem-reminder本文が入っており、そこにterraformのツール名が大量に書かれていた。grepはJSON構造を無視して文字列として検索するため、「system-reminderがツールを列挙した行」を「ツールを呼び出した行」として誤計上していたのです。

50セッションあれば50回列挙されます。1セッションあたり数十行の列挙がhitして、使ったことのないterraformが1000回呼ばれたことになっていました。

修正はjqでJSON構造を正しく辿ることです。select(.type=="assistant").message.content[]?select(.type=="tool_use") という3段フィルタで、実際にClaudeがツールを呼んだ記録だけを取り出します。text系のコンテンツブロックはtype==tool_useではないので、system-reminderの列挙は一切ヒットしません。

修正後、terraformはDormantリストに入り(もちろんそうです)、負のDormant数も消えました。

失敗② zshのstatus変数が読み取り専用だった

LaunchAgentで動かす際、スクリプトの終了コードをもとに通知を出す処理を書いていました。

# 旧実装(zshで動かない)
plugin-disable.sh apply "${CANDIDATES[@]}"
status=$?
if [ "$status" -ne 0 ]; then
  echo "[ERROR] disable failed with code $status" | tee -a "$LOGFILE"
fi

ターミナルで手動実行するとすべて動きます。LaunchAgent経由だと失敗通知が一切出ない。ログをどれだけ見ても、disable処理が失敗してもエラーメッセージが書き出されません。set -uo pipefail を入れているのに、エラーで止まらずにスクリプトが続行していました。

原因はzshの仕様です。statusはzshの組み込み読み取り専用変数で、直前のコマンドの終了コードを保持します。これはbashにはない変数です。status=$? と書くと、zshは代入を無視します(エラーにすらならないケースがある)。その後の [ "$status" -ne 0 ] は常に status の現在値(直前コマンドの終了コード)を参照しますが、代入が効いていないため意図した挙動になりません。

/bin/bash で書いたスクリプトをそのままzshで走らせると踏むトラップです。LaunchAgentのProgramArgumentsで /bin/zsh -c を使っていたため、ターミナル(fish/bash起動)では気づかずLaunchAgent経由のzshだけで誤動作していました。

直し方は変数名を変えるだけです。

# 修正後
plugin-disable.sh apply "${CANDIDATES[@]}"
rc=$?
if [ "$rc" -ne 0 ]; then
  echo "[ERROR] disable failed with code $rc" | tee -a "$LOGFILE"
fi

rc はzshで予約されていません。これで失敗通知が復活しました。一般論として、zshスクリプトでは statusARGCargvmatch など、bashにはないシステム変数が多数あります。status だけ頭に入れておけば、このトラップは一生踏みません。

失敗③ launchdにPATHを渡さないとjqが「存在しない」

LaunchAgent用のplistを最初に書いたとき、EnvironmentVariablesを書きませんでした。ターミナルでは jq --version が通るのに、LaunchAgent経由だとスクリプトが「command not found: jq」で即終了します。ログすら出ません。StandardErrorPathに書き出されるはずのエラーが空白、StandardOutPathも空白。ただスクリプトが沈黙して終わるだけです。

launchdは /usr/bin:/bin:/usr/sbin:/sbin しかPATHに持ちません。Homebrewが /opt/homebrew/bin、nvmが ~/.nvm/versions/node/v24.13.0/bin にバイナリを置いていても、launchdには見えません。

さらに厄介だったのは、jqが見つからないと set -uo pipefail 環境下ではパイプ全体が失敗扱いになり、後続の処理も走らず、ログ書き出しのコードも実行されないことです。「何も起きていない」ように見えるだけで、実際はjqが見つからない時点で落ちていました。

修正はplistに明示的なPATHを書くことです。

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:…</string>
</dict>

launchctl unloadlaunchctl load でリロードし直して、初めてjqが見つかりスクリプトが動き始めました。LaunchAgentを書くときのルールとして、使うコマンドを全部 which して、そのパスをENvironmentVariablesに書くを徹底しています。jq、python3、node、どれも素のシェルとは置き場所が違います。

失敗④ macOSのwc -lがスペースを返す

plugin-usage.shでDormant数を計算する部分です。

USED_COUNT=$(wc -l < "$TMP" | tr -d ' ')
DORMANT_COUNT=$(comm -23 … | wc -l | tr -d ' ')

末尾の tr -d ' ' が当初ありませんでした。macOSの wc -l42 ではなく 42 を返します(左にスペースパディング)。LinuxのGNU coreutilsにはこのパディングがありませんが、macOSのBSD系は付きます。

この出力をそのまま変数に入れてMarkdownに埋め込むと、ヘッダー行が - Dormant (enabled but never invoked): ** 12** のようにスペース混じりで書き出されます。見た目が崩れるだけで計算には影響しないのですが、後でこの値を数値比較するコードを足したとき [ "$DORMANT_COUNT" -gt 30 ] が正しく評価されなくなりました。文字列 " 12" は算術比較で "12" と同じですが、環境によって微妙に挙動が変わります。

tr -d ' ' を挟むだけで完全に解決します。macOSでBashスクリプトを書くときの定番の処理ですが、Linuxで育ったスクリプトをそのまま持ってくると必ず踏みます。


これら4つの失敗はすべて「ターミナルで動くのにLaunchAgentで動かない」または「数字が出るのに意味がない」という症状でした。共通しているのは動作環境の差異です。シェル(bash vs zsh)、PATH(対話シェル vs launchd)、OS(Linux vs macOS)の差が、コードの見た目上の正しさを無効化します。LaunchAgent配線の価値は完全無人化にありますが、それだけに「手動では気づかないバグ」が潜みやすい領域でもあります。ログを二重に書き出す設計(スクリプト内tee -a+plistのStandardErrorPath)も、こういう「沈黙して失敗する」パターンへの対策です。

つまずきポイント

前半・中段で取り上げた4つの失敗(grep幽霊・status変数・launchd PATH・wc -lパディング)以外にも、実際の配線で詰まったポイントがあります。それぞれに実コードの根拠があります。

  • printf '%b\n' を使わないとタブが文字列として残るplugin-auto-disable.shの85〜90行目でキャッシュサイズとプラグイン名を SIZED+=("${size}\t${p}") として配列に積みますが、この \t はシングルクォート環境ではバックスラッシュとtの2文字です。96行目の printf '%b\n' "${SIZED[@]}" | sort -rn%b フォーマットを使うことで初めてタブに展開されます。echoprintf '%s\n' にすると sort -rn がフィールドを正しく認識せず、サイズ降順ソートが壊れます。

  • comm -23 は両リストがソート済みでないと無音で壊れるcomm は入力が辞書順でソートされていることを前提とします。ソートされていない入力を渡しても comm はエラーを出さず、出力が不定になります。ENABLED_LISTsort -u で整形し、USED_LISTsort -u を通す設計はそのためです。sort を省いてテストすると、Dormantに出るべきプラグインが出なかったり、使用済みのプラグインがDormantに混じったりします。

  • set -uo pipefail-u フラグで未束縛変数がスクリプトを落とすplugin-auto-disable.sh の冒頭に set -uo pipefail が入っています。-u は未初期化変数への参照をエラーにします。配列 SIZEDSELECTED が空のまま ${#SIZED[@]} を参照するケースでは問題ありませんが、後から変数を追加したときに初期値を忘れると unbound variable で即終了します。LaunchAgent経由だとエラーログが「コマンドが起動されたが出力がない」状態になりやすいので、追加した変数には必ずデフォルト値(VAR="${VAR:-default}")を設定します。

  • trap 'rm -f ...' EXIT を入れないとtmpファイルが残るplugin-auto-disable.sh の59行目に trap 'rm -f "$TMP" "$SKILL_TMP"' EXIT が入っています。set -uo pipefail 環境でスクリプトが中途終了したとき、tmpファイルが /tmp/ に残り続けます。LaunchAgentは週次で同じスクリプトを動かすので、積み重なると /tmp を少しずつ汚します。trap は防衛線として必須です。

  • plistのXMLエスケープを忘れるとlaunchctlがloadを拒否するplugin-usage.plist の18行目がこれです。

    <string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>
    

    >&gt;&&amp; にエスケープしないと、plistをXMLとしてパースする時点で launchctl loadFormat error を返して黙って何もしません。エディタで編集したときに誤って生の > が入るミスは珍しくありません。plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist で事前検証する癖をつけると防げます。

  • Weekday=0 が日曜であることを意識しないと月曜に走る。launchdの Weekday キーは0始まりで、0=日曜、1=月曜です。週次disableを「月曜の朝に走らせたい」と思って Weekday=1 を設定すれば正しいですが、0=月曜 と誤解して Weekday=0 にすると日曜に走ります。実害は少ないですが、「なぜか日曜に動いた」ログを見て混乱します。com.shun.plugin-auto-disable.plistWeekday=0(日曜)・Hour=6Minute=45 という設定は意図的です。週明け月曜の作業開始前にdisableが済んでいる状態にするためです。

  • Macが StartCalendarInterval の時刻にスリープしていると実行されない。launchdはcronと異なり、スリープ中の実行をキューに積みません。06:45に設定しても、Macが閉じた状態だとその週は実行されます。PowerNapが有効なMacではバックグラウンド処理がスリープ中も走ることがありますが、保証はありません。確実に動かしたいなら caffeinate -s でスリープを防ぐか、実行時刻をMacが確実に起動している時間帯(09:30など)に変更します。毎日のレポート生成(09:30)が確実に走るのは、作業中の時間帯だからです。

  • plugin-disable.sh が存在しないとapply時にスクリプトが止まるplugin-auto-disable.sh の121行目で "$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}" を呼びます。この下位スクリプトが存在しないか実行権限がないと、set -uo pipefail のせいでスクリプト全体がそこで終了し、後続の echo "[$(ts)] applied disable" ログが出ません。「applyを実行したのにログに完了記録がない」はこのパターンです。ls -la ~/.claude/scripts/plugin-disable.shchmod +x の確認が先です。

  • python3の-cインラインスクリプトにシェル変数を埋め込む際の展開事故。66行目の python3 -c "import json; print('\n'.join(k.split('@')[0] for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items() if v))" は、$SETTINGS がダブルクォート内でシェルに展開されます。パスにスペースが含まれる場合、python3がパースエラーを起こします。~/.claude/settings.json の実パスにスペースはないため現状問題ありませんが、パスを変数から取る設計なら $SETTINGS を別途クォートするかヒアドキュメント経由で渡すほうが安全です。

  • disable後にClaude Codeを再起動しないとdeferred tools欄が更新されないsettings.json の書き換えは即座に反映されますが、すでに起動中のClaude Codeセッションは起動時に読み込んだ設定を保持しています。週次disableが走っても、その日のセッションではまだ無効化前の状態でdeferred tools欄が展開されます。コンテキスト削減効果を得るには次のセッション開始が必要です。自動実行を早朝に設定している理由のひとつがここで、作業開始前にdisableが完了していれば、その日の最初のセッションから効果が出ます。


ベストプラクティス

実際に動かした経験から抽出した、再現性のある方針です。

1. MODE=dry をデフォルトにして必ずdry-runで確認してからapplyする

plugin-auto-disable.sh の1行目の引数処理は MODE="${1:-dry}" です。引数なしで呼べばdry-runになります。新しく配線したばかりの週は、まずdry-runでCANDIDATESの内訳とSIZED/SELECTEDの選択結果を確認し、意図しないプラグインが選ばれていないことを確認してからLaunchAgentにapplyを設定します。

2. Protectedリストにはコメントで理由を書く

plugin-auto-disable.sh の26〜37行目のPROTECTEDリストにはなぜ保護するかのコメントが各グループについています。「LSPs: Claude Codeが透過的に呼び出す可能性。tool_useでは現れない」という行がなければ、typescript-lspが毎回Dormantに見えて削除候補に上がり続けます。理由なしのリストは3ヶ月後に見たとき整理の判断ができません。

3. WEEKLY_MAX=5MIN_CACHE_MB=5 の二段フィルタで過剰disableを防ぐ

一度に大量のプラグインを落とすと「何が消えたか」の追跡が困難になります。5MB未満のキャッシュはdeferred toolsへの影響が軽微なので除外し、5MB以上・最大5本という制約で確実にコントロールします。緊急で多数を落としたいときは WEEKLY_MAX=20 plugin-auto-disable.sh apply のように環境変数で上書きできます。

4. launchd PATHには which コマンド名 の結果をそのまま書く

jqpython3node のどれも、ターミナルで動く場所とlaunchd環境での検索対象は別物です。plistに書くPATHは、使うコマンドを which jqwhich python3 で確認して、そのディレクトリをすべて含めます。nvmを使っているなら /Users/ユーザー名/.nvm/versions/node/vX.Y.Z/bin、Homebrewなら /opt/homebrew/bin を先頭付近に入れます。com.shun.plugin-auto-disable.plist の実際の設定は nvm v24.13.0 bin → opt/homebrew/bin → homebrew/sbin → usr/local/bin → usr/bin → bin → sbin → .local/bin の順です。

5. ログは「スクリプト内 tee -a」と「plistの StandardErrorPath」の二重構造にする

plugin-auto-disable.sh の主要なログ出力は | tee -a "$LOGFILE" 経由で ~/.claude/logs/plugin-auto-disable.log に追記されます。plistの StandardErrorPath も別の場所を指しています。「コマンドが落ちてstderrしか出ない」場合はplist側のログに、「スクリプトが走って処理記録が欲しい」場合はtee側のログに、と役割が分かれています。一本化するより二重にしたほうがデバッグが速いです。

6. set -uo pipefail を必ず入れてサイレント失敗を殺す

LaunchAgent経由のスクリプトはターミナルと違い、失敗してもユーザーに何も見えません。set -uo pipefail がなければ、jq が見つからなくても後続が続行し「何もしなかった」ように見えます。-uo pipefail を入れることで、パイプの中間失敗もコマンドのゼロ以外終了コードも即スクリプト終了になります。StandardErrorPath に指定したログファイルにエラーが書かれるので、失敗の原因が残ります。

7. plutil -lint でplistをloadの前に必ず検証する

plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist

問題がなければ com.shun.plugin-auto-disable.plist: OK が返ります。XMLエスケープ漏れやdict/arrayの閉じタグ忘れはここで捕まえます。launchctl load はパースエラー時に黙って失敗するケースがあるので、lint後にloadします。

8. launchctl unloadlaunchctl load を徹底してplistの変更を反映させる

plistを編集しても、すでにloadされているジョブは新しい設定で動きません。変更のたびに launchctl unload ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plistlaunchctl load ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist の順で入れ直します。macOS Ventura以降は launchctl bootout / launchctl bootstrap を推奨する記事もありますが、ユーザースコープのLaunchAgentについては unload/load が引き続き動きます。

9. plugin-usage.shを手動で走らせて集計結果を1回確認してからLaunchAgent任せにする

~/.claude/scripts/plugin-usage.sh 14

を直接実行してDormantリストを目視します。「知らないプラグインがいる」「Dormant数がマイナスになる」「使ったはずのプラグインが未使用扱いになる」が出たら、jqパイプライン・settings.jsonのenabledPlugins・LOG_DIRのパスのいずれかに問題があります。手動確認なしにLaunchAgentに入れると、誤動作が週次まで気づきません。

10. tr -d ' 'wc -l の後ろに必ず挟む

macOSの wc -l は数字の左にスペースを付けます。wc -l < "$TMP" 12 を返す環境で、その値を直接Markdownに埋めると見た目が崩れ、数値比較では不定動作になります。wc -l < "$TMP" | tr -d ' ' の一手間が後のバグを防ぎます。GNU coreutilsで書いたスクリプトをmacOSに持ち込んだときの定番地雷です。

11. is_protected() は部分一致ではなく完全一致で判定する

plugin-auto-disable.shis_protected() 関数は [ "$p" = "$x" ] の完全一致です。code-reviewcode-reviewer を区別するためです。grep -q "$x" などで部分一致にすると、code-review がProtectedのとき code-reviewer まで保護対象になります。プラグイン名は似た名前のものが多いため、完全一致が安全です。

12. daily audit(14日窓)と weekly disable(30日窓)でウィンドウを意図的にずらす

plugin-usage.sh のデフォルト DAYS=14plugin-auto-disable.shDAYS=30 は異なる理由で設定されています。auditは直近2週間の活性度を毎日見るためのもので、disableは「1ヶ月以上まったく呼ばれていない」という確信を得てから実行するものです。audit側を30日にすると「1ヶ月単位の傾向」を毎日眺めることになり、変化が見えにくくなります。audit=短め・disable=長めの分離は意図的です。

13. disable後に該当プラグインのdeferred tools行数を次のセッションで確認する

実際に1回の週次applyでどれだけdeferred tools欄が減るかをセッション開始時のsystem-reminderで確認します。2026-07-13の実行ログでは expo(47MB)・sequential-thinking(23MB)・drawio-skill(18MB)・agent-eval(12MB)・benchmarks(9MB)の5本が無効化されました。playwrightが30ツール名を列挙するように、これらも合計で数十〜百近い行をsystem-reminderに持っています。数字で減少を確認することで、仕組みが機能していることを実感できます。


まとめ

プラグインの自動管理は「どのツールを使うか」の最適化ではなく、「使わないツールが毎セッションの先頭を侵食する」構造問題への対処です。

plugin-usage.sh がjqで実際のtool_useイベントだけを数え、plugin-auto-disable.sh がProtected除外・キャッシュサイズフィルタ・週5本上限という3段階の安全弁を通してからsettings.jsonを書き換え、2本のLaunchAgentが毎日09:30と毎週日曜06:45に完全無人で回す——この配線が完成してから、「プラグインを足したまま放置する」という問題は意識から消えました。

grepで幽霊を数えていたこと、zshの status 変数を踏んだこと、launchdにPATHを渡し忘れたこと、macOSの wc -l がスペースを返すこと。どれも「ターミナルで動いたのにLaunchAgentで動かない」系の失敗でした。こういう失敗が積み重なって「環境を育てる」ことへの理解が深まります。

月商120万の自律環境は、派手な機能よりも「見えない摩擦を削り続けること」の積み上げでできています。プラグイン管理もその一つに過ぎませんが、毎セッションのコンテキスト先頭を守ることはClaude Codeの判断品質に直接効きます。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


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

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