Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## Unreleased

**New: account identity for multi-account credential overlays.** One
`~/.claude` used to imply one account; deva-style runners broke that by
bind-mounting a different `.credentials.json` per container over the same
shared config home. The "account-scoped" caches (`usage.cache`,
`profile.cache`, `usage.jsonl`) then bled across accounts: B rendered A's
5h/7d bars whenever A fetched last, and the profile stuck to whichever
account fetched first for 24h. The credentials file itself carries no
stable identity (tokens rotate), so the runner must say who the session
is: `STATUSLINE_ACCOUNT` (explicit) or `DEVA_AUTH_TAG` (set by deva from
`--auth-with`). When present, the user segment shows an `@tag` chip
(`[MAX|@work]`, tag beats the profile display name — two accounts can
carry the same human name) and account state moves to
`accounts/<tag>/` under the shared statusline dir: same-account sessions
still share one fetch, different accounts stop clobbering each other.
`auth-default` (single-account) changes nothing; an explicit
`CLAUDE_DATA_DIR`/`CLAUDE_CACHE_DIR` override is respected verbatim; tags
are sanitized to a filesystem-safe charset before touching a path. The
chip also renders for API-key/custom-endpoint sessions, which skip the
OAuth quota block — exactly the sessions only a tag can tell apart.

## v0.18.0 — 2026-07-15 — freeze-safe cache expiry deadline, 7d hybrid reset

**Fix: the 7d pressure suffix no longer decays in a frozen frame.** The
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
| `CLAUDE_7D_WORKDAYS` | unset | Skip weekends in the 7d pace deadline — quota you won't spend Sat/Sun no longer counts against runway (opt-in; the limit itself stays calendar-based) |
| `CLAUDE_DATA_DIR` | `~/.claude/statusline` | Account-scoped cache + usage log location |
| `CLAUDE_CACHE_DIR` | `$CLAUDE_DATA_DIR/sessions` | Per-session cache-health state |
| `STATUSLINE_ACCOUNT` | unset | Account label for multi-account setups: renders an `@label` chip and moves account caches to `accounts/<label>/` so concurrent accounts stop sharing one quota cache |
| `DEVA_AUTH_TAG` | unset | Same as above, set automatically by [deva](https://github.com/thevibeworks/deva) from `--auth-with` (`auth-file-<stem>` -> `@<stem>`); `auth-default` means single-account and is ignored |
| `DEBUG_LOG` | `~/.claude/statusline/logs/statusline.log` | Debug log path |
| `DEBUG_LOG_MAX_BYTES` | `1048576` | Debug log size cap before rotation |
| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | `32000` | Output token reserve |
Expand Down
61 changes: 54 additions & 7 deletions statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,34 @@ else
CLAUDE_ACCOUNT_DIR="$CLAUDE_DATA_DIR"
fi

# Account scoping. One ~/.claude used to imply one account; credential
# overlays broke that — deva-style runners bind-mount a different
# .credentials.json per container over the SAME shared ~/.claude, so the
# "account-scoped" caches above would bleed across accounts (B renders A's
# quota whenever A fetched last; profile.cache sticks to the first account
# for 24h). The credentials file itself has no stable identity (tokens
# rotate), so the runner must say which account this session is:
# STATUSLINE_ACCOUNT explicit label, wins always
# DEVA_AUTH_TAG from deva >= 0.18 (auth-file-<stem>, api-key-<n>, env);
# auth-default means single-account -> no scoping
# When set, account state moves to accounts/<tag>/ under the shared dir:
# same-account sessions still share one fetch, different accounts stop
# clobbering each other. An explicit CLAUDE_DATA_DIR/CLAUDE_CACHE_DIR
# override is the caller's layout — respected verbatim, no rescoping.
ACCOUNT_TAG=""
if [ -n "${STATUSLINE_ACCOUNT:-}" ]; then
ACCOUNT_TAG="$STATUSLINE_ACCOUNT"
elif [ -n "${DEVA_AUTH_TAG:-}" ] && [ "$DEVA_AUTH_TAG" != "auth-default" ]; then
ACCOUNT_TAG="${DEVA_AUTH_TAG#auth-file-}"
fi
# Env-derived path component: filesystem-safe charset, bounded length, no
# dot-leading names (blocks "..", hidden dirs).
ACCOUNT_TAG=$(printf '%s' "$ACCOUNT_TAG" | tr -cd 'A-Za-z0-9._-' | cut -c1-24)
case "$ACCOUNT_TAG" in .*) ACCOUNT_TAG="" ;; esac
if [ -n "$ACCOUNT_TAG" ] && [ -z "$CLAUDE_DATA_DIR_OVERRIDDEN" ]; then
CLAUDE_ACCOUNT_DIR="$CLAUDE_ACCOUNT_DIR/accounts/$ACCOUNT_TAG"
fi

# One-time graceful migration: older versions wrote account-scoped caches
# and the usage log under $SCRIPT_DIR (and per-session data under
# $SCRIPT_DIR/sessions). If that legacy layout exists and the new shared
Expand All @@ -115,6 +143,9 @@ migrate_legacy_state() {
# Skip when caller pinned custom dirs or legacy == new (no-op).
[ -n "${CLAUDE_DATA_DIR_OVERRIDDEN:-}" ] && return 0
[ "$legacy_data" = "$CLAUDE_ACCOUNT_DIR" ] && return 0
# Legacy state predates account scoping and belongs to the default
# account — never migrate it into whichever tagged account runs first.
[ -n "$ACCOUNT_TAG" ] && return 0

local moved=false
if [ ! -d "$CLAUDE_ACCOUNT_DIR" ]; then
Expand Down Expand Up @@ -2269,13 +2300,22 @@ build_user_info() {
local tier="$1"
local name=""

local profile_cache="${CLAUDE_ACCOUNT_DIR:-$CLAUDE_CACHE_DIR}/profile.cache"
if [ -f "$profile_cache" ]; then
name=$(jq -r '.account.display_name // empty' "$profile_cache" 2>/dev/null)
fi

if [ "${#name}" -gt 8 ]; then
name="${name:0:7}."
# Runner-provided account tag beats the profile display name: two
# accounts can carry the same human name, the tag (@work / @self)
# is what tells concurrent sessions apart. Slightly wider truncation
# than names — tags like api-key-1a2b are identity, not prose.
if [ -n "${ACCOUNT_TAG:-}" ]; then
name="$ACCOUNT_TAG"
[ "${#name}" -gt 12 ] && name="${name:0:11}."
name="@$name"
else
local profile_cache="${CLAUDE_ACCOUNT_DIR:-$CLAUDE_CACHE_DIR}/profile.cache"
if [ -f "$profile_cache" ]; then
name=$(jq -r '.account.display_name // empty' "$profile_cache" 2>/dev/null)
fi
if [ "${#name}" -gt 8 ]; then
name="${name:0:7}."
fi
fi

# Tier is identity, not status — neutral white-weight so it never reads as
Expand Down Expand Up @@ -2889,6 +2929,13 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
user_component=$(build_user_info "$user_tier")
fi

# API-key / custom-endpoint sessions skip the OAuth block above, but an
# account tag is exactly what tells such sessions apart — chip from the
# tag alone, no tier.
if [ -z "$user_component" ] && [ -n "$ACCOUNT_TAG" ]; then
user_component=$(build_user_info "")
fi

right_parts=""
IFS=',' read -ra order_array <<<"$stat_order"
for item in "${order_array[@]}"; do
Expand Down
5 changes: 5 additions & 0 deletions t/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export STATUSLINE_TESTING
STATUSLINE_NO_FETCH=1
export STATUSLINE_NO_FETCH

# Account identity must come from each test, not the host: deva exports
# DEVA_AUTH_TAG inside its containers, which would otherwise add an account
# chip (and rescope cache dirs) in every integration run.
unset DEVA_AUTH_TAG STATUSLINE_ACCOUNT ACCOUNT_TAG

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Color constants (needed by functions)
Expand Down
106 changes: 106 additions & 0 deletions t/statusline.bats
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,37 @@ setup() {
rm -rf "$tmpdir"
}

@test "build_user_info: account tag beats profile display name" {
tmpdir=$(mktemp -d)
CLAUDE_CACHE_DIR="$tmpdir"
echo '{"account":{"display_name":"examplename"}}' > "$tmpdir/profile.cache"
ACCOUNT_TAG="work"
result=$(build_user_info "MAX")
plain=$(strip_ansi "$result")
[ "$plain" = "[MAX|@work]" ]
rm -rf "$tmpdir"
}

@test "build_user_info: tag-only chip when no tier" {
tmpdir=$(mktemp -d)
CLAUDE_CACHE_DIR="$tmpdir"
ACCOUNT_TAG="work"
result=$(build_user_info "")
plain=$(strip_ansi "$result")
[ "$plain" = "[@work]" ]
rm -rf "$tmpdir"
}

@test "build_user_info: long tag truncated at 12" {
tmpdir=$(mktemp -d)
CLAUDE_CACHE_DIR="$tmpdir"
ACCOUNT_TAG="api-key-1a2b3c4d"
result=$(build_user_info "")
plain=$(strip_ansi "$result")
[ "$plain" = "[@api-key-1a2.]" ]
rm -rf "$tmpdir"
}

# --- format_money_minor currencies ---

@test "format_money_minor: EUR uses euro symbol" {
Expand Down Expand Up @@ -2258,3 +2289,78 @@ JSON
| bash "$SCRIPT_DIR/statusline.sh" --test)
[[ "$(strip_ansi "$out")" != *'$0'* ]]
}

# --- account scoping (multi-account credential overlays) --------------------

@test "integration: DEVA_AUTH_TAG renders account chip" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude"
out=$(echo '{"model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" DEVA_AUTH_TAG="auth-file-work" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" == *"[@work]"* ]]
rm -rf "$tmpdir"
}

@test "integration: auth-default tag renders no chip" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude"
out=$(echo '{"model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" DEVA_AUTH_TAG="auth-default" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" != *"@"* ]]
rm -rf "$tmpdir"
}

@test "integration: STATUSLINE_ACCOUNT beats DEVA_AUTH_TAG" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude"
out=$(echo '{"model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" STATUSLINE_ACCOUNT="self" DEVA_AUTH_TAG="auth-file-work" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" == *"[@self]"* ]]
[[ "$plain" != *"@work"* ]]
rm -rf "$tmpdir"
}

@test "integration: hostile tag is neutralized, no chip, no scoping" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude"
out=$(echo '{"model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" DEVA_AUTH_TAG="../../evil" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" != *"@"* ]]
[ ! -e "$tmpdir/.claude/statusline/accounts" ]
rm -rf "$tmpdir"
}

@test "integration: tagged session reads quota from its scoped cache, not the shared one" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude/statusline/accounts/work"
printf '{"claudeAiOauth":{"accessToken":"tok"}}' > "$tmpdir/.claude/.credentials.json"
now=$(date +%s)
# Decoy in the legacy shared location: a fresh cache for the OTHER account.
printf '{"five_hour":{"utilization":77},"seven_day":{"utilization":77},"fetched_at":%s}' "$now" \
> "$tmpdir/.claude/statusline/usage.cache"
printf '{"five_hour":{"utilization":42},"seven_day":{"utilization":41},"fetched_at":%s}' "$now" \
> "$tmpdir/.claude/statusline/accounts/work/usage.cache"
out=$(echo '{"session_id":"acct-test","model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" DEVA_AUTH_TAG="auth-file-work" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" == *"5h[42%]"* ]]
[[ "$plain" != *"77%"* ]]
rm -rf "$tmpdir"
}

@test "integration: untagged session still reads the shared cache (back-compat)" {
tmpdir=$(mktemp -d)
mkdir -p "$tmpdir/.claude/statusline"
printf '{"claudeAiOauth":{"accessToken":"tok"}}' > "$tmpdir/.claude/.credentials.json"
printf '{"five_hour":{"utilization":33},"seven_day":{"utilization":31},"fetched_at":%s}' "$(date +%s)" \
> "$tmpdir/.claude/statusline/usage.cache"
out=$(echo '{"session_id":"acct-test2","model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"version":"2.1.174","cost":{"total_cost_usd":0}}' \
| HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh" --test)
plain=$(strip_ansi "$out")
[[ "$plain" == *"5h[33%]"* ]]
rm -rf "$tmpdir"
}