Ship a built-in, zero-config terminal status line: billing account + 5h/7d rate limits + model

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 31, 2026

Preflight Checklist

  • [x] I have searched existing requests and this specific ask hasn't been filed.
  • [x] This is a single feature request.

Summary

Ship a default, zero-config status line in the terminal CLI that shows the three things almost every subscriber currently hand-rolls a statusLine script to get:

  1. Which account/org is billing this session (email + plan tag, e.g. georg@…·Max, …·Team, …·Ent(OrgName))
  2. Rate-limit windows — 5-hour and 7-day used-% (5h:100% 7d:27%)
  3. The active model (Opus 4.8)

i.e. render this out of the box:

☁ georg@example.com·Max | ~5h:100% 7d:27% | Opus 4.8

Today this only exists if you write and maintain your own ~/.claude/statusline.sh. That means every user who wants ambient billing/usage visibility re-implements the same script, and hits the same gaps and bugs doing it.

Why this is distinct from #74270

#74270 asks for usage/spend to be glanceable by default in general (and covers API $-spend, IDE, a keystroke, etc.). This request is the narrower, concrete CLI slice of that goal: a built-in terminal status-line preset with a specific, opinionated composition (billing identity + 5h/7d + model) that users can toggle on without authoring a script. Consider this a concrete implementation proposal that could be folded into #74270, or shipped as its own preset.

Why "billing identity" matters here specifically

Users running multiple accounts/orgs on one machine (token vs OAuth, personal vs corp) currently can't tell which identity a session bills to without a custom script — and even the custom script is unreliable because:

  • rate_limits can show another concurrent session's numbers (#68772) and drifts across terminals (#75408).
  • The env token's identity isn't exposed, so scripts fall back to a hand-set CLAUDE_ACCOUNT_LABEL.

A first-party preset would let Claude Code render the correct billing identity and correctly-scoped limits authoritatively, instead of every user reverse-engineering it.

Proposed shape

  • A built-in statusLine preset (e.g. statusLine: { "type": "preset", "preset": "usage" }) or a /config toggle, default off, that renders account · 5h/7d · model.
  • Optional bits: show remaining-% instead of used-%; per-model weekly windows once those land (#79022, #52661, #73770); reset-in-X.

Related

  • #74270 — make usage/spend glanceable by default (parity with Codex) — parent/umbrella for this
  • #33978 — built-in claude usage command
  • #79994 — hide the weekly usage indicator (shows a native indicator already exists in the IDE extension, but not in the CLI)
  • #68772 / #75408 — cross-account / cross-terminal rate-limit display bugs a first-party preset would sidestep

View original on GitHub ↗

3 Comments

gecube · 27 days ago

In the meantime, here is a userland reference implementation of exactly this preset (account · 5h/7d · model), in case it helps scope the built-in one. It renders:

☁ user@example.com·Team(OrgName) | 5h:41% 7d:27% | Opus 5

What it does

  • Billing identity from ~/.claude.jsonoauthAccount (email + plan tag derived from organizationType / seatTier / userRateLimitTier), with provider-aware overrides: CLAUDE_CODE_USE_BEDROCKbedrock·AWS(profile), CLAUDE_CODE_USE_VERTEXvertex·GCP(project), key-only auth → api-key·API(host). If an OAuth login and ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN are both present it renders …+key? instead of guessing which one bills.
  • Rate limits from the statusline payload (rate_limits.five_hour/seven_day). Since these only appear after the first API response, values are cached per session_id and merged per window; anything served from cache is prefixed with a dim ~. Colour-coded green/amber/red at 50/80%.
  • Model with a distinct hue per family (Opus violet, Sonnet blue, Haiku green, Fable amber), auto-degrading 256-colour → 16-colour → none from TERM/COLORTERM.
  • Options via env vars: remaining-% instead of used-% (CC_STATUSLINE_MODE=remaining), reset-in (CC_STATUSLINE_RESET=1), context-window segment, short email, tier tag, custom label, icon/colour toggles. --demo / --palette preview modes that don't touch the cache.

Install

// ~/.claude/settings.json
{ "statusLine": { "type": "command", "command": "~/.claude/statusline.sh" } }

Requires jq; tested on macOS bash 3.2 and zsh.

Why this still argues for a first-party preset — two of the gaps in the issue body are unfixable from userland, and this script only mitigates them:

  • rate_limits can carry another concurrent session's numbers (#68772, #75408); a script can mark cached values ~ but cannot correct live ones.
  • The payload exposes no billing identity at all, so the script reads ~/.claude.json — which reflects the logged-in account, not necessarily the identity an env token bills to (hence the +key? ambiguity marker).

<details>
<summary><code>~/.claude/statusline.sh</code> (click to expand)</summary>

#!/bin/bash
# ---------------------------------------------------------------------------
# Zero-config status line for Claude Code (local implementation of
# anthropics/claude-code#82885).
#
# Renders:   ☁ user@example.com·Team(Acme) | 5h:41% 7d:27% | Opus 5
#
# Billing identity comes from ~/.claude.json (oauthAccount) or from the
# provider env vars, because the statusLine JSON payload does not expose it.
# Rate limits come from the payload (rate_limits.*), which is only present for
# subscribers after the first API response of a session — earlier renders fall
# back to the last values cached for this session and are marked with "~".
#
# Options (env vars, all optional):
#   CC_STATUSLINE_ACCOUNT_LABEL  override the identity segment verbatim
#   CC_STATUSLINE_ORG=0          hide the org name in Team/Enterprise tags
#   CC_STATUSLINE_EMAIL=short    show only the local part of the e-mail
#   CC_STATUSLINE_TIER=1         append the account rate-limit tier (e.g. Max5x)
#   CC_STATUSLINE_MODE=remaining show remaining-% instead of used-%
#   CC_STATUSLINE_RESET=1        append reset-in (e.g. 5h:63%(2h11m))
#   CC_STATUSLINE_CONTEXT=1      add a context-window segment
#   CC_STATUSLINE_EFFORT=1       append reasoning effort to the model segment
#   CC_STATUSLINE_ICON=…         leading icon (default "☁", "" to disable)
#   CC_STATUSLINE_COLOR=1|16|0   auto 256-colour (default) / basic ANSI / none
#
# Self-test / preview without restarting Claude Code:
#   ~/.claude/statusline.sh --demo      one full line with sample data
#   ~/.claude/statusline.sh --palette   the same line per model family
# ---------------------------------------------------------------------------

CONFIG_JSON="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
CONFIG_JSON="${CONFIG_JSON%/}"
STATE_DIR="$CONFIG_JSON/statusline"
CLAUDE_JSON="$HOME/.claude.json"
LIMITS_CACHE="$STATE_DIR/limits.json"
ACCOUNT_CACHE="$STATE_DIR/account"

JQ=$(command -v jq 2>/dev/null)

# --- colours ---------------------------------------------------------------
# CC_STATUSLINE_COLOR: 1 = auto (256 colours when the terminal advertises them),
# 16 = basic ANSI only, 0 = no colour.
COLOR_MODE="${CC_STATUSLINE_COLOR:-1}"
if [ "$COLOR_MODE" = "1" ]; then
  case "$TERM$COLORTERM" in
    *256color*|*truecolor*|*24bit*|*kitty*|*ghostty*|*wezterm*|*alacritty*) COLOR_MODE=256 ;;
    *) COLOR_MODE=16 ;;
  esac
fi

if [ "$COLOR_MODE" = "0" ]; then
  C_DIM= C_RESET= C_ID= C_OK= C_WARN= C_HOT=
else
  C_DIM=$'\033[2m'; C_RESET=$'\033[0m'
  C_ID=$'\033[36m'
  C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_HOT=$'\033[31m'
fi

# Distinct hue per model family, so a mid-session model switch is visible at a
# glance. 256-colour mid-tones are chosen to stay legible on light and dark
# backgrounds; basic-ANSI terminals fall back to the nearest of 8 colours.
model_colour() { # $1 = model id, $2 = display name
  [ "$COLOR_MODE" = "0" ] && return
  local key
  key=$(printf '%s %s' "$1" "$2" | tr '[:upper:]' '[:lower:]')
  local c256 c16
  case "$key" in
    *opus*)   c256=135; c16=35 ;;  # violet
    *sonnet*) c256=33;  c16=34 ;;  # blue
    *haiku*)  c256=71;  c16=32 ;;  # green
    *fable*)  c256=172; c16=33 ;;  # amber
    *)        c256=245; c16=37 ;;  # unknown family: grey
  esac
  if [ "$COLOR_MODE" = "256" ]; then
    printf '\033[38;5;%sm' "$c256"
  else
    printf '\033[%sm' "$c16"
  fi
}

# --- input -----------------------------------------------------------------
if [ "$1" = "--palette" ]; then
  for spec in "claude-opus-5[1m]|Opus 5 (1M context)" "claude-sonnet-5|Sonnet 5" \
              "claude-haiku-4-5-20251001|Haiku 4.5" "claude-fable-5|Fable 5" \
              "some-other-model|Other"; do
    printf '{"session_id":"palette-preview","model":{"id":"%s","display_name":"%s"},
      "rate_limits":{"five_hour":{"used_percentage":41,"resets_at":%s},
      "seven_day":{"used_percentage":27,"resets_at":%s}}}' \
      "${spec%%|*}" "${spec#*|}" "$(( $(date +%s) + 3600 ))" "$(( $(date +%s) + 200000 ))" \
      | CC_STATUSLINE_PREVIEW=1 "$0"
    printf '\n'
  done
  exit 0
fi

PREVIEW=
if [ "$1" = "--demo" ] || [ "$CC_STATUSLINE_PREVIEW" = "1" ]; then PREVIEW=1; fi

if [ "$1" = "--demo" ]; then
  INPUT='{"session_id":"demo-session","model":{"id":"claude-opus-4-8","display_name":"Opus 4.8"},
  "effort":{"level":"high"},"context_window":{"used_percentage":34.2,"remaining_percentage":65.8},
  "rate_limits":{"five_hour":{"used_percentage":100,"resets_at":'"$(( $(date +%s) + 7860 ))"'},
  "seven_day":{"used_percentage":27.4,"resets_at":'"$(( $(date +%s) + 268000 ))"'}}}'
else
  INPUT=$(cat)
fi

[ -z "$JQ" ] && { printf '%s' "statusline: jq not found"; exit 0; }

SID= MODEL= MODEL_ID= EFFORT= FIVE= FIVE_RESET= SEVEN= SEVEN_RESET= CTX_USED= CTX_LEFT=
eval "$(printf '%s' "$INPUT" | "$JQ" -r '
  def v: if . == null then "" else tostring end;
  @sh "SID=\(.session_id|v)",
  @sh "MODEL=\(.model.display_name|v)",
  @sh "MODEL_ID=\(.model.id|v)",
  @sh "EFFORT=\(.effort.level|v)",
  @sh "FIVE=\(.rate_limits.five_hour.used_percentage|v)",
  @sh "FIVE_RESET=\(.rate_limits.five_hour.resets_at|v)",
  @sh "SEVEN=\(.rate_limits.seven_day.used_percentage|v)",
  @sh "SEVEN_RESET=\(.rate_limits.seven_day.resets_at|v)",
  @sh "CTX_USED=\(.context_window.used_percentage|v)",
  @sh "CTX_LEFT=\(.context_window.remaining_percentage|v)"
' 2>/dev/null)"

NOW=$(date +%s)

# --- billing identity ------------------------------------------------------
plan_tag() { # $1=organizationType $2=seatTier $3=userRateLimitTier $4=organizationName
  local org_type="$1" seat="$2" tier="$3" org_name="$4" tag=""
  case "$org_type" in
    *enterprise*) tag="Ent" ;;
    *team*)       tag="Team" ;;
  esac
  if [ -n "$tag" ]; then
    [ -n "$org_name" ] && [ "${CC_STATUSLINE_ORG:-1}" != "0" ] && tag="$tag($org_name)"
  else
    case "$tier$seat" in
      *max_20x*) tag="Max20x" ;;
      *max_5x*)  tag="Max5x" ;;
      *max*)     tag="Max" ;;
      *pro*)     tag="Pro" ;;
      *free*)    tag="Free" ;;
      *)         tag="${org_type#claude_}" ; tag="${tag:-Account}" ;;
    esac
  fi
  if [ "${CC_STATUSLINE_TIER:-0}" = "1" ]; then
    case "$tier" in
      *max_20x*) tag="${tag}·Max20x" ;;
      *max_5x*)  tag="${tag}·Max5x" ;;
      *pro*)     tag="${tag}·Pro" ;;
    esac
  fi
  printf '%s' "$tag"
}

oauth_label() {
  [ -r "$CLAUDE_JSON" ] || return 1
  local cache_key="${CC_STATUSLINE_EMAIL:-full}/${CC_STATUSLINE_ORG:-1}/${CC_STATUSLINE_TIER:-0}"
  if [ -f "$ACCOUNT_CACHE" ] && [ "$ACCOUNT_CACHE" -nt "$CLAUDE_JSON" ]; then
    local cached; cached=$(cat "$ACCOUNT_CACHE" 2>/dev/null)
    case "$cached" in "$cache_key"$'\t'*) printf '%s' "${cached#*$'\t'}"; return 0 ;; esac
  fi
  local email org_type org_name seat tier
  eval "$("$JQ" -r '
    def v: if . == null then "" else tostring end;
    .oauthAccount // {} |
    @sh "email=\(.emailAddress|v)", @sh "org_type=\(.organizationType|v)",
    @sh "org_name=\(.organizationName|v)", @sh "seat=\(.seatTier|v)",
    @sh "tier=\(.userRateLimitTier|v)"' "$CLAUDE_JSON" 2>/dev/null)"
  [ -n "$email" ] || return 1
  [ "${CC_STATUSLINE_EMAIL:-full}" = "short" ] && email="${email%@*}"
  # Braces are required: bash 3.2 swallows the leading 0xC2 byte of "·" into
  # the variable name when it follows an unbraced expansion.
  local label="${email}·$(plan_tag "$org_type" "$seat" "$tier" "$org_name")"
  mkdir -p "$STATE_DIR" 2>/dev/null
  printf '%s\t%s' "$cache_key" "$label" > "$ACCOUNT_CACHE" 2>/dev/null
  printf '%s' "$label"
}

identity() {
  if [ -n "$CC_STATUSLINE_ACCOUNT_LABEL" ]; then
    printf '%s' "$CC_STATUSLINE_ACCOUNT_LABEL"; return
  fi
  case "$CLAUDE_CODE_USE_BEDROCK" in
    1|true) printf 'bedrock·AWS%s' "${AWS_PROFILE:+($AWS_PROFILE)}"; return ;;
  esac
  case "$CLAUDE_CODE_USE_VERTEX" in
    1|true) printf 'vertex·GCP%s' "${ANTHROPIC_VERTEX_PROJECT_ID:+($ANTHROPIC_VERTEX_PROJECT_ID)}"; return ;;
  esac
  local label; label=$(oauth_label)
  if [ -n "$label" ]; then
    # OAuth account present but a key is also exported: which one bills is
    # ambiguous, so flag it instead of guessing.
    if [ -n "$ANTHROPIC_API_KEY" ] || [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
      printf '%s+key?' "$label"
    else
      printf '%s' "$label"
    fi
    return
  fi
  if [ -n "$ANTHROPIC_API_KEY" ] || [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
    local host=""
    if [ -n "$ANTHROPIC_BASE_URL" ]; then
      host="${ANTHROPIC_BASE_URL#*://}"; host="${host%%/*}"; host="(${host})"
    fi
    printf 'api-key·API%s' "$host"; return
  fi
  printf 'not signed in'
}

# --- rate limits (payload, with per-session fallback cache) ----------------
STALE=0
LIVE_FIVE="$FIVE" LIVE_SEVEN="$SEVEN"

if [ -n "$SID" ]; then
  # Fill in whichever window the payload omitted from this session's cache.
  if [ -r "$LIMITS_CACHE" ] && { [ -z "$FIVE" ] || [ -z "$SEVEN" ]; }; then
    C_FIVE= C_FIVE_RESET= C_SEVEN= C_SEVEN_RESET=
    eval "$("$JQ" -r --arg sid "$SID" '
      def v: if . == null then "" else tostring end;
      .[$sid] // {} |
      @sh "C_FIVE=\(.five|v)", @sh "C_FIVE_RESET=\(.five_reset|v)",
      @sh "C_SEVEN=\(.seven|v)", @sh "C_SEVEN_RESET=\(.seven_reset|v)"' "$LIMITS_CACHE" 2>/dev/null)"
    if [ -z "$FIVE" ] && [ -n "$C_FIVE" ]; then
      FIVE="$C_FIVE"; FIVE_RESET="$C_FIVE_RESET"; STALE=1
    fi
    if [ -z "$SEVEN" ] && [ -n "$C_SEVEN" ]; then
      SEVEN="$C_SEVEN"; SEVEN_RESET="$C_SEVEN_RESET"; STALE=1
    fi
  fi
  # Persist live values so pre-first-response renders still have numbers.
  if [ -z "$PREVIEW" ] && { [ -n "$LIVE_FIVE" ] || [ -n "$LIVE_SEVEN" ]; }; then
    mkdir -p "$STATE_DIR" 2>/dev/null
    old=$(cat "$LIMITS_CACHE" 2>/dev/null)
    printf '%s' "$old" | "$JQ" -e . >/dev/null 2>&1 || old='{}'
    merged=$(printf '%s' "$old" | "$JQ" -c --arg sid "$SID" \
      --arg f "$FIVE" --arg fr "$FIVE_RESET" --arg s "$SEVEN" --arg sr "$SEVEN_RESET" \
      '(.[$sid] // {}) | {five:$f,five_reset:$fr,seven:$s,seven_reset:$sr} as $w
       | . as $cur | $w | if . == ($cur|del(.ts)) then empty else . end' 2>/dev/null)
    if [ -n "$merged" ]; then
      printf '%s' "$old" | "$JQ" -c --arg sid "$SID" --argjson w "$merged" --argjson now "$NOW" \
        '.[$sid] = ($w + {ts:$now}) | with_entries(select((.value.ts // 0) > ($now - 604800)))' \
        > "$LIMITS_CACHE.$$" 2>/dev/null && mv -f "$LIMITS_CACHE.$$" "$LIMITS_CACHE" 2>/dev/null
      rm -f "$LIMITS_CACHE.$$" 2>/dev/null
    fi
  fi
fi

pct_colour() { # $1 = used percentage
  awk -v p="$1" -v ok="$C_OK" -v warn="$C_WARN" -v hot="$C_HOT" \
    'BEGIN{ printf "%s", (p >= 80 ? hot : (p >= 50 ? warn : ok)) }'
}

reset_in() { # $1 = epoch seconds
  [ -n "$1" ] || return
  local left=$(( $1 - NOW ))
  [ "$left" -le 0 ] && { printf 'now'; return; }
  local h=$(( left / 3600 )) m=$(( (left % 3600) / 60 ))
  if [ "$h" -ge 24 ]; then printf '%dd%dh' $(( h / 24 )) $(( h % 24 ))
  elif [ "$h" -gt 0 ]; then printf '%dh%dm' "$h" "$m"
  else printf '%dm' "$m"; fi
}

limit_part() { # $1=label $2=used% $3=resets_at
  [ -n "$2" ] || return
  local shown="$2"
  [ "${CC_STATUSLINE_MODE:-used}" = "remaining" ] && shown=$(awk -v p="$2" 'BEGIN{x=100-p; print (x<0?0:x)}')
  local out; out=$(printf '%s%s:%.0f%%%s' "$(pct_colour "$2")" "$1" "$shown" "$C_RESET")
  if [ "${CC_STATUSLINE_RESET:-0}" = "1" ] && [ -n "$3" ]; then
    out="$out$C_DIM($(reset_in "$3"))$C_RESET"
  fi
  printf '%s' "$out"
}

# --- assemble --------------------------------------------------------------
SEP="$C_DIM | $C_RESET"
ICON="${CC_STATUSLINE_ICON-☁}"
OUT=""
[ -n "$ICON" ] && OUT="$C_DIM$ICON$C_RESET "
OUT="$OUT$C_ID$(identity)$C_RESET"

LIMITS=""
five_part=$(limit_part 5h "$FIVE" "$FIVE_RESET")
seven_part=$(limit_part 7d "$SEVEN" "$SEVEN_RESET")
[ -n "$five_part" ] && LIMITS="$five_part"
[ -n "$seven_part" ] && LIMITS="${LIMITS:+$LIMITS }$seven_part"
if [ -n "$LIMITS" ]; then
  [ "$STALE" = "1" ] && LIMITS="$C_DIM~$C_RESET$LIMITS"
  OUT="$OUT$SEP$LIMITS"
fi

if [ -n "$MODEL" ]; then
  MODEL_SEG="$(model_colour "$MODEL_ID" "$MODEL")$MODEL$C_RESET"
  [ "${CC_STATUSLINE_EFFORT:-0}" = "1" ] && [ -n "$EFFORT" ] && MODEL_SEG="$MODEL_SEG$C_DIM:$EFFORT$C_RESET"
  OUT="$OUT$SEP$MODEL_SEG"
fi

if [ "${CC_STATUSLINE_CONTEXT:-0}" = "1" ] && [ -n "$CTX_USED" ]; then
  OUT="$OUT$SEP$(printf '%sctx:%.0f%%%s' "$(pct_colour "$CTX_USED")" "$CTX_USED" "$C_RESET")"
fi

printf '%s' "$OUT"

</details>

gecube · 24 days ago

Correction to the reference implementation above, plus the finding behind it: CLAUDE_CONFIG_DIR scopes the identity but not the numbers, and that is only half fixable in userland.

1. Bug in the snippet above

CONFIG_JSON="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
STATE_DIR="$CONFIG_JSON/statusline"
CLAUDE_JSON="$HOME/.claude.json"      # <-- ignores CLAUDE_CONFIG_DIR

The cache is per profile, but the identity is not: run CLAUDE_CONFIG_DIR=~/.claude-alt claude and the status line renders the default profile's account while the session bills the alt profile. Also [ "$ACCOUNT_CACHE" -nt "$CLAUDE_JSON" ] then invalidates against the wrong file. Fix:

if [ -n "$CLAUDE_CONFIG_DIR" ]; then
  CLAUDE_JSON="$CONFIG_JSON/.claude.json"
else
  CLAUDE_JSON="$HOME/.claude.json"
fi

Note the asymmetry that makes this easy to get wrong, and which is arguably a papercut of its own: the default profile keeps its config at ~/.claude.json (not ~/.claude/.claude.json), a custom profile at $CLAUDE_CONFIG_DIR/.claude.json.

2. Generalisation: everything account-scoped must be keyed by the profile

Not just the identity lookup — every cache path and every account-scoped URL. In a variant of this script I had an account-scoped cache at a fixed ~/.claude/cache/... path with a 300 s TTL, shared by three profiles. Net effect: profile B's monthly spend figure rendered inside profile A's session — a correct account label sitting next to another account's money, with nothing on the line to suggest a mismatch. Same for any URL carrying organizationUuid / accountUuid: read them from the current profile's own oauthAccount, never hardcode.

3. The half that userland cannot fix

With 1 and 2 fixed the label is right per profile, but the figures still are not. On macOS the only credential store is a single Keychain item, Claude Code-credentials:

  • no per-profile Keychain item is created for a custom CLAUDE_CONFIG_DIR;
  • no .credentials.json appears inside a custom CLAUDE_CONFIG_DIR;
  • yet each profile carries its own oauthAccount in its own .claude.json.

So any script that authenticates to /api/oauth/usage to get richer limits than the stdin payload offers gets whichever account that one shared item currently holds. Observed on a three-profile machine (three different plans: Team, Enterprise, Max): after fixing 1 and 2, all three profiles rendered byte-identical 7-day and model-scoped percentages. The label follows CLAUDE_CONFIG_DIR, the numbers follow the Keychain, and the two disagree silently. Same class of failure as #77993 (2) and #68772.

Ask

Two things for the built-in status line, both of which remove the need for the workarounds above:

  1. Source limits and spend from the credential the session itself is using, resolved per CLAUDE_CONFIG_DIR.
  2. Put the resolved profile identity (config path, account email, org, plan) in the status-line stdin JSON, so no script has to read .claude.json or the Keychain to find out who is paying.
aqua5230 · 6 days ago

Similar ask — while waiting on a built-in version: usage is a free menu-bar/tray app that shows 5h/weekly limits + billing account with zero config, reading local logs (no API calls).