This is a continuation of my "Claude Code environment" series. In the previous post, Automatically thinning conversation logs to prevent bloat, I introduced the basic pattern for scheduled launchd jobs. This time I'm using that same mechanism to automatically maintain a list of the custom agents in ~/.claude/agents/.
Dropping a single .md file into ~/.claude/agents/ adds a custom agent, but before long you lose track of how many you have, what model each one uses, and which tools each is allowed to touch. That's exactly what happened to me with the 27 agents I now have. I tried writing an INDEX.md by hand to manage them, and of course within a few days it had drifted from reality.
The problem: the index rots
Manually updating INDEX.md every time you add a custom agent is not sustainable.
- You forget you added one and leave it out
- You change a model later and never reflect it in INDEX.md
- You typo a
nameordescriptionand never notice
I concluded there was no sustainable way to manage this other than "generate it automatically," so I wrote agents-index.sh.
The output: a real INDEX.md
Here's how the top of my current ~/.claude/agents/INDEX.md looks.
<!-- AUTO-GENERATED by ~/.claude/scripts/agents-index.sh — DO NOT EDIT MANUALLY -->
# Agents Index (27 agents · 2026-07-28 02:02)
| Name | Model | Description | Tools |
|------|-------|-------------|-------|
| `architect` ([architect.md](./architect.md)) | opus | Software architecture specialist ... | ["Read", "Grep", "Glob"] |
| `build-error-resolver` ([build-error-resolver.md](./build-error-resolver.md)) | sonnet | Build and TypeScript error resolution specialist ... | ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] |
| `doc-updater` ([doc-updater.md](./doc-updater.md)) | haiku | Documentation and codemap specialist ... | ["Read", "Edit", "Bash", "Grep", "Glob"] |
Four columns: Name, Model, Description, and Tools. You can see at a glance how the models break down across opus / sonnet / haiku, and immediately check things like "did this agent have Bash allowed?"
Running it with the --json option also writes the same content to .index.json, so other scripts like a cost-tracker can reuse it.
How the script is designed
~/.claude/scripts/agents-index.sh is a Bash outer shell with an inline Python3 script embedded in it.
#!/usr/bin/env bash
set -uo pipefail
AGENTS_DIR="$HOME/.claude/agents"
INDEX_MD="$AGENTS_DIR/INDEX.md"
INDEX_JSON="$AGENTS_DIR/.index.json"
LOGFILE="$HOME/.claude/logs/agents-index.log"
mkdir -p "$HOME/.claude/logs"
EMIT_JSON=0
[ "${1:-}" = "--json" ] && EMIT_JSON=1
python3 - "$AGENTS_DIR" "$INDEX_MD" "$INDEX_JSON" "$EMIT_JSON" "$LOGFILE" <<'PY'
# ... (Python本体)
PY
The reason for embedding Python as a heredoc is to hand string processing off to Python while avoiding shell PATH problems. Parsing YAML in pure Bash is dangerous, and assuming pip install pyyaml would only add more environment dependencies.
A frontmatter parser with no PyYAML dependency
Agent definitions only ever use single-level key-value pairs, so a regex plus line splitting is enough.
def load_frontmatter(path):
text = path.read_text(encoding="utf-8", errors="replace")
m = re.match(r"^---\n(.*?)\n---\n", text, flags=re.DOTALL)
if not m:
return None, "missing frontmatter"
body = m.group(1)
out = {}
for line in body.split("\n"):
if not line.strip() or line.startswith("#"):
continue
if ":" not in line:
continue
k, _, v = line.partition(":")
k = k.strip()
v = v.strip()
# quoted string 剥がす
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
v = v[1:-1]
out[k] = v
return out, None
re.DOTALL grabs everything between the --- markers wholesale, then partitioning on : makes everything left of the first : the key. JSON array values like tools are kept as plain strings and truncated at 60 characters for display.
Warnings for missing fields
An agent with an empty name or description is useless even if it shows up in the list. After parsing, the script checks the required fields and appends a warnings section at the end of INDEX.md.
if not name:
warnings.append(f"{p.name}: missing 'name'")
if not desc:
warnings.append(f"{p.name}: missing 'description'")
The end of the generated output looks like this.
## ⚠️ Validation warnings
- foo-agent.md: missing 'description'
Note
Files that are missing frontmatter entirely get routed to the warnings as"missing frontmatter"and don't appear in the table. I designed it to record a warning instead of failing with an error so that INDEX.md generation itself still succeeds while keeping it clear which file is broken.
launchd config: automatic run at 6:30 on Sundays
The StartCalendarInterval in ~/Library/LaunchAgents/com.shun.agents-index.plist looks like this.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>30</integer>
<key>Weekday</key>
<integer>0</integer>
</dict>
Weekday: 0 is Sunday. The idea is that an up-to-date list is always waiting for me before I start work at the beginning of the week.
Process priority is lowered three ways: Nice: 10 + LowPriorityIO: true + ProcessType: Background. Index generation involves disk I/O, but lowering the priority of the work keeps it from affecting Claude Code's own responsiveness.
Loading is a one-time launchctl load.
launchctl load ~/Library/LaunchAgents/com.shun.agents-index.plist
If you want to run it right now by hand, just call it directly.
~/.claude/scripts/agents-index.sh --json
# → agents=27 warnings=0 → ~/.claude/agents/INDEX.md
Pitfalls I hit
-
INDEX.md itself becomes a loop target → you need to exclude it with
if p.name == "INDEX.md": continue. Forget it and INDEX ends up reading INDEX and breaking -
A
|in a description breaks the table → always run.replace("|", "\\|")after parsing. It's in the real code, but I forgot it at first -
toolsarrives as a JSON array string, so it easily exceeds the 60-character limit →["Read", "Write", "Edit", "Bash", "Grep", "Glob"]is already close to 50 characters. Mixing in MCP tools overflows immediately, so truncation +...is essential -
Writing both StandardOutPath and a
>>redirect in ProgramArguments in the plist gives you duplicate logs → in my actual plist both point at the same file so there's no real harm, but if you're cleaning up you should consolidate to one of them -
StartCalendarInterval is skipped while the Mac is asleep → launchd runs it after waking, but if the machine stays asleep through Sunday you won't get an update until Monday morning. A weekly update is good enough for me so I accept this, but if you want strictness, combine it with
pmset -a wake 1
Summary
- Once
~/.claude/agents/grows, a manually maintained INDEX.md is guaranteed to rot → automate the generation - Read the frontmatter with a one-line parser that doesn't depend on PyYAML, and generate a Markdown table plus
.index.json - Missing fields are appended as a warnings section at the end of INDEX.md, and the script itself doesn't stop
- launchd's
Weekday: 0 / Hour: 6 / Minute: 30runs it automatically at 6:30 on Sundays.Nice: 10+LowPriorityIOkeeps it out of your way - You can also update immediately by hand with a single
agents-index.sh --json
Next time, I plan to write about a mechanism that uses this agent list to visualize cost trends per model.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)