cd /news/developer-tools/fully-custom-claude-code-status-line… · home topics developer-tools article
[ARTICLE · art-64355] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer built a custom three-line status readout for Claude Code that displays plan quota, context usage, session cost, and automation health. The implementation reads JSON piped from stdin, eliminating the need for separate API calls, and caches the data to a file for fallback when stdin is empty. The status line helps the developer monitor consumption at a glance, preventing issues like running out of quota during overnight automated runs.

read7 min views1 publishedJul 18, 2026

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.

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.

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
}

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 appearsafter the first API responsefor 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

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}"

Hide the jq call behind a helper function:

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

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')

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; }

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

CTXC=$(pcolor "$CTX")
C5=$(pcolor "$H5")
C7=$(pcolor "$D7")

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>

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"

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; }

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" ) &

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 tmp

mv

swap also prevents corruption from reading a half-written file.

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

While the cache is valid, it just cat

s 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)

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; everything else is offloaded to a cache or the background.jq

and ANSI formatting

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"

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.

rate_limits

always looks empty// empty

to fall back on null, jq

returns an empty string and pcolor

dies.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.ccusage

every turn freezes the terminaltmp

file is also essential.automation-health.sh

is heavy to start and causes lag·

while updating.printf "%b"

for the status line output expands the escapes correctly. echo -e

is not portable.git -C "$REAL_CWD"

, it references the main repo instead.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.pcolor()

with two thresholds at 50% and 80%.jq

and string formatting only.tmp

mv

swap prevents cache reads from being corrupted.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*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/fully-custom-claude-…] indexed:0 read:7min 2026-07-18 ·