DEV Community

Lily
Lily

Posted on • Originally published at dev.to

14 Pitfalls of Letting a Claude Code Environment Rot — and the 70-Line Weekly Audit That Catches Them

Nobody notices their dev environment rotting. It happens one broken MCP server at a time, and by the time you feel it, you have no idea which week it started. My fix was to stop trying to feel it and start measuring it: a 70-line script and a launchd job that snapshot the whole thing every Sunday morning.

Why this works

The more you use Claude Code, the more files pile up. An MCP server you tried once, a plugin you added because it "looked useful," an auto-skill you wrote on impulse — each had a purpose the moment you added it. The problem is what happens after.

An MCP server whose auth token has expired will sit in your config as Failed to connect forever. A plugin's command files can still be on disk while it's gone from enabledPlugins in settings.json — dead weight taking up space. An auto-skill you wrote as a "good enough for now" procedure is still sitting in ~/.claude/skills/auto/ six months later, and you keep paying the context tax of Claude loading it every single time.

This is nothing like "your iPhone doesn't get slower just because you have unused apps." Claude Code's context window is finite, and the total volume of settings, skills, and hooks loaded at startup directly affects the quality of that first response. If 50 plugins are enabled, their metadata rides along in context every time. If 10 MCP servers are stuck at Failed, connection-attempt timeouts drag out your startup.

I learned this the hard way three months into using Claude Code seriously. As my revenue grew, I kept adding MCPs to make things "even more convenient" — and then one week Claude's first response was noticeably sluggish. I dug in and found seven Failed to connect lines in the output of claude mcp list. Five of them I had no memory of ever installing — they'd been added automatically via plugins.

The most expensive state to be in is not knowing something is broken. Three weeks of running with a broken connection means cumulative minutes upon minutes of timeout waiting. A problem you could fix in 10 seconds if you noticed it is pure loss when you don't.

So I built a weekly "health check" that runs automatically and accumulates reports in date-stamped files. Take a diff and you can trace back to exactly which week your MCPs started breaking. It converts the vague feeling of "things seem slow lately" into the fact that "Failed went to 3 as of the report from three Sundays ago."

There's one more reason this works especially well: rot you can't perceive yourself can only be detected by automation. When you use Claude Code every day, your threshold for "feels heavy" keeps creeping up. It can be 20% slower than three months ago and that just becomes normal. Without weekly snapshots, you lose the baseline for comparison.

The overall flow

The whole thing is just two files: the diagnostic script, and a launchd job config that fires it weekly.

[毎週日曜 09:00]
        │
        ▼
launchd が com.shun.env-audit を起動
        │
        ▼
~/.claude/scripts/env-audit.sh を実行
        │
        ├─ jq で settings.json をパース
        │    └─ enabledPlugins の数を取得
        │
        ├─ find で plugin ディレクトリを走査
        │    └─ commands / SKILL.md / agents の実ファイル数
        │
        ├─ claude mcp list(timeout 25 秒)
        │    └─ Connected / Needs auth / Failed を集計
        │
        ├─ jq で hooks の構成を出力
        │
        ├─ ls ~/.claude/skills/auto/ で auto-skill 一覧
        │
        └─ ccusage blocks --active で直近コストを取得
                │
                ▼
    ~/.claude/logs/env-audit-YYYYMMDD.md に書き出し
                │
                ▼
    diff で前週比較 → 「いつ壊れたか」を遡れる
Enter fullscreen mode Exit fullscreen mode

Structure of the script

~/.claude/scripts/env-audit.sh is 70 lines. The whole thing is a single {} block that generates Markdown, redirected to a file.

#!/usr/bin/env bash
set -uo pipefail

OUT="${1:-/tmp/claude-env-audit.md}"
SETTINGS="$HOME/.claude/settings.json"

{
  # ... Markdown を echo で生成 ...
} > "$OUT"
Enter fullscreen mode Exit fullscreen mode

With no argument it writes to /tmp/claude-env-audit.md. When launchd calls it, it passes a date-stamped path as the argument (more on that below).

The Plugin Inventory section counts both the config file and the actual files on disk.

TOTAL=$(jq -r '.enabledPlugins // {} | length' "$SETTINGS")
echo "- Enabled plugins: **$TOTAL**"
echo "- Plugin commands on disk: **$(find $HOME/.claude/plugins -path '*/commands/*.md' 2>/dev/null | wc -l | tr -d ' ')**"
echo "- Plugin skills on disk: **$(find $HOME/.claude/plugins -name 'SKILL.md' 2>/dev/null | wc -l | tr -d ' ')**"
echo "- Plugin agents on disk: **$(find $HOME/.claude/plugins -path '*/agents/*.md' 2>/dev/null | wc -l | tr -d ' ')**"
Enter fullscreen mode Exit fullscreen mode

The gap between the enabledPlugins count and the number of real files on disk is your "zombie file" indicator. Plugins that have been removed from the config but remain on disk don't cost you context, but they're cleanup candidates. Conversely, if something is listed in enabledPlugins but has no files on disk, that plugin isn't working.

The MCP Server Status section is the heart of it.

MCP_OUT=$(timeout 25 claude mcp list 2>&1)
TOTAL_MCP=$(printf '%s' "$MCP_OUT" | grep -cE "://|^plugin:|^claude\.ai")
OK=$(printf '%s' "$MCP_OUT" | grep -c "Connected")
AUTH=$(printf '%s' "$MCP_OUT" | grep -c "Needs auth")
FAIL=$(printf '%s' "$MCP_OUT" | grep -c "Failed to connect")
Enter fullscreen mode Exit fullscreen mode

timeout 25 matters. When an MCP server is unresponsive, claude mcp list itself can hang. Putting a 25-second timeout on it means a broken server won't stall the entire script.

The results are output both as a one-line summary and as detailed lists of Failed / Needs auth.

echo "- Total: $TOTAL_MCP / Connected: **$OK** / Need auth: **$AUTH** / Failed: **$FAIL**"
Enter fullscreen mode Exit fullscreen mode

Next, the Hooks section uses jq to list event types and registration counts.

jq '.hooks | to_entries | map({event: .key, count: (.value | length)})' "$SETTINGS" 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

Hooks are a category that grows easily and feels scary to delete from. Checking "how many hooks are attached to UserPromptSubmit" weekly lets you catch unintentionally duplicated hooks piling up early.

The Auto-skills section simply prints a list.

ls "$HOME/.claude/skills/auto/" 2>/dev/null | grep -v README
Enter fullscreen mode Exit fullscreen mode

In my environment there are currently more than 20 SKILL.md files in ~/.claude/skills/auto/. Keeping the ones whose purpose has expired inflates the context Claude loads in its system prompt. Eyeballing the list weekly gives you the trigger for "oh right, I don't use this anymore."

The Cost section pulls the cost of the most recent active block.

ccusage blocks --active 2>&1 | grep -E "Block|Time|Tokens:|Cost:|/h" | sed 's/^/  /'
Enter fullscreen mode Exit fullscreen mode

In weeks where MCP connection failures increase, retry costs can get tacked on. Putting MCP status and cost trends in the same report lets you read the correlation after the fact: "the reason cost spiked this week was MCP instability."

The Recommendations section is threshold-based automatic judgment.

[ "$FAIL" -gt 0 ] && echo "- ⚠️  $FAIL MCP servers failed. Review/disable to reduce startup time."
[ "$AUTH" -gt 5 ] && echo "- ⚠️  $AUTH MCP servers unauthenticated. Either auth or disable to reduce noise."
[ "$TOTAL" -gt 50 ] && echo "- ⚠️  $TOTAL plugins enabled - likely heavy context tax. Consider pruning unused."
Enter fullscreen mode Exit fullscreen mode

Rationale for the thresholds: FAIL > 0 is zero tolerance (even one failure needs handling). AUTH > 5 comes from experience — "up to 5 is an acceptable range where per-project auth prompts can legitimately be pending." plugins > 50 I set from the experience that "past 50, the amount of context injected at startup gets perceptibly heavy."

Setting up the launchd job

~/Library/LaunchAgents/com.shun.env-audit.plist is the weekly trigger.

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
    <key>Weekday</key>
    <integer>0</integer>
</dict>
Enter fullscreen mode Exit fullscreen mode

Weekday 0 is Sunday. It fires every Sunday at 9:00. I picked Sunday morning for the weekly run because I want to know the state of things before I start working on Monday. I settled on that after repeating the cycle of adding an experimental MCP over the weekend and forgetting about it by Monday a few too many times.

The execution command dynamically generates a date-stamped filename like this.

<string>/bin/zsh -c ~/.claude/scripts/env-audit.sh
  ~/.claude/logs/env-audit-$(date +\%Y\%m\%d).md &gt; /dev/null 2&gt;&amp;1</string>
Enter fullscreen mode Exit fullscreen mode

In a plist you have to escape % and write it as \%Y\%m\%d. Forget this and launchd can't interpret the date command correctly, so the filename becomes a fixed literal string (I stepped on this exactly once).

Process priority settings need attention too.

<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
Enter fullscreen mode Exit fullscreen mode

Nice 10 lowers scheduling priority, and LowPriorityIO deprioritizes I/O as well. Because claude mcp list performs connection attempts internally, running it at 9:00 Sunday right after the Mac wakes from sleep affects other processes. Shunting it off with background settings is the safe move.

Both stdout and stderr are consolidated into ~/.claude/logs/com.shun.env-audit.log. The script's own output (Audit written to: ... plus a preview of the first 80 lines) lands there, so verifying "did launchd actually run" is just a tail on that file.

Retroactive tracing with diff

Since reports accumulate as env-audit-20260727.md, env-audit-20260803.md, and so on, the diff against last week is one command away.

diff ~/.claude/logs/env-audit-20260727.md ~/.claude/logs/env-audit-20260803.md
Enter fullscreen mode Exit fullscreen mode

Typical change patterns:

  • If Connected: **8**Connected: **5** / Failed: **3** happened within one week, something broke that week
  • If Enabled plugins: **34**Enabled plugins: **41**, you know you enabled 7 plugins that week
  • If new names appear in the auto-skill list, someone (you or Claude) generated a new skill

Where this diff workflow really earned its keep was when I noticed "MCP Failed has been climbing since two weeks ago." Checking that week's work log revealed things had broken right after I added a particular plugin, and disabling it restored everything. Without date-stamped reports it would have ended at "it somehow got fixed."

Implementation details

Why I chose a single redirect block { } > "$OUT"

The whole script has this structure.

{
  # すべての echo がここに入る
} > "$OUT"
Enter fullscreen mode Exit fullscreen mode

The first version I wrote was the naive implementation of repeating echo "..." >> "$OUT" on every line. Problems surfaced immediately. When the script exits with an error partway through, append mode >> leaves behind "a half-written file with a few lines in it." Diff it against the next report and the previous run's debris mixes in as noise.

Switching to the { } > "$OUT" block means the file is opened once. All output from the block flows to the same file descriptor, so the "stopped in the middle of writing" state is less common (the file itself is truncated when first opened, but leftover debris on write errors is minimal). The other benefit is readability. You can't forget to attach >> "$OUT" to a line, and the indentation lines up so it's easier to review. The structure being easy to follow despite being a 70-line script is thanks to this.

The rationale for timeout 25

This is the core of the MCP section.

MCP_OUT=$(timeout 25 claude mcp list 2>&1)
Enter fullscreen mode Exit fullscreen mode

claude mcp list attempts to connect to every configured MCP server. If a server is stdio-type (launching a local process), it responds within a second. Even HTTP-type ones answer within 3 seconds if the server is healthy. The problem is servers that look alive but are hung. The TCP connection establishes, but a response never comes — this is what happens when you leave a dev mock server you spun up locally lying around.

Without timeout, claude mcp list gets blocked by that server and the entire script stalls. I settled on 25 seconds from the experience that "a healthy server takes at most 5 seconds, and the TCP timeout for a broken server is around 20 seconds depending on the OS," plus 5 seconds of margin. Even with two or three broken MCPs lined up, the timeouts are processed concurrently, so cutting it off at 25 seconds is plenty.

Where to put 2>&1 and where not to

2>&1 shows up multiple times in the script. The criterion for which to use is "does that information belong in the report?"

MCP_OUT=$(timeout 25 claude mcp list 2>&1)          # stderr を拾う
find $HOME/.claude/plugins ... 2>/dev/null           # stderr を捨てる
ccusage blocks --active 2>&1 | grep -E "..."         # stderr を stdout に合流させてフィルタ
Enter fullscreen mode Exit fullscreen mode

claude mcp list writes connection-failure messages to stderr. Without 2>&1, MCP_OUT comes out empty and you get a report with zeros across the board. I forgot this at first and believed a lying "all MCPs Connected" report for a while.

find's 2>/dev/null is the opposite — noise removal to keep "No such file or directory" out of the report when the plugin directory doesn't exist.

The relationship between grep -c and the :-0 default assignment

OK=$(printf '%s' "$MCP_OUT" | grep -c "Connected")
AUTH=$(printf '%s' "$MCP_OUT" | grep -c "Needs auth")
FAIL=$(printf '%s' "$MCP_OUT" | grep -c "Failed to connect")
TOTAL_MCP=${TOTAL_MCP:-0}; OK=${OK:-0}; AUTH=${AUTH:-0}; FAIL=${FAIL:-0}
Enter fullscreen mode Exit fullscreen mode

grep -c prints the string "0" to stdout when there are zero matches, but returns exit code 1. Because set -o pipefail is in effect, that pipeline's exit status becomes 1. This is exactly why the script doesn't have set -e (errexit). Add -e and you get the paradox that in a "healthy week" where all MCP servers are Connected, Fail is 0 → grep -c exits 1 → the script terminates.

The :-0 default assignment is a separate safeguard. If timeout fires and the command is force-terminated, the command-substitution variable can end up as an empty string. Evaluating [ "$FAIL" -gt 0 ] on an empty string causes an arithmetic error, so it falls back to 0.

The culprit-identification pipeline in Section 6

echo "$MCP_OUT" | grep -E "Failed to connect" | grep "^plugin:" \
  | awk -F: '{print $2}' | sort -u | head -20 | sed 's/^/- /'
Enter fullscreen mode Exit fullscreen mode

The output of claude mcp list mixes lines in the format plugin:プラグイン名:スキル名 with lines registered directly by URL, depending on how the MCP was registered. Narrowing to only plugin-sourced MCPs with grep "^plugin:" matches the unit of the operation you'd actually perform: "if you're deleting it, you disable the whole plugin." awk -F: '{print $2}' extracts only the second colon-separated field (the plugin name), sort -u removes duplicates when the same plugin owns multiple MCPs, and head -20 limits the output. It's designed so the output doesn't overflow even in a catastrophic state.

Why the launchd job needs EnvironmentVariables

There's an explicit PATH at the top of the plist.

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/path/to/nvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:...</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

Processes launched by launchd read neither ~/.zshrc nor ~/.zprofile. The script runs with only a minimal PATH equivalent to /etc/paths (roughly /usr/bin:/bin:/usr/sbin:/sbin). The claude command is a Node.js binary under nvm's management, and jq is a Homebrew binary — neither is in the default PATH. Without this setting, the script appears to complete normally but outputs a nearly empty report (each command fails with command not found and the variables end up empty).


Where I got stuck

#1: launchd can't see claude

When I first created the plist, I forgot to include EnvironmentVariables.

Symptom: The report file updates every week. But looking at the MCP section, Connected and Failed are all 0, and the Cost section is empty too. The script runs but the report is blank.

Investigation: tail ~/.claude/logs/com.shun.env-audit.log showed the following lined up.

zsh: command not found: claude
zsh: command not found: jq
zsh: command not found: ccusage
Audit written to: /path/to/env-audit-20260720.md
Enter fullscreen mode Exit fullscreen mode

The script runs to completion without errors. The -u (undefined variable error) from set -uo pipefail didn't trip either, and because command not found was either captured into a variable via 2>&1 or discarded to /dev/null, the script itself finished with exit code 0. This is when I learned that "it's running" and "it's producing meaningful output" are two different things.

Fix: Running which claude also gave not found. I identified the Node.js path with nvm which current and added that bin/ directory to the plist's EnvironmentVariables > PATH. Likewise added /opt/homebrew/bin for jq. Reloaded with launchctl unload ~/Library/LaunchAgents/com.shun.env-audit.plist && launchctl load ~/Library/LaunchAgents/com.shun.env-audit.plist, then ran the script manually and confirmed every section filled in before calling it done.

This debugging burned 2 hours. "Cron-style jobs get an explicit PATH" is now reflex.

#2: The date didn't expand and I got a fixed filename

Symptom: ~/.claude/logs/ should have had date-stamped files like env-audit-20260720.md, but instead there was a single file with the literal name env-audit-%Y%m%d.md. Since it gets overwritten every week, no diff is possible.

Cause: Inside a plist <string>, the % symbol is treated specially. Write date +%Y%m%d and launchd tries to interpret %Y and %m as format specifiers, and it doesn't expand properly. The correct form escapes them as \%Y\%m\%d.

<!-- 誤り: リテラル文字列になる -->
<string>~/.claude/scripts/env-audit.sh ~/.claude/logs/env-audit-$(date +%Y%m%d).md</string>

<!-- 正しい: \% でエスケープ -->
<string>~/.claude/scripts/env-audit.sh ~/.claude/logs/env-audit-$(date +\%Y\%m\%d).md</string>
Enter fullscreen mode Exit fullscreen mode

While I'm at it: ~ does not expand to your home directory inside a plist. You need to write absolute paths. Not knowing that, I wrote ~/.claude/logs/... and it errored trying to create a ~ directory (which doesn't exist). Write absolute paths, keep spaces out of paths, escape % — memorize those three as plist rules and you won't get stuck.

Fix: Corrected the escaping, changed paths to absolute, and reloaded. Confirmed with ls -lt ~/.claude/logs/env-audit-*.md that new date-stamped files get created.

#3: claude mcp list hung for over 10 minutes

The first version of the script had no timeout.

Symptom: Starting one particular week, the launchd log stops partway. Everything from the Hooks section onward, which should appear in the head -80 preview, isn't written. The file ends in the middle of the MCP section.

Running ps aux | grep claude showed claude mcp list still executing with a PID. Checking the time, 12 minutes had elapsed.

Cause: That week I'd tried an HTTP-type mock MCP server I stood up during local development and left the container running. The server's process was dead, but since I hadn't removed it from the config, claude mcp list kept attempting to connect. TCP SYN doesn't reach it, but it kept waiting on the OS connection timeout (macOS defaults to around 75 seconds). Just one broken HTTP MCP was blocking the entire script for over a minute.

Fix: Changed to timeout 25 claude mcp list 2>&1. I also reviewed my MCP config and deleted 3 unused HTTP-type servers. Startup time after deletion felt noticeably faster. Running time timeout 25 claude mcp list finished in 2.3 seconds — I didn't even want to know how long it used to take.

Reflecting after this debugging session on "why did I leave broken MCPs around," the answer is simply "I wasn't looking at the list." Without a habit of typing claude mcp list manually, you don't notice when things are broken. As motivation for automating environment diagnostics, this hang experience was the most effective one.

#4: The -u in set -uo pipefail bit me somewhere else

A very early version of the script didn't have the :-0 default assignments on line 28.

# 古いバージョン(デフォルト代入なし)
OK=$(printf '%s' "$MCP_OUT" | grep -c "Connected")
FAIL=$(printf '%s' "$MCP_OUT" | grep -c "Failed to connect")

echo "- Connected: **$OK** / Failed: **$FAIL**"
Enter fullscreen mode Exit fullscreen mode

Symptom: In weeks where timeout 25 claude mcp list terminated on timeout (i.e., weeks where MCP was hanging), nothing from the MCP section onward appears in the report. No output in the log.

Cause: When timeout fires it returns exit code 124. If the timeout happens inside a command substitution $(), the assignment to the outer variable still executes, but the contents of MCP_OUT can end up as an empty string (because the output captured via 2>&1 gets cut off partway). Feeding that empty input to grep -c sets FAIL="0" as a zero-match count, but TOTAL_MCP was in some cases referenced while still undefined. set -u treats that as an "unbound variable" and the script terminates.

Fix: Added the default-assignment block TOTAL_MCP=${TOTAL_MCP:-0}; OK=${OK:-0}; AUTH=${AUTH:-0}; FAIL=${FAIL:-0}. Now even on timeout the variables converge to 0 and the report gets generated all the way to the end. :-0 isn't merely setting an initial value — it's insurance for the failure path.


Lining up all four failures, something becomes apparent. None of them is "a bug in the script itself" — they're all "insufficient handling of a broken environmental assumption." PATH is different, escaping rules are different, there's no timeout, there's no default value. Individually each is trivial, but combined they mean no report comes out. The state of "it's running but it's meaningless" is the hardest bug to detect. The current script is the result of stacking up a fix for each one, one at a time.

Gotchas

Operations start for real once implementation is finished. Separate from bugs inside the script (4 of which I covered above), there are places you get stuck in the "post-deployment operations phase." Here are the ones I actually experienced.

launchctl load alone doesn't apply plist changes

After editing a plist, the first thing most people do is "run launchctl load ~/Library/LaunchAgents/com.shun.env-audit.plist again." But if the job is already loaded, you get Load failed: 5: Input/output error back, or it fails silently. Either way the changes aren't applied. The correct procedure is the unload-then-load set.

launchctl unload ~/Library/LaunchAgents/com.shun.env-audit.plist
launchctl load  ~/Library/LaunchAgents/com.shun.env-audit.plist
Enter fullscreen mode Exit fullscreen mode

I've had the experience of skipping that one step and agonizing for 30 minutes over "why aren't my changes taking effect" at least 3 times. Now, whenever I touch a plist, I reflexively run unload && load as a pair.

I thought I had to wait a week after registering to test it

I convinced myself that "it only runs Sunday at 9:00" and waited until Sunday to verify it worked the first time. Embarrassing, but true. You can manually trigger it at any time with launchctl start.

launchctl start com.shun.env-audit
Enter fullscreen mode Exit fullscreen mode

tail ~/.claude/logs/com.shun.env-audit.log right after running and you can check whether the script ran and whether every section filled in. The standard practice after registering a launchd job is to verify "does it run right now" before "does it run weekly."

Fixing the script but forgetting to reload, so an old version keeps running

If you edited ~/.claude/scripts/env-audit.sh directly to improve the script internals, no reload is needed since the plist merely references the path. It runs with the updated contents from the next launch.

On the other hand, if you changed plist settings (PATH, schedule, arguments, etc.), unload && load is required. Rather than confusing these two cases and wondering "which was it again?" every time, unifying on the rule "if you touch the plist, always reload" eliminates the decision cost.

Nobody reads the generated report

The most classic automation trap is the state of "it's running but nobody reads it." Even if env-audit-20260727.md gets added to ~/.claude/logs/ every week, it's meaningless without a path that leads you to check it.

The first version I built just "generated a log on Sunday morning." When I fired up Claude Code on Monday, there was nothing prompting me to check the report. Two months later I happened to look at the logs and found Failed: 3 lined up across five weeks' worth — that actually happened.

The solution is to build a separate "reading mechanism." After adding a hook that force-displays the first lines of the latest report to Claude in the first session on Monday morning, the number of cases where handling got pushed to the following week went to zero.

Log files grow without limit

Since env-audit-YYYYMMDD.md gets added weekly, that's 52 files in a year. Space isn't an issue, but when you run ls -l ~/.claude/logs/env-audit-*.md, the old files blur your diff baseline.

Running a "keep only the last 3 months" cleanup monthly makes it easier to manage.

find ~/.claude/logs -name "env-audit-*.md" -mtime +90 -delete
Enter fullscreen mode Exit fullscreen mode

I append this one line after the } > "$OUT" block at the end of the script. It centralizes management by keeping report generation and cleanup in the same script without adding another file to manage.

The diff is too big to read

Comparing consecutive reports with plain diff gets noisy because every line of the Cost section changes on every run. To compare section by section, narrowing with grep is practical.

# MCPの状態だけ週次比較
grep "Total:\|Connected\|Failed\|Need auth" ~/.claude/logs/env-audit-20260727.md
grep "Total:\|Connected\|Failed\|Need auth" ~/.claude/logs/env-audit-20260803.md
Enter fullscreen mode Exit fullscreen mode

Making the weekly check a procedure that takes under 30 seconds prevents the feeling that "checking is a hassle."

If ccusage isn't installed, it silently stays empty

The Cost section (Section 5) depends on ccusage blocks --active. In an environment where ccusage isn't installed, the whole section comes out empty. The script picks up stderr with 2>&1, but the subsequent grep -E "Block|Time|Tokens:|Cost:|/h" filter skips the zsh: command not found: ccusage line. In other words, "a report with an empty Cost section" gets silently generated every week.

When porting to a new environment, checking in advance with which ccusage is the reliable move. Under nvm management, after installing with npm install -g ccusage, re-confirm that the plist's PATH includes ~/.nvm/versions/node/<バージョン>/bin.

Leaving MCP Failed as "I'll fix it later"

If Failed: 2 shows up in the report and you leave it as "I'll fix it later," Failed: 2 shows up again the next week. "Later" never comes. Deleting an HTTP-type MCP is one command, claude mcp remove <name>, and takes 3 seconds. The reason the script's threshold is FAIL > 0 (zero tolerance) is that tolerating even one slides into an operating norm of "up to 2 is OK." When ⚠️ 1 MCP servers failed. appears in the Recommendations section, I handle it the same day I see it.

Not knowing how to check job status with launchctl list

launchctl list com.shun.env-audit
Enter fullscreen mode Exit fullscreen mode

This command returns the PID (a number if running, - if stopped) and the last exit code (LastExitStatus). If LastExitStatus is non-zero, the script terminated abnormally. Before you tail the log, this command lets you check "did it run" and "did it exit cleanly" in one second. If you don't know it exists, you stay stuck in the "but it should be running" state.

The plist PATH goes stale after an nvm version update

When you bump the Node.js version with nvm, the binary paths change. For example, if you go from v24.13.0 to v24.15.0, the ~/.nvm/versions/node/v24.13.0/bin directory written in the plist no longer exists. From that week on, claude mcp list doesn't work and you get a report with all zeros in the MCP section. Changing versions with nvm should always come paired with running which claude, updating the plist's PATH, and reloading.


Best practices

1. Manually trigger right after registering and verify every section

Run launchctl start com.shun.env-audit immediately after launchctl load. tail ~/.claude/logs/com.shun.env-audit.log and visually confirm that the MCP section has numbers and the Cost section has values. If MCP shows "Total: 0 / Connected: 0" here, it's a PATH problem. Getting the all-sections check done up front makes the cost of discovering "empty reports have been arriving for weeks" after the fact zero.

2. Verify the plist PATH with which before writing it

which claude    # ~/.nvm/versions/node/v24.13.0/bin/claude
which jq        # /opt/homebrew/bin/jq
which ccusage   # ~/.nvm/versions/node/v24.13.0/bin/ccusage
Enter fullscreen mode Exit fullscreen mode

Extract the bin/ portion from those 3 commands and list them in the plist's PATH. When you bump versions with nvm, the same check commands immediately show you the difference.

3. unload && load as a set when changing the plist

Whenever you touch the plist, always run the set launchctl unload ~/Library/LaunchAgents/com.shun.env-audit.plist && launchctl load ~/Library/LaunchAgents/com.shun.env-audit.plist. Rather than deciding case by case that "script changes need no reload, plist changes do," it's safer to unify on "if you touch the plist, always reload."

4. Namespace the plist Label with your own handle

Change the shun part of com.shun.env-audit to your own handle. When your launchd jobs multiply, launchctl list | grep com.自分のハンドル lets you filter to just your own. You can tell them apart even when mixed in with jobs auto-generated by other tools.

5. Use "number of MCPs × 5 seconds" as the guideline for timeout

timeout 25 is a number calculated from my MCP registration count at the time. The basis is 5 healthy MCPs × 5 seconds max = 25 seconds. In an environment with 30+ MCPs, either raise it to timeout 60, or — the real fix — clean up the MCPs themselves. Keep raising the timeout value and it mutates into "a mechanism that works even if you leave broken MCPs around." Cleaning up MCPs comes before modifying the script.

6. FAIL > 0 is zero tolerance — handle it the day you see it

If even one Failed appears, handle it the same day you see it with claude mcp remove <name> or claude mcp auth <name>. Deleting an HTTP-type MCP takes 3 seconds; re-authenticating a stdio-type one is a single command. "Later" turns into "never."

7. Decide clear criteria for deleting auto-skills

When you look at the ls ~/.claude/skills/auto/ listing, ask yourself "when was the last time Claude used this?" Any skill you don't remember being invoked in over six months is a deletion candidate. Without deletion criteria, skills grow without limit and the context tax quietly keeps rising. After I set the rule "delete if unused for six months," the file count in ~/.claude/skills/auto/ settled into a manageable range.

8. grep just the specific section before reading the diff

A full-text diff takes time to read. If you only want to know "did MCP break this week,"

grep "Total:\|Connected\|Failed" ~/.claude/logs/env-audit-20260727.md
grep "Total:\|Connected\|Failed" ~/.claude/logs/env-audit-20260803.md
Enter fullscreen mode Exit fullscreen mode

narrows it to that section. Making it a procedure you can check in under 30 seconds prevents "checking is a hassle."

9. Build a separate mechanism for reading the reports

If you only automate generation with no path to reading them, the reports are meaningless. Combine either a hook that force-displays the latest report to Claude in the first session on Monday morning, or a Slack webhook that sends a summary. This system only has value once the cycle of "diagnose weekly → understand before the week's first session → handle it on the spot" is established.

10. Version-control the script itself

Put all of ~/.claude/scripts/ into a private dotfiles repository and commit every time you improve a script. The context of "why did I change this line" is preserved, and "I want to roll back to the version from 3 months ago" takes 10 seconds. Managing the script as a standalone file makes tracing the cause difficult once it degrades.

11. Check with claude mcp list immediately after adding a plugin

When you add a new plugin, type claude mcp list right then and confirm no Failed appeared. By checking right when your memory and the state still line up, rather than waiting for the weekly report, you discover problems in a state where "which plugin caused this" is self-evident.

12. Check job status in one second with launchctl list com.shun.env-audit

launchctl list com.shun.env-audit
Enter fullscreen mode Exit fullscreen mode

Returns the PID (a number if running, - if stopped) and LastExitStatus (non-zero means abnormal termination). Check "did it run" with this first, before reading the log.

13. Sweep out zombie files once every 3 months

Plugin files that remain on disk after being removed from enabledPlugins stay in find's scan scope. If you have "Plugin commands on disk: 83" but "Enabled plugins: 31," 52 files are zombies. Once every 3 months, check under ~/.claude/plugins/ directly and clean up directories whose corresponding plugin is disabled with rm -rf.

14. Tune the plugin count threshold to your own perception

The script's TOTAL > 50 threshold is an empirical value from my environment. A commands-only plugin and a plugin with skills and agents carry different context-tax weight. A realistic tuning method is to record the plugin count at the point you start to feel it's "heavy," and set the warning threshold at that value × 0.8.

15. Append find ~/.claude/logs -name "env-audit-*.md" -mtime +90 -delete at the end of the script

Building log rotation into the script itself makes "auto-delete reports older than 3 months" work with no additional configuration. It's just one line added at the end, after } > "$OUT". Report generation and cleanup fit in the same script, and you don't add another job to manage.


Wrapping up

The sense that "my environment is rotting" is inherently vague. MCP connection failures can pile up, auto-skills can multiply, and you'll almost never notice in the week it happens. Keep using it without noticing and the increased context tax, startup delay, and added retry cost accumulate bit by bit, until months later all that's left is the feeling that "things seem slow lately."

What env-audit.sh and the launchd job solve is the problem you don't notice. A 70-line script takes a weekly snapshot of the current state, accumulates it with a date stamp, and creates a record that lets you trace back via diff to when things broke — that's all. There's no difficult technology involved anywhere.

What I learned along the way is that "adding tools" and "maintaining tools" carry separate costs. Adding is instantaneous; managing is weekly. Every addition stacks more management cost on top. Unless you recoup that with automation, your environment keeps growing as debt rather than capability.

The weekly health check is the mechanism that makes Claude pay that cost. A state where humans don't have to care whether MCPs are broken is what lets human thinking stay focused on the actual work.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)