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
-
Adding
set -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 with
sort -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 โ put
mkdir -p "$(dirname "$OUT")"right before themv -
If the comma-joined node IDs are left with spaces, Mermaid breaks โ clean them up with
sed '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*
Top comments (0)