Your monthly Claude Code bill went up 20%. You know that much. What you don't know is which Skill did it — and nothing in the tooling will tell you.
Run /usage in Claude Code and you get claude-sonnet-4-6: ¥3,240 — a per-model total and nothing else. "More expensive than last week" is visible. "Which Skill caused it" is not. usage-breakdown.sh closes that gap. It's a 106-line shell script that parses transcript.jsonl with Python and tallies call counts per Skill, Agent, and MCP server using Counter.
This article walks through how the script works and how to run it, with the actual code and actual numbers.
Why This Approach Works
What Claude Code Is Actually Recording
Claude Code streams every operation during a session into .jsonl files under ~/.claude/projects/. It's JSONL — one event per line, one file per session. The files sit under a <project-id>/ directory.
The skeleton of a single record looks like this:
{
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"name": "Skill",
"input": {
"skill": "pre-completion-self-audit"
}
}
]
}
}
Inside message.content[] sit "type": "tool_use" blocks. The name field is the name of the tool that was invoked. The Bash tool, the Edit tool, the Skill tool, the Agent tool, MCP calls — all of it is recorded in this same format.
Once I noticed that, the thought was: run this through a Counter and everything becomes visible. For the Skill tool, the skill name lives in input.skill; for the Agent tool it's input.subagent_type; and for MCP servers, the tool-name convention mcp__<server>__<tool> lets you extract the server name by splitting on __. The structure is consistent, so the parser comes out surprisingly simple.
What /usage Doesn't Tell You
What Claude Code's /usage command outputs is a per-model cost total for a period.
Model Cost
claude-sonnet-4-6 ¥3,240
claude-opus-4-8 ¥ 892
Useful as far as it goes, but the breakdown of that cost is invisible. You can't see which session, which Skill, how many times it was called, or where the tokens went.
usage-breakdown.sh doesn't tally token volume — it tallies call counts. Accurate token totals would require picking up the usage object from API responses (per a comment in the script: token counts need usage-object aggregation, but call count is a stand-in for now), yet call counts alone are enough to outline what's heavy. A Skill called 100 times and a Skill called once differ by orders of magnitude in token consumption.
Narrowing to "the Last N Days" With an mtime Window
Tallying every session mixes in old logs and blurs comparisons. The script cuts a time window using each file's mtime.
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
The default is 7d; an argument changes it to 30d or 14d. Passing --short emits only a one-line summary suited to a statusline.
5015 tool_use across 39 sessions (7d)
Pipe that into a macOS status bar widget and the total call count accumulating week over week stays permanently visible.
The Four Axes the Counters Track
The script maintains four counters.
skill_calls = collections.Counter() # Skillツール → input.skill
agent_calls = collections.Counter() # Agentツール → input.subagent_type
mcp_calls = collections.Counter() # mcp__<server>__* → サーバー名
plugin_skill_calls = collections.Counter() # plugin:skill 形式のnamespace
tool_calls is the counter for all tools; the four above are its breakdown. Among Skills, those in plugin:skill-name form get bundled per namespace — and that granularity earns its keep in practice. There are moments when counting superpowers:brainstorming and superpowers:research separately tells you nothing you want; you only want to know that the superpowers plugin is heavy.
The decision logic is a plain branch.
if name == "Skill":
skill_name = inp.get("skill", "?")
if ":" in skill_name:
plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
skill_calls[skill_name] += 1
elif name == "Agent":
st = inp.get("subagent_type", "?")
agent_calls[st] += 1
elif name.startswith("mcp__"):
parts = name.split("__")
if len(parts) >= 2:
mcp_calls[parts[1]] += 1
The loop just reads one file line by line and calls json.loads. Parse errors are swallowed by try/except. The whole aggregation core is under 30 lines.
The Overall Flow
Here's the script's processing flow as an ASCII diagram.
~/.claude/projects/
└─ -Users-<username>/
├─ abc123.jsonl ─┐
├─ def456.jsonl ├─► mtime >= cutoff? ─NO─► スキップ
└─ ghi789.jsonl ─┘ │
YES
│
jsonl 1行ずつ読む
│
message.content[]
│
type=="tool_use" のブロック抽出
│
┌──────────────┼──────────────┐
│ │ │
name== name== name starts
"Skill" "Agent" "mcp__"
│ │ │
input.skill subagent_type __split[1]
│ │ │
skill_calls agent_calls mcp_calls
│ │ │
└──────────────┴──────────────┘
│
Counter.most_common(10)
│
stdout へ出力
The Script's Structure (106 Lines Total)
usage-breakdown.sh splits into three parts.
Part 1: The shell layer (lines 1–16)
Handles argument parsing, checking that the transcript directory exists, and handing off to the Python script.
#!/usr/bin/env bash
set -uo pipefail
ARG="${1:-7d}"
TR_DIR="$HOME/.claude/projects/-Users-<username>"
[ -d "$TR_DIR" ] || { echo "(no transcript dir)"; exit 0; }
python3 - "$TR_DIR" "$ARG" <<'PY'
The <<'PY' ... PY heredoc embeds the Python code inline. The point of that structure is to keep everything in one file without dropping an external .py alongside it. Operationally that means: nothing to install, no path resolution, works no matter where you call it from.
Part 2: Argument parsing and time-window computation (lines 18–28)
SHORT = arg == "--short"
days = int((arg if arg.endswith("d") else "7d").rstrip("d"))
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
After branching on the --short flag, 7d is converted to the number 7. The endswith("d") check accepts both the 30d form and a bare integer.
Part 3: File scanning and the aggregation core (lines 37–73)
glob.glob gets the list of JSONL files, and only those passing the mtime filter are opened. The pipeline is: json.loads per line → walk the message.content list → extract tool_use blocks → increment the four Counters.
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
total_files += 1
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
rec = json.loads(line)
msg = rec.get("message", {})
content = msg.get("content")
if not isinstance(content, list): continue
for block in content:
if block.get("type") != "tool_use": continue
name = block.get("name", "")
inp = block.get("input") or {}
tool_calls[name] += 1
# ... 4本の分岐
errors="replace" is passed to keep an occasional invalid byte from halting the read of an entire file.
Part 4: Output (lines 75–106)
With --short, a one-line summary; in normal mode, the top 10 per section via most_common(10).
print(f"=== usage breakdown (last {days}d, {total_files} transcripts) ===")
print(f"\ntotal tool_use: {sum(tool_calls.values())}")
if skill_calls:
print(f"\n--- top skills ({len(skill_calls)} unique) ---")
for sk, n in skill_calls.most_common(10):
print(f" {n:>5} {sk}")
The right-aligned {n:>5} format keeps the columns lined up even when digit counts differ. A small touch for readability in the terminal.
Actual Output From a 7-Day Run
=== usage breakdown (last 7d, 39 transcripts) ===
total tool_use: 5015
--- top tools ---
3656 Bash
508 Edit
304 Read
240 Write
37 Monitor
35 ToolSearch
23 AskUserQuestion
20 TaskUpdate
19 mcp__plugin_playwright_playwright__browser_take_screenshot
16 mcp__claude-in-chrome__navigate
--- top skills (3 unique) ---
3 artifact-design
1 dataviz
1 claude-api
--- top agents (1 unique) ---
1 code-reviewer
--- top MCP servers (4 unique) ---
79 plugin_playwright_playwright
45 claude-in-chrome
15 claude_ai_Google_Calendar
2 claude_ai_Gmail
39 sessions over 7 days, 5,015 total tool calls. Bash leads by a mile at 3,656 calls (72.9%), with Edit behind it at 508. Skills and Agents are lower than I expected — what that number means is dug into in the next section. Widen to 30 days and the picture changes.
=== usage breakdown (last 30d, 203 transcripts) ===
total tool_use: 21215
--- top agents (7 unique) ---
94 general-purpose
22 Explore
6 reviewer
...
--- top MCP servers (5 unique) ---
1571 claude-in-chrome
81 plugin_playwright_playwright
54 computer-use
Over a 30-day span, claude-in-chrome hits 1,571 calls — about 366 per week. Among Agents, general-purpose hits 94 (23 per week). Steady-state weight that was hard to see in a 7-day window surfaces in a 30-day one.
That gap — weight invisible in a short window and only visible in a long one — is where the tuning points for scheduled automation live.
Implementation Details — Digging Into "Why It's Written That Way"
The Design Intent Behind the Double try/except
Reading the aggregation core (lines 37–73), you'll notice try/except is two layers deep.
for path in glob.glob(f"{tr_dir}/*.jsonl"):
try:
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
total_files += 1
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
try:
rec = json.loads(line)
except: continue # ← 内側
...
except Exception:
continue # ← 外側
The inner try/except wraps only json.loads. Since JSONL is one record per line, a single line failing to parse doesn't stop the rest from being read. It just continues to the next line.
The outer try/except Exception catches per-file exceptions. Permission error, file deleted, mtime lookup failed — whichever happens, continue skips that file and moves to the next. That's why the total_files increment sits inside the outer try: you only want to count a file you successfully opened.
The reason for two layers is the difference in granularity. "This file can't be read" and "this line isn't JSON" are different failures with different continuation scopes. Collapse them into one layer with a per-file continue and a single file with a broken first line costs you the remaining few thousand lines wholesale.
Being Thorough With isinstance Guards
Line 47 has a guard that looks belt-and-suspenders at first glance.
msg = rec.get("message", {}) if isinstance(rec.get("message"), dict) else {}
rec.get("message", {}) looks like it'd be enough, but it isn't. transcript.jsonl contains records with "message": null. null is valid JSON, so it sails through json.loads, but in Python it becomes None. {}.get("content") is fine; None.get("content") dies with AttributeError. Without the pattern of confirming it's a dict via isinstance before calling .get(), every null record you hit gets caught by the inner except instead.
For the same reason, line 54 has its own defense.
inp = block.get("input") or {}
block.get("input") can return None. None or {} evaluates to {}, so the subsequent inp.get("skill", "?") runs safely. It's shorter than writing if inp is None: inp = {}, and it conveys the intent — "for both None and an empty dict, I want an empty dict" — in a single line.
And line 50.
for block in content:
if not isinstance(block, dict): continue
content has been confirmed to be a list, but that doesn't guarantee its elements are all dict. Browsing Claude Code transcripts, you occasionally find records where content is a list of strings (in some cases where text blocks and tool blocks are mixed). Checking isinstance(block, dict) per element and skipping non-dicts is the robust move.
The Heredoc Quoting Is a Lifeline
Look carefully at line 16.
python3 - "$TR_DIR" "$ARG" <<'PY'
The single quotes on <<'PY' are absolutely required. Make it <<PY (unquoted) and shell variable expansion runs inside the heredoc. If the Python code contains even one occurrence of something like $tr_dir, the shell will try to expand it and it mutates into an unintended string. f"{tr_dir}/*.jsonl" is a Python f-string so there's no $, but anything that looks like $1 or ${HOME} breaks. Quoting the delimiter as in <<'PY' fully disables expansion inside the heredoc, and the Python code is passed to python3's stdin as the literal string it is.
The advantage of embedding Python inline via a heredoc is that everything lives in one file. Drop the script in some directory, put it on your PATH, and that's all it takes to run. If you're calling ~/.claude/scripts/usage-breakdown.sh from launchd, there's no separate Python file path to manage. External file dependencies break silently the moment that file is deleted or moved.
The plugin namespace Splitting Logic
The block at lines 59–61 is small, but its value shows once you actually use it.
if ":" in skill_name:
plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
skill_calls[skill_name] += 1
The 1 in split(":", 1) matters. Capping the max split count at 1 means expo:eas-hosting becomes ["expo", "eas-hosting"], and even if a skill name shaped like expo:eas:hosting existed, it becomes ["expo", "eas:hosting"] — the namespace portion alone is extracted correctly.
Incrementing both plugin_skill_calls and skill_calls is about separating the axes of aggregation. skill_calls tallies individual skill names; plugin_skill_calls tallies namespaces. In a weekly report you can pull both the bundled number ("used the expo plugin 12 times total") and the breakdown ("expo:eas-hosting 5 times, expo:expo-upgrade 4 times").
The --short Flag and Status Bar Integration
The single line --short mode returns is meant to be called directly from a macOS status bar widget (xbar, Übersicht, etc.) and displayed.
5015 tool_use across 39 sessions (7d)
The setup: a launchd plist runs the script every 5 minutes, writes the result to /tmp/usage-short.txt, and the widget reads that. Since the widget only reads a file, periodic runs during a Claude Code session don't conflict with anything. Without the --short flag the output runs over 10 lines — too long to embed in a widget. Designing in a per-purpose output-format switch from the start saves you from getting stuck later.
Where I Got Stuck
Snag ①: Without errors="replace", Whole Files Never Made It Through
The first version didn't have errors="replace".
with open(path, "r", encoding="utf-8") as f: # ← errorsなし
Run it that way and some transcript files throw UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position ... and the script stops. Because the file was wrapped in the outer try/except Exception it didn't actually halt — but that entire file got skipped by continue.
The cause is transcripts that contain base64 PNG image data. In sessions where you use screenshots in Claude Code, images are written into transcript.jsonl as base64. The base64 string itself is ASCII so it reads fine as UTF-8, but occasionally malformed JSONL gets generated with binary mixed in. Passing errors="replace" substitutes unreadable bytes with U+FFFD (REPLACEMENT CHARACTER) and keeps reading. Even if a broken byte inside a JSON value becomes a replacement character, json.loads parses the whole line — so as long as the structure is intact, the parse goes through. If the structure is broken, the inner except: continue catches it.
errors="replace" trades tolerance for data loss in exchange for getting through the whole file. For usage aggregation, "did I manage to scan every file" matters more than byte-level precision, so the call was right.
Snag ②: The mtime Window Returned a Flood of False Positives
After using the script for a while, there was a day where I noticed that a supposedly last-7-days tally "obviously has data from old sessions mixed in." The total count in the output had ballooned to 3–4× the usual, and looking at the contents, exchanges from two weeks earlier were included.
The cause was a backup software restore. Sync your home directory with Time Machine or rsync and the files under ~/.claude/projects/ get overwritten by copies. The copy changes each file's creation time — and mtime becomes "the time it was copied" too. The contents are an old session's transcript, but the mtime is today's date.
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
The mtime filter looks at "when this file was last modified," so every file whose mtime got refreshed by the copy is treated as recent. 164 files became false positives in one go, and for 5 consecutive windows the state persisted: "zero new sessions, yet the numbers keep inflating."
The fundamental fix is to parse the 日時: field inside the transcript and judge by actual session time. But that raises implementation cost, so the current workaround is to aggregate over a 30-day window and read the long-term trend. Even when a bulk copy injects false positives, they settle into statistical outliers within a 30-day total. If you use a 7-day window, the only option is an operational rule you hold yourself: don't trust the numbers for the few days right after a backup.
Had I not caught this and instead trusted a number like "Skills were called 100 times last week" while changing a plist's StartInterval, I might have been adjusting something that needed no adjustment at all. The lesson: before taking a tool's output at face value, get in the habit of questioning once — "what does the method of obtaining this number depend on?"
Snag ③: Blanket-Catching the json.loads Exception Hid the Side Effects
The inner except: continue isn't except Exception: continue — it's a bare except:. That catches every exception including BaseException. It swallows KeyboardInterrupt and SystemExit alike.
At first I thought that was fine, but during debugging there was a time when hitting Ctrl+C to stop the script didn't stop it. KeyboardInterrupt was being caught by the inner except: and continued. Once the loop advanced into the next file, it never reached the outer try or except either.
The fix is narrowing the inner one to except (json.JSONDecodeError, ValueError): continue. The reasons json.loads fails are effectively just JSONDecodeError (Python 3.5+) or, rarely, ValueError. Anything else (including KeyboardInterrupt) shouldn't be caught on the inside — it should propagate to the outer except Exception, or reach the user. The current code still has the bare except:, and I do think there's room for improvement there even now. It has never caused a problem in actual operation, but "Ctrl+C might not work" is behavior worth knowing about.
Snag ④: Re-sorting the most_common Return Value Was Doing the Work Twice
At the output stage, there was a point where I tried to further re-order the return value of skill_calls.most_common(10) with sorted(). I wanted it in alphabetical order too.
# やってしまったパターン
for sk, n in sorted(skill_calls.most_common(10), key=lambda x: x[0]):
print(f" {n:>5} {sk}")
This takes most_common(10) first and then reorders by name, so the result is "the overall top 10, alphabetized." Seems fine at a glance, but it creates confusion: "the 11th-most-frequent Skill should sort near the top by name, and it isn't showing."
The job of most_common() is to return the counter in descending frequency. The argument 10 narrows it to the top 10 by frequency. If you're going to sort afterwards, you should either pass no argument to most_common() and take everything before sorting, or use a different data structure suited to the purpose from the start.
This one was fixed with a one-line change, but the real problem was using it without understanding how Collections' Counter works. Counter is internally a subclass of dict, and most_common() is a heap-based O(n log k) operation. Even with a million entries, the top 10 comes back fast. Conversely, fetching everything and sorting it yourself is O(n log n). At small scale it's noise, but the difference shows up once transcripts grow.
Snag ⑤: There Were Cases Where the MCP Server Name Came Out as an Empty String
This is the part that extracts the server name from the mcp__<server>__<tool> form.
elif name.startswith("mcp__"):
parts = name.split("__")
if len(parts) >= 2:
mcp_calls[parts[1]] += 1
In an early version without the if len(parts) >= 2: guard, when a tool name of just mcp__ got mixed in (parts being ["mcp", ""]), parts[1] became an empty string and mcp_calls[""] += 1 piled up. An empty entry reading " 23 " appeared in the output and at first I had no idea what it was.
The cause of empty tool names is incomplete records. Occasionally an MCP response gets interrupted and a transcript is generated with the tool name cut off mid-way. The len(parts) >= 2 guard is the simplest fix, and once I added it the empty entries disappeared. Going further, I'd also want to skip cases where parts[1] is an empty string, so it really should be if len(parts) >= 2 and parts[1]:. In the current code, an empty parts[1] isn't rejected and becomes mcp_calls[""], but it never reaches counts high enough to land in most_common(10), so there's no practical harm.
Gotchas
Beyond the 5 items detailed in the previous section (UnicodeDecodeError, mtime false positives, the bare except:, the most_common double work, and empty MCP server names), here are the finer traps I hit in real operation.
TR_DIR is hardcoded, so it doesn't run in anyone else's environment. Line 13 of the script has a username baked in, like
TR_DIR="$HOME/.claude/projects/-Users-yourname". I tried to carry it to another account and another machine and it didn't work.-Users-$(whoami)solves it, but unless you know the naming convention where slashes in the directory name are replaced with hyphens, you can't even identify the cause.The
--shortflag and the day count are mutually exclusive. Arguments take only the single$1, so writingusage-breakdown.sh --short 30dignores30d. The combination "I want a one-line summary of 30 days" can't be expressed directly. In practice you either take just the first line ofusage-breakdown.sh 30d's output, or modify the script to handle$1/$2.Called from launchd,
python3isn't on the PATH. A script launched by launchd runs with a PATH of only/usr/bin:/bin:/usr/sbin:/sbin. Since the python3 installed by homebrew or nvm lives in/usr/local/binor/opt/homebrew/binand the like, a plain launchd plist gives youpython3: command not found. You need to spell out<key>PATH</key>under the plist's<key>EnvironmentVariables</key>, or specify an absolute path (/opt/homebrew/bin/python3) at the top of the script instead of/usr/bin/env python3.I changed StartInterval and forgot to reload the plist. Even after fixing
StartIntervalin~/Library/LaunchAgents/com.lily.usage-breakdown.plistfrom300(5 minutes) to1800(30 minutes), forgettinglaunchctl unload+launchctl loadleaves it running on the old setting. The reliable way to check whether the change took islaunchctl list com.lily.usage-breakdownand looking atLastExitStatusand the next fire time. I've had the state where I thought I'd changed it by editing the file and in fact nothing had changed — and didn't notice for days.glob.glob's return order isn't guaranteed. The ordering varies by filesystem. The totals come out the same, and a changed processing order doesn't affect thetotal_filescount (the counters are cumulative), but when debugging and trying to trace which position a particular file gets processed in, the order changing every time is confusing. If you want the order pinned down for sure, spelling outsorted(glob.glob(...))is safer.The
most_common(10)cap is fixed, so as Skills grow the tail goes invisible. Once the environment has more than 50 Skills installed, anything below 10th place drops out of view. For weekly tuning purposes, narrowing by a threshold like "everything over 100 calls" is more realistic. The current code hardcodes the output count, so as the environment grows, the information you want gets truncated.I changed a plist based only on the 7-day-window numbers. In a week with few transcripts (e.g. right after a long holiday), absolute numbers look low. "
claude-in-chromewas only called 20 times" reads, from the vantage of a normal 140-per-week, as "this week just happened to be light." Without always pairing it with the 30-day window, you'll judge on an outlier and do unnecessary tuning.I forgot the guard for records where
contentis a string instead of a list. Theisinstance(content, list)check is there now, but the first version made do with justmsg.get("content"), so when a string came in,for block in content:became an iteration over characters. Since each character getsisinstance(block, dict)-tested and dropped, there was no practical harm — but the loop count ballooned pointlessly and it got noticeably slow on transcripts with large file sizes.I wasn't saving the script's output, so I couldn't compare over time. Just running
usage-breakdown.sh 7dby hand and eyeballing it leaves "up or down versus last week" to memory. Once I changed it to write to/tmp/usage-weekly-$(date +%Y-%m-%d).txtonce a week via launchd, a comparison like "MCP was 550 calls per week last month and halved to 280 this month" became objectively available.I misread the intent of the
splitfor skill names containing:going only intoplugin_skill_calls. Incrementing bothplugin_skill_callsandskill_callsis about separating the aggregation axes, but at first I thought it was a bug and deleted the increment toskill_calls. The result was that every individual skill-name tally became?, producing output that read "Skills are being called but all the names are unknown" — very confusing. When reading code, it's important to check why anifis used rather than anelifin a decision branch.
Best Practices
1. Always Quote the Heredoc Delimiter With Single Quotes
Without <<'PY', shell expansion runs whenever the Python code contains a $ (f-strings, or anything that looks like $HOME). If <<PY is working for you, that's luck — it breaks the moment you add a variable named $tr_dir. Fix this as a rule for handling inline Python scripts.
2. Always Pass errors="replace" to open()
Logs and transcripts and the like can have binary mixed in (base64 screenshots, copies of external content, etc.). errors="replace" suits aggregation work that prioritizes "did I manage to scan every file" over data precision. It's a move for raising completion rate.
3. Split try/except Into Two Layers by Granularity
"Per-file failure" and "per-line failure" have different continuation scopes. Design in this two-layer structure from the start and, when debugging, you can trace "which line is broken" and "which file is broken" separately.
4. Don't Skip isinstance Guards Even When They Look Belt-and-Suspenders
rec.get("message", {}) can't reject "message": null. The single line isinstance(rec.get("message"), dict) completely seals off the path where None raises an AttributeError. transcript.jsonl routinely contains values outside the spec, so it's safer to distrust types and check every time.
5. The or {} Pattern Handles None and Empty dict at Once
inp = block.get("input") or {}
Shorter than if inp is None: inp = {}, and clearer in intent. The or operator replaces every falsy value (None, empty dict, empty string) with {}, so the subsequent .get() is safe to call.
6. Keep Aggregation Axes in Multiple Counters
Holding both skill_calls (individual names) and plugin_skill_calls (namespaces) lets you extract the higher-level view ("the expo plugin as a whole is heavy") and the individual view ("expo:eas-hosting 5 times") from the same run. Sorting out "what unit do I want to look at this in" at design time is easier than adding an axis later.
7. Always Use the 7d and 30d Windows as a Pair
The 7-day window is sensitive to noise. Mix in a holiday, a backup restore, or a heavy-work week and it becomes an outlier. Line up the 30-day window, decide whether it's "consistently high or high only this week," and only then touch the plist — this two-window practice prevents wobble in tuning decisions.
8. Design Per-Purpose Output With a --short Flag From the Start
Separating the detailed mode humans read from the one-line mode you feed to widgets and log files from the beginning lets you reuse the same script across multiple contexts. Trying to add an output format later multiplies the branches in the code and hurts clarity.
9. Write Weekly Logs to a File to Preserve the Time Series
~/.claude/scripts/usage-breakdown.sh 7d > /tmp/usage-$(date +%Y-%m-%d).txt
Just running this every Monday via launchd lets you see the comparison against 4 weeks ago with diff. When you want to verify a feeling like "costs seem to have gone up lately" with numbers, having logs on hand versus not changes the conversation entirely.
10. After Changing a plist, Always Take Both Steps: unload → load
launchctl unload ~/Library/LaunchAgents/com.lily.usage-breakdown.plist
launchctl load ~/Library/LaunchAgents/com.lily.usage-breakdown.plist
Rewriting the file alone doesn't apply it. Build the habit of checking "NextScheduledFire" in launchctl list com.lily.usage-breakdown to confirm the next fire time follows the new StartInterval.
11. Spell Out PATH in the launchd plist
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
When running a script that uses homebrew or nvm tools from launchd, without this it fails silently with command not found. There are environments where /usr/bin/env python3 alone isn't enough.
12. For the MCP Server Name Empty-String Guard, Check parts[1] Too
if len(parts) >= 2 and parts[1]:
mcp_calls[parts[1]] += 1
len(parts) >= 2 alone lets the empty string from mcp__ → ["mcp", ""] slip through. Adding and parts[1] prevents an empty key from contaminating most_common.
13. Design Scripts to Be Self-Contained in One File
Carry a dependency on an external Python file and it breaks silently when the file is moved or deleted. The inline heredoc approach with <<'PY' ... PY is the easiest way to make a single script self-contained so it runs as-is wherever you put it.
14. Read Aggregated Numbers Separately From "Trust in How They Were Obtained"
The mtime window story is the archetype. A number appears, but unless you understand its basis — which field of which file is being read — you'll drive tuning on a false premise. The right order is: trace the script's behavior by hand once, grasp the limitation that "false positives appear after a backup," and then put it into steady operation.
Summary
What Claude Code's /usage gives you is only "the per-model total." The 106-line usage-breakdown.sh is the script I wrote to close that gap — it parses transcript.jsonl with Python and tallies call counts per Skill, Agent, and MCP server with a Counter.
Run it over 7 days and the reality shows up as Bash: 3,656 calls (72.9%); widen it to 30 days and steady-state weight like claude-in-chrome: 1,571 calls (366/week equivalent) surfaces. Using those numbers to identify components exceeding 100 weekly calls and adjust a plist's StartInterval — that was the goal of this whole procedure.
Lined up like that the gotchas look like a lot, but every one is a pitfall I could only have noticed after reading the actual code. Read through the aggregation core at lines 30–50 by hand once and trace the behavior, and that alone prevents half of them. The rest are environment dependencies specific to the launchd combination, and they clear up once you've nailed PATH and unload/load.
What holds up a self-driving environment isn't just how smart the individual Skills and MCP servers are — it's having a mechanism that shows you, in numbers, which component is running how much in constant operation. You can't improve what you can't measure. The same data is already piling up in your own transcript.jsonl, so you can run this today.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)