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*