A complete guide to building a Claude Code status line that shows your 5-hour and weekly usage limits with color-coded progress bars, pacing markers, reset times, and context window usage.
What you get:
yearone-3 main* │ Opus 4.6 │ 07:00 PM │ ctx ▓▓░░░░░░░░ 20% │ 5hr (11pm) ▓▓▓│░░░░░░ 37% │ wk (thu, 10am) ▓▓░│░░░░░░ 26%
- Color-coded bars: green (<50%), yellow (50-80%), bright red (>80%)
- Hot pink
│pacing marker showing where you should be for even usage across the window - Reset times so you know when your limits refill
- macOS (uses
securitykeychain CLI and BSDdate) jqinstalled (brew install jq)- Claude Code with an active subscription (Max, Pro, etc.)
The status line calls an undocumented Anthropic API (/api/oauth/usage) that requires an OAuth token with user:profile scope. Here's where it gets tricky.
Claude Code stores OAuth credentials in the macOS Keychain under the service name Claude Code-credentials. The entry contains JSON with a claudeAiOauth object that includes accessToken, refreshToken, expiresAt, and scopes.
Claude Code refreshes its token in-memory but does not write the refreshed token back to the keychain. So the keychain entry can go stale (ours was expired for 7+ months while CC worked fine).
Fix: Delete the stale keychain entry and force CC to re-authenticate:
# Delete the stale entry
security delete-generic-password -s "Claude Code-credentials"
# Then fully quit ALL Claude Code instances and restart.
# CC will open a browser for OAuth login and write fresh credentials.The /login slash command inside Claude Code is for MCP server authentication, not CC's own OAuth. Running it will overwrite the keychain entry with MCP OAuth data (just an mcpOAuth key) and wipe out your claudeAiOauth credentials.
If you accidentally did this, delete the keychain entry again and restart CC as above.
claude setup-token generates a long-lived token, but it only has user:inference scope. The usage API requires user:profile scope. This token will return:
{"error": {"message": "OAuth token does not meet scope requirement user:profile"}}Don't use setup-token for this purpose.
If you try to manually refresh the token using the OAuth endpoint, be aware that the refresh token is single-use. If you call the refresh endpoint and don't save the new access token, the refresh token is consumed and you'll need to delete the keychain entry and re-authenticate.
For reference, the refresh endpoint is:
POST https://api.anthropic.com/v1/oauth/token
Content-Type: application/json
{"grant_type": "refresh_token", "refresh_token": "...", "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e"}
The API returns timestamps in UTC (2026-02-07T23:59:59.771624+00:00). On macOS, when parsing with date -jf, you must use the -u flag to tell date the input is UTC, otherwise the pacing target calculation will be wrong:
# WRONG - interprets UTC time as local time
date -jf "%Y-%m-%dT%H:%M:%S" "2026-02-07T23:59:59" +%s
# CORRECT - treats input as UTC
date -juf "%Y-%m-%dT%H:%M:%S" "2026-02-07T23:59:59" +%sIf you build a string with ANSI color codes (\033[32m) and pass it to printf, the \ characters get interpreted as format specifiers and you'll get:
printf: `\': invalid format character
Use echo -e instead of printf for the final output line.
Endpoint: GET https://api.anthropic.com/api/oauth/usage
Headers:
Authorization: Bearer <oauth_access_token>
anthropic-beta: oauth-2025-04-20
Content-Type: application/json
Response:
{
"five_hour": {
"utilization": 37.0,
"resets_at": "2026-02-08T04:59:59.000000+00:00"
},
"seven_day": {
"utilization": 26.0,
"resets_at": "2026-02-12T14:59:59.771647+00:00"
},
"seven_day_opus": null,
"seven_day_sonnet": {
"utilization": 1.0,
"resets_at": "2026-02-13T20:59:59.771655+00:00"
},
"extra_usage": {
"is_enabled": false,
"monthly_limit": null,
"used_credits": null,
"utilization": null
}
}utilizationis a percentage (0-100)resets_atis when the window ends (UTC)five_houris a rolling 5-hour windowseven_dayis a rolling 7-day window
Save this as ~/.claude/statusline-command.sh:
#!/bin/bash
# Read Claude Code context from stdin
input=$(cat)
# Extract information from Claude Code context
model_name=$(echo "$input" | jq -r '.model.display_name // "Claude"')
current_dir=$(echo "$input" | jq -r '.workspace.current_dir // ""')
output_style=$(echo "$input" | jq -r '.output_style.name // "default"')
context_pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
# Get current working directory basename for display
if [ -n "$current_dir" ]; then
dir_name=$(basename "$current_dir")
else
dir_name=$(basename "$(pwd)")
fi
# Get git branch with dirty indicator
git_info=""
if git rev-parse --git-dir > /dev/null 2>&1; then
branch=$(git branch --show-current 2>/dev/null || git rev-parse --short HEAD 2>/dev/null)
if [ -n "$branch" ]; then
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
git_info=" ${branch}*"
else
git_info=" ${branch}"
fi
fi
fi
current_time=$(date '+%I:%M %p')
# --- Progress bar with optional pacing marker ---
# Usage: make_bar <pct> [target_pct] [ansi_color_code]
# The target marker shows where you'd be with perfectly even usage across the window.
# If your filled blocks are past the marker, you're ahead of pace (burning fast).
# If behind, you're pacing well.
make_bar() {
local pct=$1 target=${2:-} color=${3:-} width=10
local filled=$((pct * width / 100))
[ "$filled" -gt "$width" ] && filled=$width
local target_pos=-1
if [ -n "$target" ] && [ "$target" -ge 0 ] 2>/dev/null && [ "$target" -le 100 ]; then
target_pos=$((target * width / 100))
[ "$target_pos" -gt "$width" ] && target_pos=$width
fi
local bar=""
for ((i=0; i<width; i++)); do
if [ "$i" -eq "$target_pos" ]; then
# Hot pink pacing marker, then restore the bar color
bar="${bar}\033[38;5;199m│\033[0m${color}"
elif [ "$i" -lt "$filled" ]; then
bar="${bar}▓"
else
bar="${bar}░"
fi
done
printf "%s" "$bar"
}
# Color thresholds: green < 50%, yellow 50-80%, bright red > 80%
color_for_pct() {
local pct=$1
if [ "$pct" -ge 80 ]; then
printf "\033[91m" # bright red
elif [ "$pct" -ge 50 ]; then
printf "\033[33m" # yellow
else
printf "\033[32m" # green
fi
}
CTX_COLOR=$(color_for_pct "$context_pct")
CTX_BAR=$(make_bar "$context_pct" "" "$CTX_COLOR")
# --- Usage limits from Anthropic API ---
USAGE_CACHE="/tmp/claude-statusline-usage.json"
USAGE_CACHE_AGE=60 # seconds between API calls
fetch_usage() {
local creds token response
creds=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) || return 1
token=$(echo "$creds" | jq -r '.claudeAiOauth.accessToken') || return 1
[ -z "$token" ] || [ "$token" = "null" ] && return 1
response=$(curl -s --max-time 3 "https://api.anthropic.com/api/oauth/usage" \
-H "Authorization: Bearer $token" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "Content-Type: application/json" 2>/dev/null) || return 1
if echo "$response" | jq -e '.error' >/dev/null 2>&1; then
return 1
fi
echo "$response" > "$USAGE_CACHE"
}
# Only call API if cache is stale or missing
if [ ! -f "$USAGE_CACHE" ] || [ $(($(date +%s) - $(stat -f%m "$USAGE_CACHE" 2>/dev/null || echo 0))) -gt $USAGE_CACHE_AGE ]; then
fetch_usage 2>/dev/null
fi
# Parse cached data and calculate pacing targets
usage_5h=""
usage_7d=""
target_5h=""
target_7d=""
resets_5h_label=""
resets_7d_label=""
if [ -f "$USAGE_CACHE" ]; then
usage_5h=$(jq -r '.five_hour.utilization // empty' "$USAGE_CACHE" 2>/dev/null | cut -d. -f1)
usage_7d=$(jq -r '.seven_day.utilization // empty' "$USAGE_CACHE" 2>/dev/null | cut -d. -f1)
NOW_EPOCH=$(date +%s)
# 5-hour pacing target
resets_5h=$(jq -r '.five_hour.resets_at // empty' "$USAGE_CACHE" 2>/dev/null)
if [ -n "$resets_5h" ]; then
# IMPORTANT: -u flag treats input as UTC (resets_at is always UTC)
reset_epoch=$(date -juf "%Y-%m-%dT%H:%M:%S" "$(echo "$resets_5h" | cut -d. -f1 | sed 's/+.*//')" +%s 2>/dev/null || date -d "$resets_5h" +%s 2>/dev/null)
if [ -n "$reset_epoch" ]; then
window_secs=$((5 * 3600))
start_epoch=$((reset_epoch - window_secs))
elapsed=$((NOW_EPOCH - start_epoch))
[ "$elapsed" -lt 0 ] && elapsed=0
[ "$elapsed" -gt "$window_secs" ] && elapsed=$window_secs
target_5h=$((elapsed * 100 / window_secs))
resets_5h_label=$(date -r "$reset_epoch" '+%-l%p' | tr '[:upper:]' '[:lower:]' | tr -d ' ')
fi
fi
# 7-day pacing target
resets_7d=$(jq -r '.seven_day.resets_at // empty' "$USAGE_CACHE" 2>/dev/null)
if [ -n "$resets_7d" ]; then
reset_epoch=$(date -juf "%Y-%m-%dT%H:%M:%S" "$(echo "$resets_7d" | cut -d. -f1 | sed 's/+.*//')" +%s 2>/dev/null || date -d "$resets_7d" +%s 2>/dev/null)
if [ -n "$reset_epoch" ]; then
window_secs=$((7 * 86400))
start_epoch=$((reset_epoch - window_secs))
elapsed=$((NOW_EPOCH - start_epoch))
[ "$elapsed" -lt 0 ] && elapsed=0
[ "$elapsed" -gt "$window_secs" ] && elapsed=$window_secs
target_7d=$((elapsed * 100 / window_secs))
resets_7d_label=$(date -r "$reset_epoch" '+%a, %-l%p' | tr '[:upper:]' '[:lower:]' | sed 's/ //2')
fi
fi
fi
# Assemble usage segments
usage_parts=""
if [ -n "$usage_5h" ]; then
U5_COLOR=$(color_for_pct "$usage_5h")
U5_BAR=$(make_bar "$usage_5h" "$target_5h" "$U5_COLOR")
reset_label=""
[ -n "$resets_5h_label" ] && reset_label=" (${resets_5h_label})"
usage_parts="${U5_COLOR}5hr${reset_label} ${U5_BAR} ${usage_5h}%\033[0m"
fi
if [ -n "$usage_7d" ]; then
U7_COLOR=$(color_for_pct "$usage_7d")
U7_BAR=$(make_bar "$usage_7d" "$target_7d" "$U7_COLOR")
reset_7d_label_str=""
[ -n "$resets_7d_label" ] && reset_7d_label_str=" (${resets_7d_label})"
[ -n "$usage_parts" ] && usage_parts="${usage_parts}\033[2m │ \033[0m"
usage_parts="${usage_parts}${U7_COLOR}wk${reset_7d_label_str} ${U7_BAR} ${usage_7d}%\033[0m"
fi
# Single line output using echo -e (NOT printf — printf chokes on ANSI codes in variables)
line="\033[2m\033[96m${dir_name}\033[0m\033[2m${git_info} │ ${model_name} │ ${current_time} │ ${CTX_COLOR}ctx ${CTX_BAR} ${context_pct}%\033[0m"
if [ -n "$usage_parts" ]; then
line="${line}\033[2m │ \033[0m${usage_parts}"
fi
echo -e "$line"chmod +x ~/.claude/statusline-command.shAdd this to ~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh"
}
}# Check if you have a valid token
security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null | jq '.claudeAiOauth.scopes'You need ["user:inference", "user:profile"]. If the entry is missing, expired, or has wrong scopes:
# Delete stale entry
security delete-generic-password -s "Claude Code-credentials"
# Quit ALL Claude Code instances, then restart.
# CC opens a browser for OAuth — this creates a fresh keychain entry with correct scopes.# Test the script manually
echo '{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp"},"context_window":{"used_percentage":42}}' \
| bash ~/.claude/statusline-command.shThe script uses ANSI 256-color codes. Change these to taste:
| Element | Current | Code |
|---|---|---|
| Directory | Light cyan | \033[96m |
| Pacing marker | Hot pink | \033[38;5;199m |
| Good (<50%) | Green | \033[32m |
| Warning (50-80%) | Yellow | \033[33m |
| Danger (>80%) | Bright red | \033[91m |
Dark red (\033[31m) is nearly invisible on dark terminal backgrounds. Use bright red (\033[91m) instead.
Change width=10 in make_bar() to make bars wider or narrower.
Change USAGE_CACHE_AGE=60 to control how often the API is called (in seconds).
Claude Code pipes these fields to your script via stdin:
| Field | Description |
|---|---|
model.display_name |
Current model name |
model.id |
Model identifier |
context_window.used_percentage |
Context window usage |
context_window.total_input_tokens |
Cumulative input tokens |
context_window.total_output_tokens |
Cumulative output tokens |
cost.total_cost_usd |
Session cost |
cost.total_duration_ms |
Total session time |
cost.total_lines_added |
Lines added |
cost.total_lines_removed |
Lines removed |
workspace.current_dir |
Current directory |
output_style.name |
Output style |
vim.mode |
Vim mode (if enabled) |
The script uses macOS-specific commands. For Linux:
- Replace
security find-generic-password ...with however you store credentials (e.g.,secret-tool, a file, or an env var) - Replace
date -jufwithdate -udfor UTC parsing - Replace
stat -f%mwithstat -c%Yfor file modification time - Replace
date -r $epochwithdate -d @$epochfor epoch-to-human conversion
Run this to check everything at once:
echo "=== 1. jq installed? ==="
which jq && jq --version || echo "MISSING: brew install jq"
echo -e "\n=== 2. Keychain entry exists? ==="
CREDS=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
if [ -z "$CREDS" ]; then
echo "MISSING: No keychain entry. Quit all CC instances and restart to trigger OAuth login."
else
echo "Found keychain entry"
fi
echo -e "\n=== 3. Has claudeAiOauth? ==="
echo "$CREDS" | jq -e '.claudeAiOauth' >/dev/null 2>&1 \
&& echo "YES" \
|| echo "NO — keychain only has: $(echo "$CREDS" | jq -r 'keys | join(", ")'). Delete entry and restart CC."
echo -e "\n=== 4. Token scopes ==="
echo "$CREDS" | jq -r '.claudeAiOauth.scopes // ["none"] | join(", ")' 2>/dev/null
echo "(need: user:inference, user:profile)"
echo -e "\n=== 5. Token expired? ==="
EXPIRES=$(echo "$CREDS" | jq -r '.claudeAiOauth.expiresAt // 0' 2>/dev/null)
NOW_MS=$(($(date +%s) * 1000))
if [ "$EXPIRES" -gt "$NOW_MS" ] 2>/dev/null; then
echo "VALID — expires $(date -r $((EXPIRES / 1000)))"
else
echo "EXPIRED — delete keychain entry and restart CC"
fi
echo -e "\n=== 6. API test ==="
TOKEN=$(echo "$CREDS" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
if [ -n "$TOKEN" ]; then
RESP=$(curl -s --max-time 5 "https://api.anthropic.com/api/oauth/usage" \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "Content-Type: application/json")
if echo "$RESP" | jq -e '.five_hour' >/dev/null 2>&1; then
echo "SUCCESS"
echo "$RESP" | jq '{five_hour: .five_hour.utilization, seven_day: .seven_day.utilization}'
else
echo "FAILED: $(echo "$RESP" | jq -r '.error.message // "unknown error"')"
fi
else
echo "SKIPPED — no token"
fi
echo -e "\n=== 7. Script test ==="
echo '{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp"},"context_window":{"used_percentage":42}}' \
| bash ~/.claude/statusline-command.sh 2>&1 && echo -e "\n(exit: 0)" || echo -e "\n(exit: $?)"| Problem | Cause | Fix |
|---|---|---|
| No status line at all | Script crashes with non-zero exit | Run the diagnostic above — step 7 shows the error |
| No usage data (only ctx shows) | Keychain token expired or missing | Run diagnostic steps 2-6, then delete entry + restart CC |
| Usage shows 0% with bar at far left | Stale cache from a previous window | rm /tmp/claude-statusline-usage.json to force a fresh fetch |
| Pacing marker at far left (wrong) | UTC timezone not handled in date parsing | Ensure -u flag: date -juf not date -jf |
printf: invalid format character |
ANSI escape codes in printf variable | Use echo -e for final output, not printf |
Token scope error (user:profile) |
Used claude setup-token |
That token only has user:inference. Delete keychain entry, restart CC for browser OAuth |
Keychain has only mcpOAuth key |
Ran /login inside CC |
/login is for MCP servers, not CC auth. Delete entry, restart CC |
| Status line wraps to next line | Output too wide for terminal | Shorten labels (wk not weekly), drop user@host, drop seconds from time |
jq: command not found |
jq not installed | brew install jq |
| Keychain access popup/prompt | macOS asking permission | Click "Always Allow" — the script reads keychain on every cache refresh |
| Usage data is stale / not updating | Cache file not refreshing | Check ls -la /tmp/claude-statusline-usage.json — if mod time is old, token is probably expired |
| Script works manually but not in CC | disableAllHooks: true in settings |
Remove that setting from ~/.claude/settings.json |
| Git branch slow in large repos | git diff and git ls-files scanning |
Cache git info to a file with a 5-second TTL |
If nothing works, start completely fresh:
# 1. Delete keychain entry
security delete-generic-password -s "Claude Code-credentials" 2>/dev/null
# 2. Delete stale cache
rm -f /tmp/claude-statusline-usage.json
# 3. Quit ALL Claude Code instances (all terminals)
# Cmd+Q or `killall claude` if desperate
# 4. Restart Claude Code
# It will open a browser for OAuth login
# This creates a fresh keychain entry with user:inference + user:profile scopes
# 5. Verify
security find-generic-password -s "Claude Code-credentials" -w | jq '.claudeAiOauth.scopes'
# Should output: ["user:inference", "user:profile"]- Usage API endpoint discovered via codelynx.dev
- OAuth client ID and refresh flow reverse-engineered from the Claude Code binary
- Context window calculation inspired by Richard-Weiss/ffadccca6238