Hitting ¥1.2M/month taught me something I didn't expect: keeping the environment healthy has to come before doing the work.
Adding a plugin to Claude Code takes a second. "Let's add context7." "Let's add playwright." "Might as well try expose too." Before I noticed, I had more than 50 plugins enabled. That's where the problem starts.
Every time Claude Code starts, a deferred tools section expands inside a system-reminder
block. It's the part that enumerates every tool name owned by the connected MCP servers. The more plugins you have, the more that section swells. playwright alone lines up close to 30 tool names, from browser_click
to browser_wait_for
.
This is a quiet tax on the context window. The first few thousand characters of input tokens in every single session disappear into a list of tool names that have nothing to do with the actual work. It shows up as degraded response accuracy early in a session, or as "that piece of information" getting pushed out at the end of a long conversation.
After half a year of building out an autonomous environment, I can say this with confidence: leaving unused plugins enabled is not the same as leaving unused tools lying on your desk. Tools only eat space. Plugins eat thinking budget in every session.
The root of the problem is the limit of manual management. Out of 50 plugins, it isn't realistic for a human to track "when did I last use this one?" So I built a system instead: every Sunday morning, automatically disable any plugin that has had zero MCP calls and zero Skill calls for 30 days.
Here's the real shape of the deferred tools that get expanded into system-reminder
.
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行以上続く)
That's playwright by itself. Add chrome-devtools, expo, and sequential-thinking, and the enumeration alone runs past 100 lines. At an average of 50 characters per line that's 5,000 characters — comfortably close to 4,000 tokens of fixed cost every session. Disabling unused plugins wipes that out entirely.
Back when I was a university student making ¥100k a month, I focused on "doing more work" to maximize side-income. Juggling gigs got me to ¥600k a month, but there was a hard ceiling. Time is finite, and so is stamina.
Then I was laid off, my income went to zero, and rebuilding over the next six months changed how I think. Once I had an autonomous environment in Claude Code, I understood that the key isn't "how well do I work" but "how well do I maintain the environment."
Automatic plugin management is part of that. Rather than spending 10 minutes a week deciding by hand which plugins to remove, it's far more reliable to write that decision logic into a script and let it run autonomously on a weekly schedule. Precisely because no human is involved, it never forgets. It never hesitates. It never takes a week off.
The whole thing consists of three components.
セッション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 ① generates a report every morning, and LaunchAgent ② performs the disabling weekly. Both run via /bin/zsh
and dump their results into ~/.claude/logs/
.
The core of the report-generating script is counting tool_use events accurately out of the session JSONL.
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"
This jq pipeline does three things.
First, it narrows to type=="assistant". The records of Claude actually calling a tool live inside assistant messages. Other event types such as tool_result are excluded.
Next, it targets only tool_use events. .message.content[]?
expands each content block, and select(.type=="tool_use")
filters them. This is "the biggest difference from the grep implementation" (more on that later).
Finally, it normalizes the plugin name. For the Skill tool, it splits input.skill
on :
and takes the leading part (the plugin name). For MCP tools, it strips the mcp__plugin_
prefix and takes everything up to the next underscore. mcp__plugin_playwright_playwright__browser_click
yields playwright
.
After aggregation, it pulls the list of plugins with enabled=true
out of enabledPlugins
in settings.json
, and uses comm -23
to extract the ones that never appeared in the aggregation, displaying them as "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
It also matters that awk -F@ '{print $1}'
drops the scope portion (@scope
). Even for plugins registered with a version, like context7@1.0.0
, the name portion alone still matches correctly.
This script runs via LaunchAgent daily at 09:30, overwriting the result into ~/.claude/scripts/plugin-audit-latest.md
. The plist contents look like this.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
The absence of a Weekday
key is deliberate — that's what makes it run every day. When I want to see the report, I just open that md file.
The script that actually rewrites settings.json
has three safety layers.
Layer 1: the Protected list
Plugins that must never be removed, even at low usage, are explicitly excluded.
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
typescript-lsp pyright-lsp php-lsp ruby-lsp rust-analyzer-lsp swift-lsp
code-simplifier code-modernization ralph-loop agent-sdk-dev mcp-server-dev
playground commit-commands pr-review-toolkit
azure-cosmos-db-assistant
)
The LSP entries matter most. typescript-lsp
and pyright-lsp
are invoked transparently by Claude Code internally, so they never appear as tool_use
events in the session logs. They're plugins that must stay enabled even with zero usage logs.
Layer 2: the cache size filter
Even when a 30-day zero-call candidate is found, anything with a cache size under MIN_CACHE_MB=5
MB is skipped.
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
Plugins with a small cache have a small impact on the system-reminder, so passing on them costs nothing. Conversely, prioritizing plugins with large caches produces the biggest context reduction per run. That's why sort -rn
orders by size descending and picks from the top.
Layer 3: the weekly cap
A single apply run disables at most WEEKLY_MAX=5
plugins.
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
Dropping a lot at once makes "when did that plugin disappear?" hard to trace. The operating model is five per week, with a record left in ~/.claude/logs/plugin-auto-disable.log
.
The actual disable operation is delegated to a separate script, plugin-disable.sh
. The point is to centralize settings.json
rewrites in one place.
This script runs automatically via LaunchAgent every Sunday at 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>
Weekday
0
is Sunday. 6:45 in the morning is chosen because it's a time when the Mac is running and before work starts. Because of how launchd works, the schedule only fires while the Mac is up, so setting it for a time when the machine is off means it doesn't run until the following week.
Logs are written to two places: ~/.claude/logs/plugin-auto-disable.log
, and com.shun.plugin-auto-disable.log
as specified in the plist's StandardOutPath
/ StandardErrorPath
(because the script does tee -a "$LOGFILE"
internally). Output looks like this.
[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 of cache freed in a single weekly run, and from the next session onward the deferred tools enumeration for those plugins is gone.
Every line of the jq pipeline quoted earlier has a purpose. Let me dig into the easy-to-miss parts in order.
First, the trailing ?
on fromjson?
. That's jq's "error suppression operator," which silently discards lines that can't be parsed as JSON. Claude Code session files are in .jsonl
(JSON Lines) format, but there can be lines truncated mid-write — for example when the Mac goes to sleep during a write. With fromjson
(no ?
), a single parse error halts the entire pipeline. With the ?
, it runs straight through hundreds of thousands of JSONL lines without complaint.
Next, select(.type=="assistant")
. Events in the JSONL are split by the type
field into user
/ assistant
/ tool_result
and so on. The record of a tool being called exists only inside assistant messages. tool_result
contains the tool's response, but there are no type=="tool_use"
blocks in it. Without this filter, the content expansion downstream gets confused.
.message.content[]?
expands the array, and select(.type=="tool_use")
extracts only the actual tool-call blocks. This is the decisive difference from the old implementation, and the cause of the failure described later.
The Skill tool side is handled like this.
if .name=="Skill"
then ((.input.skill // "") | select(contains(":")) | split(":")[0])
select(contains(":"))
is a filter that only passes strings containing a colon. Claude Code skills are called in a form that joins the plugin name and the skill name with a colon, like context7:query-docs
. Built-in skills and invalid values have no colon, so this excludes them. // ""
is for null safety — if input.skill
doesn't exist it returns an empty string, which select then rejects.
The MCP tool side is as follows.
.name | select(startswith("mcp__plugin_"))
| sub("^mcp__plugin_";"") | split("_")[0]
This extracts playwright
from a tool name like mcp__plugin_playwright_playwright__browser_click
. sub
(substitution) strips the prefix, then the remainder playwright_playwright__browser_click
is split on underscore and the first element is taken. There are currently no cases of plugin names containing internal underscores, but if this pattern ever breaks, this is where.
After aggregation, comm -23
extracts the dormant (zero-usage) plugins as a difference.
comm -23 <(echo "$ENABLED_LIST") <(echo "$USED_LIST")
comm -23
is a command that outputs "lines present only in file 1." However, it assumes both lists are already sorted. That's why ENABLED_LIST is always run through sort -u
at construction time, and USED_LIST is deduplicated with sort -u
after aggregation. Neglect this and comm
's output breaks.
awk -F@ '{print $1}'
exists to extract just the name portion from plugins registered with a version, like context7@1.0.0
. In Claude Code's settings.json, enabledPlugins
keys can take the context7@1.0.0
form. Using @
as the delimiter and taking only the first field lets it match correctly against context7
on the usage-log side.
plugin-auto-disable.sh
uses python3 to extract the list of enabled plugins.
ENABLED=$(python3 -c "import json; print('\n'.join(
k.split('@')[0]
for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items()
if v))")
The same thing in jq would be jq -r '.enabledPlugins // {} | to_entries[] | select(.value) | .key'
(that's what plugin-usage.sh uses). Either is fine, but at the time I wrote auto-disable.sh I wanted to avoid the possibility that select(.value)
behaves subtly differently on falsy values (0 or an empty string) depending on the jq version, so I went with python3's if v
to drop falsy values explicitly. The judgment was that in a script that rewrites configuration, the risk of "selecting the wrong target" should be minimized.
The actual disable operation is delegated to plugin-disable.sh
.
"$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}"
Concentrating the settings.json
rewrite logic in one script prevents two places writing at once and corrupting the JSON. Whether it's the weekly auto-disable or a manual one-off disable, the write path is always and only here.
launchd inherits none of your shell environment. It reads neither .zshrc
nor .zprofile
. In other words, even if jq
, python3
, and node
work in your terminal, via launchd they run with only /usr/bin:/bin
on PATH. Homebrew's jq and nvm's node won't be found unless you tell it explicitly.
That's the role of EnvironmentVariables
. Both plists contain the following.
<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's bin is placed first so that nvm always wins when versions get mixed with the system node. Homebrew bin
comes next.
Using >
and >>
differently for log output is also an important design choice. plugin-usage.plist's ProgramArguments looks like this.
<string>/bin/zsh -c '…/plugin-usage.sh 14 > …/plugin-audit-latest.md 2>&1'</string>
>
is the XML escape for >
. It overwrites (>
) every time. audit-latest.md is a file for viewing "the state at this exact moment," so a leftover previous report is meaningless. The plugin-auto-disable.plist command, on the other hand, appends with >>
. That's because a history of what was disabled and when is needed.
ProcessType: Background
tells launchd "no UI access needed, feel free to run in the background." With Interactive, there are cases where it won't run unless the user session is active. For a fully unattended weekly job, Background is the correct setting.
Both plists also specify StandardOutPath
/ StandardErrorPath
, but the 2>&1
redirect inside ProgramArguments takes precedence. Effectively nothing gets written to the plist-side paths. It's redundant, but if I ever remove the command-side redirect in the future, the plist side functions as a fallback. It's a deliberate double structure.
When I first wrote plugin-usage.sh, I didn't use jq — I ran grep across all the JSONL files.
grep -rh "mcp__plugin_${plugin_name}" ~/.claude/projects/ | wc -l
After running it for a while and checking the aggregate results, a suspicious number showed up. terraform was #1 in the usage ranking. I have never used terraform once. On top of that, the usage count exceeded the number of enabled plugins, producing a "negative Dormant count." A minus that shouldn't be able to exist.
I figured out the cause when I looked directly at the JSONL contents. At the start of each session, Claude Code sends a system-reminder
saying "deferred tools are now available." Inside it is a plain-text list of every tool name owned by the enabled plugins.
mcp__plugin_terraform_terraform__workspace_list
mcp__plugin_terraform_terraform__resource_read
…(以下続く)
grep was picking up that enumeration text. One line of JSONL is a JSON object representing one event in the whole session. The system-reminder body sits in a text field inside it, and that body contained a pile of terraform tool names. Because grep searches as strings while ignoring JSON structure, it was miscounting "lines where the system-reminder enumerated tools" as "lines where a tool was called."
With 50 sessions, that's 50 enumerations. Dozens of enumerated lines hit per session, and terraform — which I'd never used — ended up recorded as called 1,000 times.
The fix is to walk the JSON structure properly with jq. The three-stage filter select(.type=="assistant")
→ .message.content[]?
→ select(.type=="tool_use")
extracts only the records where Claude actually called a tool. Text-type content blocks aren't type==tool_use, so the system-reminder enumeration never hits at all.
After the fix, terraform landed in the Dormant list (of course it did), and the negative Dormant count disappeared.
status
variable is read-only When running things through LaunchAgent, I had written logic to emit a notification based on the script's exit code.
plugin-disable.sh apply "${CANDIDATES[@]}"
status=$?
if [ "$status" -ne 0 ]; then
echo "[ERROR] disable failed with code $status" | tee -a "$LOGFILE"
fi
Run manually in the terminal, everything works. Via LaunchAgent, no failure notification ever appears. No matter how much I stared at the logs, no error message was written even when the disable processing failed. Despite having set -uo pipefail
, the script kept going instead of stopping on the error.
The cause is a zsh specification. ** status is a zsh built-in read-only variable** that holds the exit code of the previous command. It's a variable bash doesn't have. Write
status=$?
and zsh [ "$status" -ne 0 ]
then references the current value of status
(the previous command's exit code), but since the assignment never took effect, it doesn't behave as intended.It's the trap you hit when you run a script written for /bin/bash
under zsh as-is. Because LaunchAgent's ProgramArguments used /bin/zsh -c
, it went unnoticed in the terminal (fish/bash startup) and misbehaved only under launchd's zsh.
The fix is just renaming the variable.
plugin-disable.sh apply "${CANDIDATES[@]}"
rc=$?
if [ "$rc" -ne 0 ]; then
echo "[ERROR] disable failed with code $rc" | tee -a "$LOGFILE"
fi
rc
isn't reserved in zsh. That brought the failure notification back. Generally speaking, zsh scripts have many system variables bash doesn't — status
, ARGC
, argv
, match
, and more. Keep just status
in mind and you'll never hit this trap again.
When I first wrote the plist for the LaunchAgent, I didn't include EnvironmentVariables
. jq --version
works in the terminal, but via LaunchAgent the script immediately exits with "command not found: jq". Not even a log. The error that should have been written to StandardErrorPath
was blank, and StandardOutPath
was blank too. The script just went silent and ended.
launchd has only /usr/bin:/bin:/usr/sbin:/sbin
on PATH. Even though Homebrew puts binaries in /opt/homebrew/bin
and nvm in ~/.nvm/versions/node/v24.13.0/bin
, launchd can't see them.
What made it worse: when jq
isn't found, under set -uo pipefail
the whole pipe is treated as a failure, nothing downstream runs, and the log-writing code never executes either. It merely looks like "nothing happened" — in reality it had died at the point jq wasn't found.
The fix is writing an explicit PATH into the plist.
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:…</string>
</dict>
Re with launchctl unload
→ launchctl load
was the point at which jq was finally found and the script started working. As a rule for writing LaunchAgents, I now strictly ** which every command I use and write those paths into EnvironmentVariables**. jq, python3, node — every one of them lives somewhere different from a bare shell's view.
wc -l
returns spaces This is the part of plugin-usage.sh that computes the Dormant count.
USED_COUNT=$(wc -l < "$TMP" | tr -d ' ')
DORMANT_COUNT=$(comm -23 … | wc -l | tr -d ' ')
The trailing tr -d ' '
wasn't there originally. macOS's wc -l
returns not 42
but 42
(space-padded on the left). GNU coreutils on Linux has no such padding; the BSD-derived macOS one does.
Put that output straight into a variable and embed it in Markdown, and the header line gets written out with stray spaces, like - Dormant (enabled but never invoked): ** 12**
. It only breaks the appearance and doesn't affect the arithmetic — but later, when I added code to compare the value numerically, [ "$DORMANT_COUNT" -gt 30 ]
stopped evaluating correctly. The string " 12"
is the same as "12"
in an arithmetic comparison, but behavior varies subtly by environment.
Inserting tr -d ' '
solves it completely. It's a staple step when writing Bash scripts on macOS, but bring a script raised on Linux over as-is and you'll hit it every time.
All four of these failures presented as either "works in the terminal but not under LaunchAgent" or "numbers come out but they're meaningless." What they share is differences in the execution environment. Differences in shell (bash vs zsh), PATH (interactive shell vs launchd), and OS (Linux vs macOS) invalidate code that looks correct on the page. The value of LaunchAgent wiring lies in full unattended operation — but that's exactly what makes it an area where "bugs you'd never notice manually" hide. The design of writing logs twice (script-internal tee -a
plus the plist's StandardErrorPath
) is also a countermeasure against this "fail silently" pattern.
Beyond the four failures covered above (grep ghosts, the status
variable, launchd PATH, and wc -l
padding), there were other points where the actual wiring tripped me up. Each one has real code behind it.
Without printf '%b\n', the tab stays a literal string. On lines 85–90 of
plugin-auto-disable.sh
, the cache size and plugin name are pushed into an array as SIZED+=("${size}\t${p}")
, but in a single-quoted context that \t
is two characters, backslash and t. Only by using the %b
format in printf '%b\n' "${SIZED[@]}" | sort -rn
on line 96 does it expand into a tab. Use echo
or printf '%s\n'
and sort -rn
fails to recognize the fields correctly, breaking the descending size sort.comm -23
breaks silently unless both lists are sorted.comm
assumes its inputs are sorted in lexicographic order. Hand it unsorted input and comm
raises no error — the output is simply undefined. That's why the design shapes ENABLED_LIST
with sort -u
and runs USED_LIST
through sort -u
as well. Test with sort
omitted and plugins that should appear in Dormant don't, or used plugins get mixed into Dormant.
The -u flag of set -uo pipefail kills the script on unbound variables.
plugin-auto-disable.sh
starts with set -uo pipefail
. -u
turns references to uninitialized variables into errors. Referencing ${#SIZED[@]}
while the SIZED
or SELECTED
arrays are still empty is fine, but forget an initial value when adding a variable later and it exits immediately with unbound variable
. Via LaunchAgent, the error log easily ends up in a "the command was launched but there's no output" state, so any variable I add always gets a default value (VAR="${VAR:-default}"
).Without trap 'rm -f ...' EXIT, tmp files linger. Line 59 of
plugin-auto-disable.sh
has trap 'rm -f "$TMP" "$SKILL_TMP"' EXIT
. Under set -uo pipefail
, when the script terminates midway, tmp files stay behind in /tmp/
. LaunchAgent runs the same script weekly, so they pile up and gradually pollute /tmp
. trap
is mandatory as a line of defense.Forget the plist's XML escaping and launchctl refuses to load it. Line 18 of plugin-usage.plist
is this.
<string>/bin/zsh -c '…/plugin-usage.sh 14 > …/plugin-audit-latest.md 2>&1'</string>
Unless >
is escaped as >
and &
as &
, launchctl load
returns a Format error
at the point of parsing the plist as XML and silently does nothing. Accidentally introducing a raw >
while editing is not an unusual mistake. Getting into the habit of verifying beforehand with plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist
prevents it.
Not being aware that Weekday=0 is Sunday means it runs on Monday. launchd's
Weekday
key is zero-based: 0=Sunday, 1=Monday. If you want the weekly disable to "run Monday morning," setting Weekday=1
is correct — but misread it as 0=Monday
and set Weekday=0
, and it runs on Sunday. The practical harm is small, but you'll be confused looking at logs showing "it ran on Sunday for some reason." The Weekday=0
(Sunday) / Hour=6
/ Minute=45
setting in com.shun.plugin-auto-disable.plist
is intentional: it's so the disabling is already done before work starts on Monday.If the Mac is asleep at the StartCalendarInterval time, it doesn't run. Unlike cron, launchd doesn't queue up runs missed during sleep. Set it to 06:45 and, with the Mac closed, that week's run happens. On Macs with PowerNap enabled, background processing can run during sleep, but there's no guarantee. If you need certainty, prevent sleep with
caffeinate -s
, or move the run time to a window when the Mac is definitely up (like 09:30). The daily report generation (09:30) runs reliably precisely because it's during working hours.If plugin-disable.sh doesn't exist, the script stops during apply. Line 121 of
plugin-auto-disable.sh
calls "$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}"
. If that downstream script doesn't exist or isn't executable, set -uo pipefail
ends the entire script right there, and the subsequent echo "[$(ts)] applied disable"
log never appears. "I ran apply but there's no completion record in the log" is this pattern. Checking ls -la ~/.claude/scripts/plugin-disable.sh
and chmod +x
comes first.Expansion accidents when embedding shell variables into python3's -c inline script. Line 66's
python3 -c "import json; print('\n'.join(k.split('@')[0] for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items() if v))"
has $SETTINGS
expanded by the shell inside double quotes. If the path contains a space, python3 raises a parse error. The real path of ~/.claude/settings.json
has no spaces so it's not a problem today, but if the design takes the path from a variable, it's safer to quote $SETTINGS
separately or pass it via a heredoc.Without restarting Claude Code after disabling, the deferred tools section isn't updated. Rewriting settings.json
takes effect immediately, but an already-running Claude Code session retains the settings it loaded at startup. Even after the weekly disable runs, that day's session still expands the deferred tools section in the pre-disable state. Getting the context reduction requires starting the next session. That's one reason the automation is scheduled for early morning: if the disabling is finished before work starts, the effect is there from the first session of the day.
Reproducible principles distilled from actually running this.
1. Make MODE=dry the default and always confirm with a dry-run before apply
Argument handling on line 1 of plugin-auto-disable.sh
is MODE="${1:-dry}"
. Call it with no arguments and you get a dry-run. In the first week after wiring something new, I first check the breakdown of CANDIDATES and the SIZED/SELECTED selection results with a dry-run, confirm no unintended plugin got picked, and only then configure the LaunchAgent to apply.
2. Write the reason in a comment on the Protected list
The PROTECTED list on lines 26–37 of plugin-auto-disable.sh
has a comment on each group explaining why it's protected. Without the line "LSPs: Claude Code may invoke these transparently; they don't appear in tool_use," typescript-lsp would look Dormant every time and keep coming up as a deletion candidate. A list with no reasons gives you no basis for cleanup decisions three months later.
3. Prevent over-disabling with the two-stage filter WEEKLY_MAX=5 and MIN_CACHE_MB=5
Dropping a lot of plugins at once makes "what disappeared" hard to trace. Caches under 5MB have negligible impact on deferred tools, so they're excluded, and the constraint of 5MB-or-more with a max of five gives reliable control. When I want to drop a lot in a hurry, I can override via environment variable, like WEEKLY_MAX=20 plugin-auto-disable.sh apply
.
4. Write the results of which <command> straight into the launchd PATH
For jq
, python3
, and node
alike, where they work in the terminal and what the launchd environment searches are different things. The PATH written into the plist should include every directory found by checking which jq
and which python3
for the commands you use. If you use nvm, that's /Users/<username>/.nvm/versions/node/vX.Y.Z/bin
; for Homebrew, put /opt/homebrew/bin
near the front. The actual setting in com.shun.plugin-auto-disable.plist
is, in order: nvm v24.13.0 bin → opt/homebrew/bin → homebrew/sbin → usr/local/bin → usr/bin → bin → sbin → .local/bin.
5. Give logs a double structure: script-internal tee -a plus the plist's StandardErrorPath
The main log output of plugin-auto-disable.sh
is appended to ~/.claude/logs/plugin-auto-disable.log
via | tee -a "$LOGFILE"
. The plist's StandardErrorPath
points somewhere else. When "the command crashed and only stderr came out," you look at the plist-side log; when "the script ran and I want the processing record," you look at the tee-side log. The roles are split. Doubling up debugs faster than unifying them.
6. Always include set -uo pipefail to kill silent failures
Unlike the terminal, scripts run via LaunchAgent show the user nothing when they fail. Without set -uo pipefail
, even if jq
isn't found, everything downstream continues and it looks like "nothing happened." Adding -uo pipefail
makes both mid-pipe failures and non-zero exit codes terminate the script immediately. The error gets written to the log file specified in StandardErrorPath
, so the cause of failure is preserved.
**7. Always validate the plist with plutil -lint before **
plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist
If there's no problem, it returns com.shun.plugin-auto-disable.plist: OK
. Missing XML escapes and forgotten dict/array closing tags get caught here. launchctl load
can fail silently on parse errors, so lint first, then load.
8. Strictly do launchctl unload → launchctl load to apply plist changes
Edit a plist and an already-loaded job still won't run with the new settings. Every time you change it, reinstall in the order launchctl unload ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist
→ launchctl load ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist
. Some articles recommend launchctl bootout
/ launchctl bootstrap
on macOS Ventura and later, but for user-scope LaunchAgents unload
/load
continues to work.
9. Run plugin-usage.sh manually once and check the aggregation before leaving it to LaunchAgent
~/.claude/scripts/plugin-usage.sh 14
Run that directly and eyeball the Dormant list. If you see "there's a plugin I don't recognize," "the Dormant count is negative," or "a plugin I definitely used is marked unused," then there's a problem in one of: the jq pipeline, settings.json's enabledPlugins, or the LOG_DIR path. Hand it to LaunchAgent without a manual check and you won't notice a malfunction until the weekly run.
10. Always chain tr -d ' ' after wc -l
macOS's wc -l
puts spaces to the left of the number. In an environment where wc -l < "$TMP"
returns 12
, embedding that value directly in Markdown breaks the appearance and produces undefined behavior in numeric comparisons. The small extra step of wc -l < "$TMP" | tr -d ' '
prevents later bugs. It's the classic landmine when bringing a script written for GNU coreutils onto macOS.
11. Make is_protected() judge by exact match, not partial match
The is_protected()
function in plugin-auto-disable.sh
uses the exact comparison [ "$p" = "$x" ]
. That's to distinguish code-review
from code-reviewer
. Make it a partial match with something like grep -q "$x"
, and when code-review
is Protected, code-reviewer
becomes protected too. Plugin names are often similar, so exact match is the safe choice.
12. Deliberately offset the windows for the daily audit (14 days) and the weekly disable (30 days)
plugin-usage.sh
's default DAYS=14
and plugin-auto-disable.sh
's DAYS=30
are set for different reasons. The audit is for looking at activity over the last two weeks, daily; the disable is executed only after gaining confidence that something hasn't been called at all for over a month. Set the audit side to 30 days and you'd be staring at a "monthly trend" every day, which makes change harder to see. The separation of audit=short, disable=long is intentional.
13. After disabling, verify the deferred tools line count for those plugins in the next session
Check at session start, via the system-reminder, how much the deferred tools section actually shrinks per weekly apply. In the 2026-07-13 run log, five plugins were disabled: expo (47MB), sequential-thinking (23MB), drawio-skill (18MB), agent-eval (12MB), and benchmarks (9MB). Just as playwright enumerates 30 tool names, these too hold dozens to nearly a hundred lines in the system-reminder in total. Confirming the reduction in numbers is what makes the system's effectiveness tangible.
Automatic plugin management isn't an optimization of "which tools to use." It's a fix for the structural problem that "tools you don't use erode the top of every session."
plugin-usage.sh
counts only actual tool_use events with jq; plugin-auto-disable.sh
passes through three stages of safety valves — Protected exclusions, the cache size filter, and the five-per-week cap — before rewriting settings.json; and two LaunchAgents run it completely unattended at 09:30 daily and 06:45 every Sunday. Since this wiring was finished, the problem of "adding plugins and leaving them there" has vanished from my mind.
Counting ghosts with grep. Stepping on zsh's status
variable. Forgetting to give launchd a PATH. macOS's wc -l
returning spaces. Every one of them was a "it worked in the terminal but not under LaunchAgent" failure. Failures like these accumulate into a deeper understanding of what it means to cultivate an environment.
A ¥1.2M/month autonomous environment is built less from flashy features than from continually shaving away invisible friction. Plugin management is only one piece of that, but protecting the top of the context in every session bears directly on Claude Code's quality of judgment.
I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day process in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*