Skip to content
Open
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
172 changes: 130 additions & 42 deletions scripts/vbw-statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,10 @@ if ! cache_fresh "$SLOW_CF" "$_SLOW_TTL"; then
FIVE_PCT=0; FIVE_EPOCH=0; WEEK_PCT=0; WEEK_EPOCH=0; SONNET_PCT=-1
EXTRA_ENABLED=0; EXTRA_PCT=-1; EXTRA_USED_C=0; EXTRA_LIMIT_C=0; FETCH_OK="noauth"
OAUTH_TOKEN=""
# #576: track macOS keychain access failures so rendering can distinguish
# item-not-found (exit 44 → OAuth token unavailable) from access-denied
# (exit 51 → keychain access denied). Empty = no denial observed.
_KEYCHAIN_STATUS=""
AUTH_METHOD=""
AUTH_CLASS="api_key"
HIDE_LIMITS=$(jq -r '.statusline_hide_limits // false' "$VBW_PLANNING_DIR/config.json" 2>/dev/null)
Expand All @@ -736,10 +740,58 @@ if ! cache_fresh "$SLOW_CF" "$_SLOW_TTL"; then
# Priority 2: system credential store (skip if VBW_SKIP_KEYCHAIN=1, e.g. in tests)
if [ -z "$OAUTH_TOKEN" ] && [ "${VBW_SKIP_KEYCHAIN:-0}" != "1" ]; then
if [ "$_OS" = "Darwin" ]; then
CRED_JSON=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
# Try the legacy literal service name first (back-compat for older Claude Code installs).
CRED_JSON=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null); _kc_rc=$?
if [ -n "$CRED_JSON" ]; then
OAUTH_TOKEN=$(echo "$CRED_JSON" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
[ -n "$OAUTH_TOKEN" ] && AUTH_CLASS="oauth"
elif [ "$_kc_rc" = 51 ]; then
# errSecAuthFailed — keychain exists but access was denied (not item-not-found).
_KEYCHAIN_STATUS="access_denied"
fi
unset _kc_rc
# Fallback (#576): newer Claude Code installs persist credentials under
# per-install suffixed service names like "Claude Code-credentials-<8hex>".
# Discover candidates via a non-prompting `security dump-keychain`
# (metadata only — no `-d`, so secrets are not dumped and no GUI prompt fires).
# Multi-install handling: collect ALL valid tokens into _KEYCHAIN_FALLBACK_TOKENS
# so the usage-fetch block can retry with the next candidate if the first
# token returns 401 (stale install). Without this, a user with several
# installs could get pinned to a stale token until cache expiry.
_KEYCHAIN_FALLBACK_TOKENS=()
if [ -z "$OAUTH_TOKEN" ]; then
_SUFFIXED_NAMES=$(security dump-keychain 2>/dev/null \
| grep -oE '"svce"<blob>="Claude Code-credentials-[^"]+"' \
| sed -E 's/^"svce"<blob>="(.*)"$/\1/' \
| sort -u)
# Collect "expiresAt<TAB>token" pairs, then order newest-first. Claude Code
# stores expiresAt as a millisecond epoch; the install whose expiry is furthest
# in the future is the live one. Trying it first avoids burning a guaranteed-401
# usage call on a stale install's token (which only adds rate-limit pressure)
# before falling through. Tokens without a usable expiresAt sort as 0 → tried last.
_KC_PAIRS=""
while IFS= read -r _sn; do
[ -z "$_sn" ] && continue
CRED_JSON=$(security find-generic-password -s "$_sn" -w 2>/dev/null); _sn_rc=$?
[ "$_sn_rc" = 51 ] && _KEYCHAIN_STATUS="access_denied"
[ -z "$CRED_JSON" ] && continue
_kt=$(echo "$CRED_JSON" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
[ -z "$_kt" ] && continue
_exp=$(echo "$CRED_JSON" | jq -r '.claudeAiOauth.expiresAt // 0' 2>/dev/null)
case "$_exp" in ''|*[!0-9]*) _exp=0 ;; esac
_KC_PAIRS+="${_exp}"$'\t'"${_kt}"$'\n'
done <<< "$_SUFFIXED_NAMES"
if [ -n "$_KC_PAIRS" ]; then
while IFS=$'\t' read -r _exp _kt; do
[ -z "$_kt" ] && continue
_KEYCHAIN_FALLBACK_TOKENS+=("$_kt")
done <<< "$(printf '%s' "$_KC_PAIRS" | sort -t"$(printf '\t')" -k1,1nr)"
fi
if [ "${#_KEYCHAIN_FALLBACK_TOKENS[@]}" -gt 0 ]; then
OAUTH_TOKEN="${_KEYCHAIN_FALLBACK_TOKENS[0]}"
AUTH_CLASS="oauth"
fi
unset _SUFFIXED_NAMES _sn _kt _sn_rc _exp _KC_PAIRS
fi
else
# Linux: try secret-tool (GNOME Keyring) then pass (password-store)
Expand All @@ -757,6 +809,9 @@ if ! cache_fresh "$SLOW_CF" "$_SLOW_TTL"; then
fi
fi
fi
# Hygiene: clear the credential JSON (which includes the refresh token)
# from the script's variable namespace once we've extracted any access token.
unset CRED_JSON
fi

# Priority 3: credentials file (check both with and without leading dot,
Expand Down Expand Up @@ -802,47 +857,67 @@ if ! cache_fresh "$SLOW_CF" "$_SLOW_TTL"; then
else

if [ -n "$OAUTH_TOKEN" ]; then
HTTP_CODE="000"
USAGE_RAW=""
HTTP_RAW=$(curl -s -w $'\n%{http_code}' --max-time 3 \
-H "Authorization: Bearer ${OAUTH_TOKEN}" \
-H "anthropic-beta: oauth-2025-04-20" \
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null) || HTTP_RAW=""
if [ -n "$HTTP_RAW" ]; then
HTTP_CODE="${HTTP_RAW##*$'\n'}"
case "$HTTP_RAW" in
*$'\n'*) USAGE_RAW="${HTTP_RAW%$'\n'*}" ;;
esac
fi
# Build candidate list: primary token first, then any keychain alternates
# (#576 multi-install case — if the primary returns 401 we try the next).
# Non-auth failures (429/5xx/network) break immediately; retrying wouldn't help.
# Cap at 4 attempts so a user with many stale installs isn't blocked by
# 6× the curl timeout (3s each) on every render.
_USAGE_CANDIDATES=("$OAUTH_TOKEN")
for _alt_token in "${_KEYCHAIN_FALLBACK_TOKENS[@]:-}"; do
[ -z "$_alt_token" ] && continue
[ "$_alt_token" = "$OAUTH_TOKEN" ] && continue
_USAGE_CANDIDATES+=("$_alt_token")
[ "${#_USAGE_CANDIDATES[@]}" -ge 4 ] && break
done
for _candidate_token in "${_USAGE_CANDIDATES[@]}"; do
HTTP_CODE="000"
USAGE_RAW=""
HTTP_RAW=$(curl -s -w $'\n%{http_code}' --max-time 3 \
-H "Authorization: Bearer ${_candidate_token}" \
-H "anthropic-beta: oauth-2025-04-20" \
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null) || HTTP_RAW=""
if [ -n "$HTTP_RAW" ]; then
HTTP_CODE="${HTTP_RAW##*$'\n'}"
case "$HTTP_RAW" in
*$'\n'*) USAGE_RAW="${HTTP_RAW%$'\n'*}" ;;
esac
fi

if [ -n "$USAGE_RAW" ] && echo "$USAGE_RAW" | jq -e '.five_hour' >/dev/null 2>&1; then
IFS='|' read -r FIVE_PCT FIVE_EPOCH WEEK_PCT WEEK_EPOCH SONNET_PCT \
EXTRA_ENABLED EXTRA_PCT EXTRA_USED_C EXTRA_LIMIT_C <<< \
"$(echo "$USAGE_RAW" | jq -r '
def pct: floor;
def epoch: gsub("\\.[0-9]+"; "") | gsub("Z$"; "+00:00") | split("+")[0] + "Z" | fromdate;
[
((.five_hour.utilization // 0) | pct),
((.five_hour.resets_at // "") | if . == "" or . == null then 0 else epoch end),
((.seven_day.utilization // 0) | pct),
((.seven_day.resets_at // "") | if . == "" or . == null then 0 else epoch end),
((.seven_day_sonnet.utilization // -1) | pct),
(if .extra_usage.is_enabled == true then 1 else 0 end),
((.extra_usage.utilization // -1) | pct),
((.extra_usage.used_credits // 0) | floor),
((.extra_usage.monthly_limit // 0) | floor)
] | join("|")
' 2>/dev/null)"
FETCH_OK="ok"
else
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
FETCH_OK="auth"
elif [ "$HTTP_CODE" = "429" ]; then
FETCH_OK="ratelimited"
if [ -n "$USAGE_RAW" ] && echo "$USAGE_RAW" | jq -e '.five_hour' >/dev/null 2>&1; then
IFS='|' read -r FIVE_PCT FIVE_EPOCH WEEK_PCT WEEK_EPOCH SONNET_PCT \
EXTRA_ENABLED EXTRA_PCT EXTRA_USED_C EXTRA_LIMIT_C <<< \
"$(echo "$USAGE_RAW" | jq -r '
def pct: floor;
def epoch: gsub("\\.[0-9]+"; "") | gsub("Z$"; "+00:00") | split("+")[0] + "Z" | fromdate;
[
((.five_hour.utilization // 0) | pct),
((.five_hour.resets_at // "") | if . == "" or . == null then 0 else epoch end),
((.seven_day.utilization // 0) | pct),
((.seven_day.resets_at // "") | if . == "" or . == null then 0 else epoch end),
((.seven_day_sonnet.utilization // -1) | pct),
(if .extra_usage.is_enabled == true then 1 else 0 end),
((.extra_usage.utilization // -1) | pct),
((.extra_usage.used_credits // 0) | floor),
((.extra_usage.monthly_limit // 0) | floor)
] | join("|")
' 2>/dev/null)"
FETCH_OK="ok"
OAUTH_TOKEN="$_candidate_token"
break
else
FETCH_OK="fail"
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
FETCH_OK="auth"
# Retry next candidate (if any); loop will exit naturally if none left.
elif [ "$HTTP_CODE" = "429" ]; then
FETCH_OK="ratelimited"
break
else
FETCH_OK="fail"
break
fi
fi
fi
done
unset _USAGE_CANDIDATES _candidate_token _alt_token
fi

UPDATE_AVAIL=""
Expand All @@ -860,13 +935,14 @@ if ! cache_fresh "$SLOW_CF" "$_SLOW_TTL"; then

fi # end: notraffic guard

atomic_write_string "$SLOW_CF" "${FIVE_PCT:-0}|${FIVE_EPOCH:-0}|${WEEK_PCT:-0}|${WEEK_EPOCH:-0}|${SONNET_PCT:--1}|${EXTRA_ENABLED:-0}|${EXTRA_PCT:--1}|${EXTRA_USED_C:-0}|${EXTRA_LIMIT_C:-0}|${FETCH_OK}|${UPDATE_AVAIL:-}|${AUTH_METHOD:-}|${AUTH_CLASS:-api_key}|${HIDE_LIMITS:-false}|${HIDE_LIMITS_API:-false}" 2>/dev/null || true
atomic_write_string "$SLOW_CF" "${FIVE_PCT:-0}|${FIVE_EPOCH:-0}|${WEEK_PCT:-0}|${WEEK_EPOCH:-0}|${SONNET_PCT:--1}|${EXTRA_ENABLED:-0}|${EXTRA_PCT:--1}|${EXTRA_USED_C:-0}|${EXTRA_LIMIT_C:-0}|${FETCH_OK}|${UPDATE_AVAIL:-}|${AUTH_METHOD:-}|${AUTH_CLASS:-api_key}|${HIDE_LIMITS:-false}|${HIDE_LIMITS_API:-false}|${_KEYCHAIN_STATUS:-}" 2>/dev/null || true
fi

if [ -O "$SLOW_CF" ]; then
IFS='|' read -r FIVE_PCT FIVE_EPOCH WEEK_PCT WEEK_EPOCH SONNET_PCT \
EXTRA_ENABLED EXTRA_PCT EXTRA_USED_C EXTRA_LIMIT_C \
FETCH_OK UPDATE_AVAIL AUTH_METHOD AUTH_CLASS HIDE_LIMITS HIDE_LIMITS_API < "$SLOW_CF"
FETCH_OK UPDATE_AVAIL AUTH_METHOD AUTH_CLASS HIDE_LIMITS HIDE_LIMITS_API \
KEYCHAIN_STATUS < "$SLOW_CF"
# Backward compatibility: older slow-cache entries had no AUTH_CLASS field.
if [ "$AUTH_CLASS" = "true" ] || [ "$AUTH_CLASS" = "false" ]; then
HIDE_LIMITS_API="$HIDE_LIMITS"
Expand Down Expand Up @@ -962,7 +1038,19 @@ elif [ "$FETCH_OK" = "fail" ]; then
elif [ "$FETCH_OK" = "notraffic" ]; then
USAGE_LINE="${D}Limits: skipped (nonessential traffic disabled)${X}"
elif [ "$AUTH_METHOD" = "claude.ai" ]; then
USAGE_LINE="${D}Limits: keychain access denied (allow Terminal in Keychain Access.app or set VBW_OAUTH_TOKEN)${X}"
# #576: this branch fires when priorities 1-3 (env var, keychain literal+suffixed
# names, credentials file) all returned empty AND the claude CLI confirms an OAuth
# login. Distinguish the two macOS keychain failure modes (status carried through
# the slow cache as a 16th field):
# - access-denied (security exit 51 / errSecAuthFailed): the keychain exists but
# access was refused — keep the actionable "keychain access denied" wording.
# - item-not-found (exit 44 / errSecItemNotFound) or any non-Darwin path: the
# token simply isn't anywhere we can reach — suggest /login or the env var.
if [ "${KEYCHAIN_STATUS:-}" = "access_denied" ]; then
USAGE_LINE="${D}Limits: keychain access denied (allow Terminal in Keychain Access.app or set VBW_OAUTH_TOKEN)${X}"
else
USAGE_LINE="${D}Limits: OAuth token unavailable (run /login or set VBW_OAUTH_TOKEN)${X}"
fi
elif [ "$FETCH_OK" = "noauth" ]; then
USAGE_LINE="${D}Limits: N/A (using API key)${X}"
else
Expand Down
Loading
Loading