DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Auto-Generating a Mermaid Map of My Claude Code Setup Every Morning

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 ' ')"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;'
Enter fullscreen mode Exit fullscreen mode

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/ *$//'; }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

If the last line shows generated (N lines), you're good.

[2026-06-20 04:50:03] environment-map.md generated (218 lines)
Enter fullscreen mode Exit fullscreen mode

Pitfalls I hit

  • Adding set -e exits at the MCP timeout โ†’ set -uo pipefail only. 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 the san() 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 the mv
  • If the comma-joined node IDs are left with spaces, Mermaid breaks โ†’ clean them up with sed 's/ /,/g' before passing them to class

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)