{"slug": "14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that", "title": "14 Pitfalls of Letting a Claude Code Environment Rot — and the 70-Line Weekly Audit That Catches Them", "summary": "A developer built a 70-line script and launchd job that snapshots a Claude Code environment every Sunday to catch configuration rot before it degrades performance. The audit parses settings, plugins, MCP servers, hooks, and auto-skills, writing date-stamped reports that allow diffing to pinpoint when a connection or plugin broke. The developer warns that unused MCP servers and plugins accumulate silently, taxing the context window and slowing startup, and that automation is necessary because users' tolerance for sluggishness creeps up over time.", "body_md": "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.\n\nThe 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.\n\nAn MCP server whose auth token has expired will sit in your config as `Failed to connect`\n\nforever. A plugin's command files can still be on disk while it's gone from `enabledPlugins`\n\nin 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/`\n\nsix months later, and you keep paying the context tax of Claude loading it every single time.\n\nThis 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`\n\n, connection-attempt timeouts drag out your startup.\n\nI 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`\n\nlines in the output of `claude mcp list`\n\n. Five of them I had no memory of ever installing — they'd been added automatically via plugins.\n\nThe 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.\n\nSo 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`\n\nwent to 3 as of the report from three Sundays ago.\"\n\nThere'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.\n\nThe whole thing is just two files: the diagnostic script, and a launchd job config that fires it weekly.\n\n```\n[毎週日曜 09:00]\n        │\n        ▼\nlaunchd が com.shun.env-audit を起動\n        │\n        ▼\n~/.claude/scripts/env-audit.sh を実行\n        │\n        ├─ jq で settings.json をパース\n        │    └─ enabledPlugins の数を取得\n        │\n        ├─ find で plugin ディレクトリを走査\n        │    └─ commands / SKILL.md / agents の実ファイル数\n        │\n        ├─ claude mcp list（timeout 25 秒）\n        │    └─ Connected / Needs auth / Failed を集計\n        │\n        ├─ jq で hooks の構成を出力\n        │\n        ├─ ls ~/.claude/skills/auto/ で auto-skill 一覧\n        │\n        └─ ccusage blocks --active で直近コストを取得\n                │\n                ▼\n    ~/.claude/logs/env-audit-YYYYMMDD.md に書き出し\n                │\n                ▼\n    diff で前週比較 → 「いつ壊れたか」を遡れる\n```\n\n`~/.claude/scripts/env-audit.sh`\n\nis 70 lines. The whole thing is a single `{}`\n\nblock that generates Markdown, redirected to a file.\n\n``` bash\n#!/usr/bin/env bash\nset -uo pipefail\n\nOUT=\"${1:-/tmp/claude-env-audit.md}\"\nSETTINGS=\"$HOME/.claude/settings.json\"\n\n{\n  # ... Markdown を echo で生成 ...\n} > \"$OUT\"\n```\n\nWith no argument it writes to `/tmp/claude-env-audit.md`\n\n. When launchd calls it, it passes a date-stamped path as the argument (more on that below).\n\n**The Plugin Inventory section** counts both the config file and the actual files on disk.\n\n```\nTOTAL=$(jq -r '.enabledPlugins // {} | length' \"$SETTINGS\")\necho \"- Enabled plugins: **$TOTAL**\"\necho \"- Plugin commands on disk: **$(find $HOME/.claude/plugins -path '*/commands/*.md' 2>/dev/null | wc -l | tr -d ' ')**\"\necho \"- Plugin skills on disk: **$(find $HOME/.claude/plugins -name 'SKILL.md' 2>/dev/null | wc -l | tr -d ' ')**\"\necho \"- Plugin agents on disk: **$(find $HOME/.claude/plugins -path '*/agents/*.md' 2>/dev/null | wc -l | tr -d ' ')**\"\n```\n\nThe gap between the `enabledPlugins`\n\ncount 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`\n\nbut has no files on disk, that plugin isn't working.\n\n**The MCP Server Status section** is the heart of it.\n\n```\nMCP_OUT=$(timeout 25 claude mcp list 2>&1)\nTOTAL_MCP=$(printf '%s' \"$MCP_OUT\" | grep -cE \"://|^plugin:|^claude\\.ai\")\nOK=$(printf '%s' \"$MCP_OUT\" | grep -c \"Connected\")\nAUTH=$(printf '%s' \"$MCP_OUT\" | grep -c \"Needs auth\")\nFAIL=$(printf '%s' \"$MCP_OUT\" | grep -c \"Failed to connect\")\n```\n\n`timeout 25`\n\nmatters. When an MCP server is unresponsive, `claude mcp list`\n\nitself can hang. Putting a 25-second timeout on it means a broken server won't stall the entire script.\n\nThe results are output both as a one-line summary and as detailed lists of `Failed`\n\n/ `Needs auth`\n\n.\n\n```\necho \"- Total: $TOTAL_MCP / Connected: **$OK** / Need auth: **$AUTH** / Failed: **$FAIL**\"\n```\n\nNext, **the Hooks section** uses `jq`\n\nto list event types and registration counts.\n\n```\njq '.hooks | to_entries | map({event: .key, count: (.value | length)})' \"$SETTINGS\" 2>/dev/null\n```\n\nHooks 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.\n\n**The Auto-skills section** simply prints a list.\n\n```\nls \"$HOME/.claude/skills/auto/\" 2>/dev/null | grep -v README\n```\n\nIn my environment there are currently more than 20 SKILL.md files in `~/.claude/skills/auto/`\n\n. 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.\"\n\n**The Cost section** pulls the cost of the most recent active block.\n\n```\nccusage blocks --active 2>&1 | grep -E \"Block|Time|Tokens:|Cost:|/h\" | sed 's/^/  /'\n```\n\nIn 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.\"\n\n**The Recommendations section** is threshold-based automatic judgment.\n\n```\n[ \"$FAIL\" -gt 0 ] && echo \"- ⚠️  $FAIL MCP servers failed. Review/disable to reduce startup time.\"\n[ \"$AUTH\" -gt 5 ] && echo \"- ⚠️  $AUTH MCP servers unauthenticated. Either auth or disable to reduce noise.\"\n[ \"$TOTAL\" -gt 50 ] && echo \"- ⚠️  $TOTAL plugins enabled - likely heavy context tax. Consider pruning unused.\"\n```\n\nRationale for the thresholds: `FAIL > 0`\n\nis zero tolerance (even one failure needs handling). `AUTH > 5`\n\ncomes from experience — \"up to 5 is an acceptable range where per-project auth prompts can legitimately be pending.\" `plugins > 50`\n\nI set from the experience that \"past 50, the amount of context injected at startup gets perceptibly heavy.\"\n\n`~/Library/LaunchAgents/com.shun.env-audit.plist`\n\nis the weekly trigger.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n    <key>Hour</key>\n    <integer>9</integer>\n    <key>Minute</key>\n    <integer>0</integer>\n    <key>Weekday</key>\n    <integer>0</integer>\n</dict>\n```\n\n`Weekday`\n\n`0`\n\nis 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.\n\nThe execution command dynamically generates a date-stamped filename like this.\n\n```\n<string>/bin/zsh -c ~/.claude/scripts/env-audit.sh\n  ~/.claude/logs/env-audit-$(date +\\%Y\\%m\\%d).md &gt; /dev/null 2&gt;&amp;1</string>\n```\n\nIn a plist you have to escape `%`\n\nand write it as `\\%Y\\%m\\%d`\n\n. Forget this and launchd can't interpret the `date`\n\ncommand correctly, so the filename becomes a fixed literal string (I stepped on this exactly once).\n\nProcess priority settings need attention too.\n\n```\n<key>LowPriorityIO</key>\n<true/>\n<key>Nice</key>\n<integer>10</integer>\n<key>ProcessType</key>\n<string>Background</string>\n```\n\n`Nice 10`\n\nlowers scheduling priority, and `LowPriorityIO`\n\ndeprioritizes I/O as well. Because `claude mcp list`\n\nperforms 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.\n\nBoth stdout and stderr are consolidated into `~/.claude/logs/com.shun.env-audit.log`\n\n. The script's own output (`Audit written to: ...`\n\nplus a preview of the first 80 lines) lands there, so verifying \"did launchd actually run\" is just a `tail`\n\non that file.\n\nSince reports accumulate as `env-audit-20260727.md`\n\n, `env-audit-20260803.md`\n\n, and so on, the diff against last week is one command away.\n\n```\ndiff ~/.claude/logs/env-audit-20260727.md ~/.claude/logs/env-audit-20260803.md\n```\n\nTypical change patterns:\n\n`Connected: **8**`\n\n→ `Connected: **5**`\n\n/ `Failed: **3**`\n\nhappened within one week, something broke that week`Enabled plugins: **34**`\n\n→ `Enabled plugins: **41**`\n\n, you know you enabled 7 plugins that weekWhere this diff workflow really earned its keep was when I noticed \"MCP `Failed`\n\nhas 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.\"\n\n`{ } > \"$OUT\"`\n\nThe whole script has this structure.\n\n```\n{\n  # すべての echo がここに入る\n} > \"$OUT\"\n```\n\nThe first version I wrote was the naive implementation of repeating `echo \"...\" >> \"$OUT\"`\n\non every line. Problems surfaced immediately. When the script exits with an error partway through, append mode `>>`\n\nleaves 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.\n\nSwitching to the `{ } > \"$OUT\"`\n\nblock 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\"`\n\nto 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.\n\n`timeout 25`\n\n**This is the core of the MCP section.**\n\n```\nMCP_OUT=$(timeout 25 claude mcp list 2>&1)\n```\n\n`claude mcp list`\n\nattempts 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.\n\nWithout `timeout`\n\n, `claude mcp list`\n\ngets 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.\n\n`2>&1`\n\nand where not to\n`2>&1`\n\nshows up multiple times in the script. The criterion for which to use is \"does that information belong in the report?\"\n\n```\nMCP_OUT=$(timeout 25 claude mcp list 2>&1)          # stderr を拾う\nfind $HOME/.claude/plugins ... 2>/dev/null           # stderr を捨てる\nccusage blocks --active 2>&1 | grep -E \"...\"         # stderr を stdout に合流させてフィルタ\n```\n\n`claude mcp list`\n\nwrites connection-failure messages to stderr. Without `2>&1`\n\n, `MCP_OUT`\n\ncomes 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.\n\n`find`\n\n's `2>/dev/null`\n\nis the opposite — noise removal to keep \"No such file or directory\" out of the report when the plugin directory doesn't exist.\n\n`grep -c`\n\nand the `:-0`\n\ndefault assignment\n\n```\nOK=$(printf '%s' \"$MCP_OUT\" | grep -c \"Connected\")\nAUTH=$(printf '%s' \"$MCP_OUT\" | grep -c \"Needs auth\")\nFAIL=$(printf '%s' \"$MCP_OUT\" | grep -c \"Failed to connect\")\nTOTAL_MCP=${TOTAL_MCP:-0}; OK=${OK:-0}; AUTH=${AUTH:-0}; FAIL=${FAIL:-0}\n```\n\n`grep -c`\n\nprints the string \"0\" to stdout when there are zero matches, but returns exit code 1. Because `set -o pipefail`\n\nis in effect, that pipeline's exit status becomes 1. This is exactly why the script doesn't have `set -e`\n\n(errexit). Add `-e`\n\nand you get the paradox that in a \"healthy week\" where all MCP servers are Connected, Fail is 0 → `grep -c`\n\nexits 1 → the script terminates.\n\nThe `:-0`\n\ndefault assignment is a separate safeguard. If `timeout`\n\nfires and the command is force-terminated, the command-substitution variable can end up as an empty string. Evaluating `[ \"$FAIL\" -gt 0 ]`\n\non an empty string causes an arithmetic error, so it falls back to 0.\n\n```\necho \"$MCP_OUT\" | grep -E \"Failed to connect\" | grep \"^plugin:\" \\\n  | awk -F: '{print $2}' | sort -u | head -20 | sed 's/^/- /'\n```\n\nThe output of `claude mcp list`\n\nmixes lines in the format `plugin:プラグイン名:スキル名`\n\nwith lines registered directly by URL, depending on how the MCP was registered. Narrowing to only plugin-sourced MCPs with `grep \"^plugin:\"`\n\nmatches the unit of the operation you'd actually perform: \"if you're deleting it, you disable the whole plugin.\" `awk -F: '{print $2}'`\n\nextracts only the second colon-separated field (the plugin name), `sort -u`\n\nremoves duplicates when the same plugin owns multiple MCPs, and `head -20`\n\nlimits the output. It's designed so the output doesn't overflow even in a catastrophic state.\n\n`EnvironmentVariables`\n\nThere's an explicit PATH at the top of the plist.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n    <key>PATH</key>\n    <string>/path/to/nvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:...</string>\n</dict>\n```\n\nProcesses launched by launchd read neither `~/.zshrc`\n\nnor `~/.zprofile`\n\n. The script runs with only a minimal PATH equivalent to `/etc/paths`\n\n(roughly `/usr/bin:/bin:/usr/sbin:/sbin`\n\n). The `claude`\n\ncommand is a Node.js binary under nvm's management, and `jq`\n\nis 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`\n\nand the variables end up empty).\n\n`claude`\n\nWhen I first created the plist, I forgot to include `EnvironmentVariables`\n\n.\n\n**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.\n\n**Investigation**: `tail ~/.claude/logs/com.shun.env-audit.log`\n\nshowed the following lined up.\n\n```\nzsh: command not found: claude\nzsh: command not found: jq\nzsh: command not found: ccusage\nAudit written to: /path/to/env-audit-20260720.md\n```\n\nThe script runs to completion without errors. The `-u`\n\n(undefined variable error) from `set -uo pipefail`\n\ndidn't trip either, and because `command not found`\n\nwas either captured into a variable via `2>&1`\n\nor discarded to `/dev/null`\n\n, 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.\n\n**Fix**: Running `which claude`\n\nalso gave `not found`\n\n. I identified the Node.js path with `nvm which current`\n\nand added that `bin/`\n\ndirectory to the plist's `EnvironmentVariables`\n\n> `PATH`\n\n. Likewise added `/opt/homebrew/bin`\n\nfor `jq`\n\n. Reloaded with `launchctl unload ~/Library/LaunchAgents/com.shun.env-audit.plist && launchctl load ~/Library/LaunchAgents/com.shun.env-audit.plist`\n\n, then ran the script manually and confirmed every section filled in before calling it done.\n\nThis debugging burned 2 hours. \"Cron-style jobs get an explicit PATH\" is now reflex.\n\n**Symptom**: `~/.claude/logs/`\n\nshould have had date-stamped files like `env-audit-20260720.md`\n\n, but instead there was a single file with the literal name `env-audit-%Y%m%d.md`\n\n. Since it gets overwritten every week, no diff is possible.\n\n**Cause**: Inside a plist `<string>`\n\n, the `%`\n\nsymbol is treated specially. Write `date +%Y%m%d`\n\nand launchd tries to interpret `%Y`\n\nand `%m`\n\nas format specifiers, and it doesn't expand properly. The correct form escapes them as `\\%Y\\%m\\%d`\n\n.\n\n``` php\n<!-- 誤り: リテラル文字列になる -->\n<string>~/.claude/scripts/env-audit.sh ~/.claude/logs/env-audit-$(date +%Y%m%d).md</string>\n\n<!-- 正しい: \\% でエスケープ -->\n<string>~/.claude/scripts/env-audit.sh ~/.claude/logs/env-audit-$(date +\\%Y\\%m\\%d).md</string>\n```\n\nWhile I'm at it: `~`\n\ndoes not expand to your home directory inside a plist. You need to write absolute paths. Not knowing that, I wrote `~/.claude/logs/...`\n\nand it errored trying to create a `~`\n\ndirectory (which doesn't exist). Write absolute paths, keep spaces out of paths, escape `%`\n\n— memorize those three as plist rules and you won't get stuck.\n\n**Fix**: Corrected the escaping, changed paths to absolute, and reloaded. Confirmed with `ls -lt ~/.claude/logs/env-audit-*.md`\n\nthat new date-stamped files get created.\n\n`claude mcp list`\n\nhung for over 10 minutes\nThe first version of the script had no `timeout`\n\n.\n\n**Symptom**: Starting one particular week, the launchd log stops partway. Everything from the Hooks section onward, which should appear in the `head -80`\n\npreview, isn't written. The file ends in the middle of the MCP section.\n\nRunning `ps aux | grep claude`\n\nshowed `claude mcp list`\n\nstill executing with a PID. Checking the time, 12 minutes had elapsed.\n\n**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`\n\nkept 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.\n\n**Fix**: Changed to `timeout 25 claude mcp list 2>&1`\n\n. 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`\n\nfinished in 2.3 seconds — I didn't even want to know how long it used to take.\n\nReflecting 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`\n\nmanually, you don't notice when things are broken. As motivation for automating environment diagnostics, this hang experience was the most effective one.\n\n`-u`\n\nin `set -uo pipefail`\n\nbit me somewhere else\nA very early version of the script didn't have the `:-0`\n\ndefault assignments on line 28.\n\n```\n# 古いバージョン（デフォルト代入なし）\nOK=$(printf '%s' \"$MCP_OUT\" | grep -c \"Connected\")\nFAIL=$(printf '%s' \"$MCP_OUT\" | grep -c \"Failed to connect\")\n\necho \"- Connected: **$OK** / Failed: **$FAIL**\"\n```\n\n**Symptom**: In weeks where `timeout 25 claude mcp list`\n\nterminated on timeout (i.e., weeks where MCP was hanging), nothing from the MCP section onward appears in the report. No output in the log.\n\n**Cause**: When `timeout`\n\nfires it returns exit code 124. If the timeout happens inside a command substitution `$()`\n\n, the assignment to the outer variable still executes, but the contents of `MCP_OUT`\n\ncan end up as an empty string (because the output captured via `2>&1`\n\ngets cut off partway). Feeding that empty input to `grep -c`\n\nsets `FAIL=\"0\"`\n\nas a zero-match count, but `TOTAL_MCP`\n\nwas in some cases referenced while still undefined. `set -u`\n\ntreats that as an \"unbound variable\" and the script terminates.\n\n**Fix**: Added the default-assignment block `TOTAL_MCP=${TOTAL_MCP:-0}; OK=${OK:-0}; AUTH=${AUTH:-0}; FAIL=${FAIL:-0}`\n\n. Now even on timeout the variables converge to 0 and the report gets generated all the way to the end. `:-0`\n\nisn't merely setting an initial value — it's insurance for the failure path.\n\nLining 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.\n\nOperations 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.\n\n`launchctl load`\n\nalone doesn't apply plist changes\n\nAfter editing a plist, the first thing most people do is \"run `launchctl load ~/Library/LaunchAgents/com.shun.env-audit.plist`\n\nagain.\" But if the job is already loaded, you get `Load failed: 5: Input/output error`\n\nback, or it fails silently. Either way the changes aren't applied. The correct procedure is the `unload`\n\n-then-`load`\n\nset.\n\n```\nlaunchctl unload ~/Library/LaunchAgents/com.shun.env-audit.plist\nlaunchctl load  ~/Library/LaunchAgents/com.shun.env-audit.plist\n```\n\nI'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`\n\nas a pair.\n\n**I thought I had to wait a week after registering to test it**\n\nI 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`\n\n.\n\n```\nlaunchctl start com.shun.env-audit\n```\n\n`tail`\n\n`~/.claude/logs/com.shun.env-audit.log`\n\nright 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.\"\n\n**Fixing the script but forgetting to reload, so an old version keeps running**\n\nIf you edited `~/.claude/scripts/env-audit.sh`\n\ndirectly 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.\n\nOn the other hand, if you changed plist settings (PATH, schedule, arguments, etc.), `unload && load`\n\nis 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.\n\n**Nobody reads the generated report**\n\nThe most classic automation trap is the state of \"it's running but nobody reads it.\" Even if `env-audit-20260727.md`\n\ngets added to `~/.claude/logs/`\n\nevery week, it's meaningless without a path that leads you to check it.\n\nThe 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`\n\nlined up across five weeks' worth — that actually happened.\n\nThe 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.\n\n**Log files grow without limit**\n\nSince `env-audit-YYYYMMDD.md`\n\ngets 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`\n\n, the old files blur your diff baseline.\n\nRunning a \"keep only the last 3 months\" cleanup monthly makes it easier to manage.\n\n```\nfind ~/.claude/logs -name \"env-audit-*.md\" -mtime +90 -delete\n```\n\nI append this one line after the `} > \"$OUT\"`\n\nblock 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.\n\n**The diff is too big to read**\n\nComparing consecutive reports with plain `diff`\n\ngets noisy because every line of the Cost section changes on every run. To compare section by section, narrowing with `grep`\n\nis practical.\n\n```\n# MCPの状態だけ週次比較\ngrep \"Total:\\|Connected\\|Failed\\|Need auth\" ~/.claude/logs/env-audit-20260727.md\ngrep \"Total:\\|Connected\\|Failed\\|Need auth\" ~/.claude/logs/env-audit-20260803.md\n```\n\nMaking the weekly check a procedure that takes under 30 seconds prevents the feeling that \"checking is a hassle.\"\n\n**If ccusage isn't installed, it silently stays empty**\n\nThe Cost section (Section 5) depends on `ccusage blocks --active`\n\n. In an environment where `ccusage`\n\nisn't installed, the whole section comes out empty. The script picks up stderr with `2>&1`\n\n, but the subsequent `grep -E \"Block|Time|Tokens:|Cost:|/h\"`\n\nfilter skips the `zsh: command not found: ccusage`\n\nline. In other words, \"a report with an empty Cost section\" gets silently generated every week.\n\nWhen porting to a new environment, checking in advance with `which ccusage`\n\nis the reliable move. Under nvm management, after installing with `npm install -g ccusage`\n\n, re-confirm that the plist's PATH includes `~/.nvm/versions/node/<バージョン>/bin`\n\n.\n\n**Leaving MCP Failed as \"I'll fix it later\"**\n\nIf `Failed: 2`\n\nshows up in the report and you leave it as \"I'll fix it later,\" `Failed: 2`\n\nshows up again the next week. \"Later\" never comes. Deleting an HTTP-type MCP is one command, `claude mcp remove <name>`\n\n, and takes 3 seconds. The reason the script's threshold is `FAIL > 0`\n\n(zero tolerance) is that tolerating even one slides into an operating norm of \"up to 2 is OK.\" When `⚠️ 1 MCP servers failed.`\n\nappears in the Recommendations section, I handle it the same day I see it.\n\n**Not knowing how to check job status with launchctl list**\n\n```\nlaunchctl list com.shun.env-audit\n```\n\nThis command returns the PID (a number if running, `-`\n\nif stopped) and the last exit code (`LastExitStatus`\n\n). If `LastExitStatus`\n\nis non-zero, the script terminated abnormally. Before you `tail`\n\nthe 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.\n\n**The plist PATH goes stale after an nvm version update**\n\nWhen you bump the Node.js version with nvm, the binary paths change. For example, if you go from `v24.13.0`\n\nto `v24.15.0`\n\n, the `~/.nvm/versions/node/v24.13.0/bin`\n\ndirectory written in the plist no longer exists. From that week on, `claude mcp list`\n\ndoesn'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`\n\n, updating the plist's PATH, and reloading.\n\n**1. Manually trigger right after registering and verify every section**\n\nRun `launchctl start com.shun.env-audit`\n\nimmediately after `launchctl load`\n\n. `tail`\n\n`~/.claude/logs/com.shun.env-audit.log`\n\nand 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.\n\n**2. Verify the plist PATH with which before writing it**\n\n```\nwhich claude    # ~/.nvm/versions/node/v24.13.0/bin/claude\nwhich jq        # /opt/homebrew/bin/jq\nwhich ccusage   # ~/.nvm/versions/node/v24.13.0/bin/ccusage\n```\n\nExtract the `bin/`\n\nportion from those 3 commands and list them in the plist's `PATH`\n\n. When you bump versions with nvm, the same check commands immediately show you the difference.\n\n**3. unload && load as a set when changing the plist**\n\nWhenever 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`\n\n. 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.\"\n\n**4. Namespace the plist Label with your own handle**\n\nChange the `shun`\n\npart of `com.shun.env-audit`\n\nto your own handle. When your launchd jobs multiply, `launchctl list | grep com.自分のハンドル`\n\nlets you filter to just your own. You can tell them apart even when mixed in with jobs auto-generated by other tools.\n\n**5. Use \"number of MCPs × 5 seconds\" as the guideline for timeout**\n\n`timeout 25`\n\nis 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`\n\n, 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.\n\n**6. FAIL > 0 is zero tolerance — handle it the day you see it**\n\nIf even one `Failed`\n\nappears, handle it the same day you see it with `claude mcp remove <name>`\n\nor `claude mcp auth <name>`\n\n. Deleting an HTTP-type MCP takes 3 seconds; re-authenticating a stdio-type one is a single command. \"Later\" turns into \"never.\"\n\n**7. Decide clear criteria for deleting auto-skills**\n\nWhen you look at the `ls ~/.claude/skills/auto/`\n\nlisting, 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/`\n\nsettled into a manageable range.\n\n**8. grep just the specific section before reading the diff**\n\nA full-text diff takes time to read. If you only want to know \"did MCP break this week,\"\n\n```\ngrep \"Total:\\|Connected\\|Failed\" ~/.claude/logs/env-audit-20260727.md\ngrep \"Total:\\|Connected\\|Failed\" ~/.claude/logs/env-audit-20260803.md\n```\n\nnarrows it to that section. Making it a procedure you can check in under 30 seconds prevents \"checking is a hassle.\"\n\n**9. Build a separate mechanism for reading the reports**\n\nIf 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.\n\n**10. Version-control the script itself**\n\nPut all of `~/.claude/scripts/`\n\ninto 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.\n\n**11. Check with claude mcp list immediately after adding a plugin**\n\nWhen you add a new plugin, type `claude mcp list`\n\nright then and confirm no `Failed`\n\nappeared. 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.\n\n**12. Check job status in one second with launchctl list com.shun.env-audit**\n\n```\nlaunchctl list com.shun.env-audit\n```\n\nReturns the PID (a number if running, `-`\n\nif stopped) and `LastExitStatus`\n\n(non-zero means abnormal termination). Check \"did it run\" with this first, before reading the log.\n\n**13. Sweep out zombie files once every 3 months**\n\nPlugin files that remain on disk after being removed from `enabledPlugins`\n\nstay in `find`\n\n'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/`\n\ndirectly and clean up directories whose corresponding plugin is disabled with `rm -rf`\n\n.\n\n**14. Tune the plugin count threshold to your own perception**\n\nThe script's `TOTAL > 50`\n\nthreshold 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.\n\n**15. Append find ~/.claude/logs -name \"env-audit-*.md\" -mtime +90 -delete at the end of the script**\n\nBuilding 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\"`\n\n. Report generation and cleanup fit in the same script, and you don't add another job to manage.\n\nThe 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.\"\n\nWhat `env-audit.sh`\n\nand 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.\n\nWhat 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.\n\nThe 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.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that", "canonical_source": "https://dev.to/bokuwalily/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-audit-that-catches-5hme", "published_at": "2026-08-17 11:00:08+00:00", "updated_at": "2026-08-17 11:14:08.146694+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "mlops"], "entities": ["Claude Code", "launchd", "MCP"], "alternates": {"html": "https://wpnews.pro/news/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that", "markdown": "https://wpnews.pro/news/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that.md", "text": "https://wpnews.pro/news/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that.txt", "jsonld": "https://wpnews.pro/news/14-pitfalls-of-letting-a-claude-code-environment-rot-and-the-70-line-weekly-that.jsonld"}}