This is a follow-up to my previous post on getting Claude Code to improve itself unattended. This time the topic is a different problem: a mechanism to stay aware of your environment as it balloons.
Skills piled up under ~/.claude/, launchd jobs multiplied, and once my project count crossed into the double digits, I could no longer answer off the top of my head "how many layers is the whole thing, and what's running right now?" Rather than typing ls every morning, I wanted a state where opening Obsidian gives me the bird's-eye view. So I wrote ~/.claude/scripts/env-map.sh.
The problem: as the environment grows, you lose the big picture
Here's the current snapshot.
- Plugin bundle skills: 1,004
- Self-generated skills (auto/): 77
- launchd jobs (
com.shun.*.plist): 24
- Projects I'm tracking: 11
The last-modified times and git state of skills, jobs, and projects are all over the place, and the cost of going to check each one adds up every time. I wanted one place that could answer "what did today's autopilot do?" and "what branch is that project on?" on a single page.
Design: three-layer Mermaid + output to Obsidian
The diagram covers three layers.
| Layer | What's collected |
|---|---|
| 💻 PC environment | macOS version, chip, RAM, free disk, key CLIs |
| 🤖 Claude environment | skill count, agent count, plugin count, hook event count, MCP connection count, launchd job count |
| 📦 Project environment | branch, last commit date, and uncommitted change count for known repos |
The output destination is the Obsidian Vault (~/Documents/claude-obsidian/wiki/meta/environment-map.md). vault-auto-ingest (4:55) picks it up and auto-commits, so version control comes along for free.
The script's design intent is written in the comments.
# 何が起きても生成を完走させる(個別の収集失敗は "?" で degrade)。set -e は使わない。
set -uo pipefail
The key is not using set -e. Even if something partially fails, like an MCP connection check, it fills in ? and keeps producing output all the way to the end.
Working around launchd's minimal PATH
When launched from launchd, PATH is roughly only /usr/bin:/bin:/usr/sbin:/sbin. Neither node, nor claude, nor jq is found. So at the top of the script I explicitly assemble PATH.
NVM_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:${NVM_BIN:+$NVM_BIN:}/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
Because nvm's path changes when you bump the node version, sort -V | tail -1 resolves the latest version's bin dynamically. Hardcoding a fixed path breaks on the next version bump.
:::message
Getting stuck on launchd's minimal PATH affects "any CLI the user installed." Stacking fallbacks in the order ~/.local/bin (uv, claude, etc.) → Homebrew → nvm hits the mark across any environment configuration.
:::
What to put in the diagram so the overview actually helps
Collecting the Claude environment
AUTO_SKILLS="$(ls "$HOME/.claude/skills/auto/" 2>/dev/null | grep -vc README)"
AGENTS="$(find "$HOME/.claude/plugins" "$HOME/.claude/agents" -path '*/agents/*.md' \
-o -path "$HOME/.claude/agents/*.md" 2>/dev/null | wc -l | tr -d ' ')"
PLUGINS="$(jq -r '.enabledPlugins // {} | length' "$SETTINGS" 2>/dev/null || echo '?')"
HOOK_EVENTS="$(jq -r '.hooks // {} | keys | length' "$SETTINGS" 2>/dev/null || echo '?')"
LAUNCHD="$(ls "$HOME/Library/LaunchAgents/"com.shun.*.plist 2>/dev/null | wc -l | tr -d ' ')"
MCP is the one thing that needs care. claude mcp list attempts a live connection, so it's slow to start and can fail. I contain it with a timeout.
MCP_OK="?"
if have claude; then
_mcp="$(timeout 12 claude mcp list 2>/dev/null)"
[ -n "$_mcp" ] && MCP_OK="$(printf '%s' "$_mcp" | grep -c 'Connected')"
fi
With timeout 12, it gives up after 12 seconds and leaves ? in place. It doesn't halt generation.
Project state
For each repo, it fetches the branch, last commit date, and uncommitted change count, and reflects them in the node color.
proj_meta() {
local path="$1"
PROJ_EXISTS=0; PROJ_BRANCH="-"; PROJ_LAST="-"; PROJ_DIRTY=0
[ -d "$path" ] || return
PROJ_EXISTS=1
if git -C "$path" rev-parse --git-dir >/dev/null 2>&1; then
PROJ_BRANCH="$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-')"
PROJ_LAST="$(git -C "$path" log -1 --format=%cd --date=format:%Y-%m-%d 2>/dev/null || echo '-')"
PROJ_DIRTY="$(git -C "$path" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
fi
}
Projects with uncommitted changes show up in orange, and projects that don't exist on disk show up in red.
echo ' classDef dirty fill:#3a2a00,stroke:#e8a33d,color:#fff;'
echo ' classDef gone fill:#3a1a1a,stroke:#e06666,color:#fff;'
Just by opening Obsidian in the morning, the color tells me "which project has commits I forgot to make."
Sanitizing Mermaid labels
If a host name or chip name contains () or [], Mermaid's parsing breaks. I run every label through a sanitize function.
san() { printf '%s' "$1" | tr '"[]()#|<>' ' ' | tr '\n' ' ' | sed 's/ */ /g; s/ *$//'; }
Even a string like Apple M4 Pro (14-core) is output safely.
What the generated diagram looks like
graph LR
PC["💻 PC 環境<br/>MacBook · macOS 15.x"]
CC["🤖 Claude 環境<br/>plugins 14 · launchd 24"]
PJ["📦 プロジェクト環境<br/>11 repos"]
PC --> CC
CC -->|builds / runs| PJ
PC -.->|ローカル開発| PJ
The breakdown of the Claude environment looks like this (measured values).
graph TD
CC["🤖 Claude Code"]
SK["🧩 Skills"]
SK --> SKa["auto: 77"]
SK --> SKp["plugin: 1004"]
CC --> SK
CC --> AG["🎭 Agents"]
CC --> PL["🔌 Plugins enabled"]
CC --> HK["🪝 Hooks"]
CC --> MCP["🔗 MCP connected"]
CC --> AUTO["⚡ launchd 24 jobs"]
launchd configuration
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>50</integer></dict>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>10</integer></dict>
</array>
<key>RunAtLoad</key><false/>
It fires at 4:50 in order to place the output before the 4:55 vault-auto-ingest. Since ingest commits it straight to Obsidian, no git operations are needed. The 8:10 run is a catch-up for cases where 4:50 was skipped due to overnight sleep. It's idempotent, so firing multiple times is harmless. RunAtLoad: false suppresses the immediate fire on plist load.
Logs are consolidated into a single file.
<key>StandardOutPath</key>
<string>~/.claude/logs/env-map.launchd.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/env-map.launchd.log</string>
If the last line shows generated (N lines), you're good.
[2026-06-20 04:50:03] environment-map.md generated (218 lines)
Pitfalls I hit
Addingset -eexits at the MCP timeout →set -uo pipefailonly. Continue past MCP or git failures with?or-
Writing a fixed NVM path broke it on a Node update → dynamically resolve the latest version's bin withsort -V | tail -1
A()in a Mermaid label causes a parse error in the diagram → run thesan()function on every label. I got bitten by a chip name
Falling behind vault-ingest makes the artifact the previous day's → dual setup: fire early at 4:50, catch up at 8:10
Not noticing a project path change → visualize with a(not found)red node. Update the list when PROJECTS changes
It fails if the output directory doesn't exist → putmkdir -p "$(dirname "$OUT")"right before themv
If the comma-joined node IDs are left with spaces, Mermaid breaks → clean them up withsed 's/ /,/g'before passing them toclass
Wrap-up
- Once skills, launchd, and projects cross into the double digits, prepare a single place to survey them with a Mermaid diagram
Assemble PATH explicitly at the top of the script for launchd's minimal PATH at startup. Resolve nvm dynamically, assuming version bumps- Wrap slow operations like MCP connection checks in
timeout, and degrade failures to?to prioritize completing the run - Placing the output before vault-ingest gets you version control and commits automatically
Visualizing projects' uncommitted changes by node color means just opening Obsidian in the morning tells you the action to take
Combining this with liveness monitoring (automation-health-check) gives you a morning snapshot on a single page of "what's running, what's broken, and what's left undone."
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*