DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Fully Custom Claude Code Status Line: A JSON-Driven 3-Line Readout of Quota, Cost, and Health

This is part of my "Claude Code environment" series. Last time I wrote about the Codex worktree swarm, a setup that gets Codex and Claude Code collaborating. This time the topic is something far less flashy but that you look at every single turn: customizing the status line.

Claude Code can print a single status line at the bottom of the window. By default it shows little more than the model name. Using the feature that lets you point statusLine.command in settings.json at an arbitrary shell script, I built a version that formats your 5h/7d plan quota, context usage, session cost, and automation health into three lines — and I'll walk through the whole implementation. The nice part is that the quota numbers flow in directly from stdin, so no API calls and no auth are required.

The problem: an unattended job goes haywire and you never notice in the terminal

Once I started running autopilot.sh overnight, I kept hitting the same accident: "the 5h block quota is at zero by morning, so every job after that got SKIPPED." The issue was that checking cost or quota meant running ccusage every time. Because checking was a hassle, I stopped checking, and I noticed problems too late.

The ideal state is "one glance at the screen tells me my current plan consumption." If I can just put that on Claude Code's status line, I don't even need to open a terminal.

The settings.json config: how JSON drives it

Lines 269–274 of ~/.claude/settings.json are just this:

"statusLine": {
  "type": "command",
  "command": "~/.claude/scripts/statusline.sh",
  "padding": 0
}
Enter fullscreen mode Exit fullscreen mode

With type: "command", every turn Claude Code launches that command and pipes JSON into stdin. Standard output is displayed verbatim as the status line.

So what's in that stdin? The actual fields are:

Path Contents
.rate_limits.five_hour.used_percentage 5h block usage rate (0–100)
.rate_limits.five_hour.resets_at Reset time (UNIX epoch)
.rate_limits.seven_day.used_percentage 7-day block usage rate
.rate_limits.seven_day.resets_at 7-day reset time (UNIX epoch)
.context_window.used_percentage Context usage rate (pre-computed)
.cost.total_cost_usd Current session cost (USD)
.model.display_name Model name
.effort.level Effort level

Note: rate_limits only appears after the first API response for subscription members. Before the first turn it's empty, so the script needs to fall back to --.

statusline.sh: reading stdin and falling back

At the top of the script it reads stdin and, if it's valid JSON, caches it to /tmp/cc-statusline-last.json.

RAW_INPUT="$(cat 2>/dev/null || true)"
if printf '%s' "$RAW_INPUT" | jq -e 'type == "object" and length > 0' >/dev/null 2>&1; then
  INPUT="$RAW_INPUT"
  printf '%s' "$INPUT" > /tmp/cc-statusline-last.json 2>/dev/null || true
elif [ -s /tmp/cc-statusline-last.json ]; then
  INPUT="$(cat /tmp/cc-statusline-last.json 2>/dev/null || echo '{}')"
else
  INPUT='{}'
fi
Enter fullscreen mode Exit fullscreen mode

Even when stdin is empty (e.g. when another tool references it), it can read back from the cache. claude-limits-segment.sh is set up to receive this cache path via the CLAUDE_STATUSLINE_JSON environment variable:

STATUS_JSON="${CLAUDE_STATUSLINE_JSON:-/tmp/cc-statusline-last.json}"
Enter fullscreen mode Exit fullscreen mode

Hide the jq call behind a helper function:

j() { printf '%s' "$INPUT" | jq -r "$1" 2>/dev/null; }
Enter fullscreen mode Exit fullscreen mode

Fetching plan quota and context is just this:

CTX=$(j '.context_window.used_percentage // empty')
H5=$(j '.rate_limits.five_hour.used_percentage // empty')
H5R=$(j '.rate_limits.five_hour.resets_at // empty')
D7=$(j '.rate_limits.seven_day.used_percentage // empty')
D7R=$(j '.rate_limits.seven_day.resets_at // empty')
SESSION_USD=$(j '.cost.total_cost_usd // 0')
Enter fullscreen mode Exit fullscreen mode

Color coding: two thresholds at 50% and 80%

pcolor() converts a number to an ANSI color:

pcolor() { local n="${1%%.*}"; [ -z "$n" ] && { printf '%s' "$DIM"; return; }
  if [ "$n" -ge 80 ] 2>/dev/null; then printf '%s' "$RED"
  elif [ "$n" -ge 50 ] 2>/dev/null; then printf '%s' "$YEL"
  else printf '%s' "$GRN"; fi; }
Enter fullscreen mode Exit fullscreen mode
  • Under 50% → green (comfortable)
  • 50–79% → yellow (caution)
  • 80%+ → red (danger zone)

Apply this to all three: context, 5h, and 7d:

CTXC=$(pcolor "$CTX")
C5=$(pcolor "$H5")
C7=$(pcolor "$D7")
Enter fullscreen mode Exit fullscreen mode

Assembling the three lines

The spec in the script's comments maps directly to the final output:

line1: 📁 dir   ⌥ branch
line2: 🤖 model·effort   🧠 ctx%   🕐 5h N% ⏪reset   📅 7d N% ⏪reset
line3: 💴 session ≈¥x   今日 ≈¥y   <health>
Enter fullscreen mode Exit fullscreen mode

In code:

L1=$(printf "${DIM}📁${R} ${CYAN}%s${R}%s%s" "$DIRNAME" "$BR" "$WT")
L2=$(printf "${MAG}🤖 %s%s${R}  ${DIM}·${R}  ${CTXC}🧠 ctx %s${R}  ${DIM}·${R}  ${C5}🕐 5h %s${R}%b  ${DIM}·${R}  ${C7}📅 7d %s${R}%b" \
  "$MODEL" "$EFF" "$(pct "$CTX")" "$(pct "$H5")" "$R5" "$(pct "$D7")" "$R7")
L3=$(printf "${DIM}💴 session${R} %s   ${DIM}今日${R} %s  %s%b" \
  "$(yen "$SESSION_USD")" "$(yen "$DAY_USD")" "$HEALTH" "$PROJ_HEALTH_SEG")

printf "%b\n%b\n%b" "$L1" "$L2" "$L3"
Enter fullscreen mode Exit fullscreen mode

Reset times are converted from epoch with date -r. The 5h uses HH:MM, and the 7d uses M/D HH:MM (since it can cross day boundaries):

clk()  { [ -n "$1" ] && date -r "$1" "+%H:%M" 2>/dev/null; }
mdhm() { [ -n "$1" ] && date -r "$1" "+%-m/%-d %H:%M" 2>/dev/null; }
Enter fullscreen mode Exit fullscreen mode

Today's cost: 2-minute cache plus background refresh

The 5h block quota comes straight from stdin, but today's total cost requires an external command, ccusage daily. Calling it every turn feels slow, so I use a 2-minute cache plus background refresh:

NEED=true
if [ -f "$DAY_CACHE" ]; then
  AGE=$(($(date +%s) - $(stat -f %m "$DAY_CACHE" 2>/dev/null || echo 0)))
  [ "$AGE" -lt 120 ] && NEED=false
fi
[ "$NEED" = true ] && ( "$CCUSAGE" daily --json 2>/dev/null > "${DAY_CACHE}.tmp" && mv "${DAY_CACHE}.tmp" "$DAY_CACHE" ) &
Enter fullscreen mode Exit fullscreen mode

The & launches it in the background, while the foreground reads immediately from the cache. The display can lag by up to 2 minutes, but the status line never freezes. The atomic tmpmv swap also prevents corruption from reading a half-written file.

Health icon: reading with zero subprocesses

automation-health.sh checks the success/failure of every launchd job, so it's expensive to start. Calling it every turn produces noticeable lag. The solution is the same pattern — 5-minute cache plus background refresh:

HEALTH_CACHE="/tmp/cc-health-cache"; HEALTH=""
if [ -f "$HEALTH_CACHE" ]; then
  HAGE=$(($(date +%s) - $(stat -f %m "$HEALTH_CACHE" 2>/dev/null || echo 0)))
  [ "$HAGE" -lt 300 ] && HEALTH=$(cat "$HEALTH_CACHE" 2>/dev/null)
fi
if [ -z "$HEALTH" ]; then
  ( h=$(~/.claude/scripts/automation-health.sh 2>&1 | grep -oE "ALL GREEN|WARN|FAIL" | head -1)
    case "$h" in
      "ALL GREEN") echo "🟢" > "$HEALTH_CACHE" ;;
      "WARN")      echo "🟡" > "$HEALTH_CACHE" ;;
      "FAIL")      echo "🔴" > "$HEALTH_CACHE" ;;
      *)           echo "⚫" > "$HEALTH_CACHE" ;;
    esac ) &
  HEALTH="·"   # show a middle dot while updating
fi
Enter fullscreen mode Exit fullscreen mode

While the cache is valid, it just cats the file. Zero subprocesses. If it's expired, it fires the refresh into the background with & and shows · (a middle dot) in the meantime. By the next turn the cache is updated and it switches to an icon.

project-health-latest.md (the project health report) follows the same philosophy — resolve everything by just reading a file:

PH_FILE="$HOME/.claude/logs/project-health-latest.md"
PH_WARN=$(grep -oE '^## Warnings \([0-9]+\)' "$PH_FILE" 2>/dev/null | grep -oE '[0-9]+' | head -1)
Enter fullscreen mode Exit fullscreen mode

It picks up the warning count with grep, showing 🟢 if 0 and 🟡/🔴 if there are any. No external process is started.

Note: "The status line script is called every turn." Startup cost adds up. The rule is: the only things done synchronously are jq and ANSI formatting; everything else is offloaded to a cache or the background.

claude-limits-segment.sh: carving out a component for other tools

Separately from statusline.sh, I also keep a small script that returns just the plan quota. It's used by things like autopilot.sh to check "does the plan have room right now?":

STATUS_JSON="${CLAUDE_STATUSLINE_JSON:-/tmp/cc-statusline-last.json}"

h5=$(jq_field '.rate_limits.five_hour.used_percentage')
h5r=$(jq_field '.rate_limits.five_hour.resets_at')
d7=$(jq_field '.rate_limits.seven_day.used_percentage')
d7r=$(jq_field '.rate_limits.seven_day.resets_at')

stale=''
[ "$age" -gt 600 ] && stale='*'   # mark caches older than 10 minutes

printf '🕐 5h %s' "$(pct "$h5")"
[ -n "$r5" ] && printf ' ⏪%s' "$r5"
printf ' · 📅 7d %s' "$(pct "$d7")"
[ -n "$r7" ] && printf ' ⏪%s' "$r7"
printf '%s' "$stale"
Enter fullscreen mode Exit fullscreen mode

It references the very same /tmp/cc-statusline-last.json that statusline.sh wrote. If the cache is older than 10 minutes, it appends a * to make staleness visible.

Pitfalls I hit

  • rate_limits always looks empty → before the first turn it genuinely doesn't exist. Without // empty to fall back on null, jq returns an empty string and pcolor dies.
  • Reset times can't be converted to local time → macOS's date -r is the BSD version, with different syntax from Linux's date -d @epoch. stat -f %m is the same story. Using this on Linux requires rewriting.
  • Calling ccusage every turn freezes the terminal → solved with a 2-minute cache plus background refresh. The atomic swap via a tmp file is also essential.
  • automation-health.sh is heavy to start and causes lag → offload to a 5-minute cache plus background. Show · while updating.
  • Some terminals let ANSI color codes interfere with output → using printf "%b" for the status line output expands the escapes correctly. echo -e is not portable.
  • Can't get the git branch inside a worktree → without making the working directory explicit with git -C "$REAL_CWD", it references the main repo instead.

Summary

  • Just specifying statusLine.command in settings.json delivers JSON stdin every turn.
  • rate_limits, context_window, and cost.total_cost_usd can be read straight from stdin with no API call.
  • Color coding is handled by pcolor() with two thresholds at 50% and 80%.
  • Offload heavy work (ccusage, health check) to a cache plus background. Keep the synchronous path to jq and string formatting only.
  • The atomic tmpmv swap prevents cache reads from being corrupted.
  • Carving out a component script (claude-limits-segment.sh) makes it easy to reuse from other automation.

Next time I plan to write about the internals of the automation health check (automation-health.sh) shown on this status line — a setup that lists the liveness of launchd jobs, script exit codes, and the last successful run time.


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)