diff --git a/statusline.sh b/statusline.sh index af834ff..9de1f93 100644 --- a/statusline.sh +++ b/statusline.sh @@ -146,10 +146,23 @@ fi [ "${#BRANCH}" -gt 30 ] && BRANCH="${BRANCH:0:27}..." # ── Refresh usage via Anthropic OAuth API ──────────────────────────────────── +# Reads the subscription OAuth token from either the legacy credentials file +# (Linux / older Claude Code installs) or the macOS Keychain (current Darwin +# installs store the token there instead of on disk). +read_oauth_token() { + if [ -f "$CREDENTIALS_FILE" ]; then + jq -r '.claudeAiOauth.accessToken // empty' "$CREDENTIALS_FILE" 2>/dev/null + return + fi + if [ "$(uname)" = "Darwin" ] && command -v security >/dev/null 2>&1; then + security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w 2>/dev/null \ + | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null + fi +} + refresh_usage_api() { - [ ! -f "$CREDENTIALS_FILE" ] && return 1 local token - token=$(jq -r '.claudeAiOauth.accessToken // empty' "$CREDENTIALS_FILE" 2>/dev/null) + token=$(read_oauth_token) [ -z "$token" ] && return 1 local resp resp=$(curl -s --max-time 3 \ @@ -183,7 +196,15 @@ refresh_usage_api() { LOCK_FILE="/tmp/statusline-refresh.lock" if [ "$(cache_age_sec)" -gt "$REFRESH_INTERVAL" ]; then - ( flock -n 9 || exit 0; refresh_usage_api ) 9>"$LOCK_FILE" + if command -v flock >/dev/null 2>&1; then + ( flock -n 9 || exit 0; refresh_usage_api ) 9>"$LOCK_FILE" + else + # macOS does not ship `flock` by default. The cache_age_sec gate above + # already throttles to one refresh per REFRESH_INTERVAL; concurrent + # renders racing through that gate would cause at most 1-2 duplicate + # API calls per interval for a single user, which is acceptable. + refresh_usage_api + fi fi # ── Read cached usage metrics ───────────────────────────────────────────────── @@ -275,7 +296,7 @@ if [ -f "$USAGE_FILE" ] && [ "$REFRESH_INTERVAL" -gt 0 ] 2>/dev/null; then [ "$(cache_age_sec)" -gt $(( REFRESH_INTERVAL * 3 )) ] && IS_STALE=1 fi [ "$IS_STALE" = 1 ] && [ -n "$BLOCK_DISPLAY" ] && \ - BLOCK_DISPLAY=$(echo "$BLOCK_DISPLAY" | sed 's/🟢\|🟡\|🔴/⚠/') + BLOCK_DISPLAY=$(echo "$BLOCK_DISPLAY" | sed -E 's/(🟢|🟡|🔴)/⚠/') # ── Assemble ────────────────────────────────────────────────────────────────── PARTS=() diff --git a/test_statusline.sh b/test_statusline.sh index 15bc008..09c1912 100755 --- a/test_statusline.sh +++ b/test_statusline.sh @@ -47,6 +47,16 @@ assert_not_contains() { fi } +# Portable "touch to N minutes ago" — `touch -d '30 minutes ago'` works on GNU +# coreutils but not on BSD (macOS), which only accepts a fixed timestamp format +# via `-t`. Compute the timestamp with whichever `date` dialect is available. +touch_minutes_ago() { + local minutes="$1" file="$2" ts + ts=$(date -d "$minutes minutes ago" +%Y%m%d%H%M.%S 2>/dev/null) \ + || ts=$(date -v "-${minutes}M" +%Y%m%d%H%M.%S) + touch -t "$ts" "$file" +} + # ── Unit tests: make_bar ────────────────────────────────────────────────────── echo "" echo "=== Unit tests: make_bar ===" @@ -61,7 +71,9 @@ run_make_bar() { count_char() { local char="$1" str="$2" - echo -n "$str" | grep -o "$char" | wc -l + # BSD `wc -l` right-pads the count with whitespace; GNU does not. Strip it + # so downstream string comparisons work on both. + echo -n "$str" | grep -o "$char" | wc -l | tr -d ' ' } # pct=0 → 6 empty blocks @@ -164,7 +176,7 @@ echo "" echo "-- Test 5: stale cache --" USAGE_STALE=$(mktemp /tmp/test-usage-stale-XXXX.json); TMPFILES+=("$USAGE_STALE") echo '{"timestamp":"2026-02-21T09:00:00+00:00","source":"api","metrics":{"session":{"percent_used":30.0,"percent_remaining":70.0,"resets_at":null}}}' > "$USAGE_STALE" -touch -d '30 minutes ago' "$USAGE_STALE" +touch_minutes_ago 30 "$USAGE_STALE" OUT=$(run_statusline '{"model":"claude-sonnet-4-6","context_window":{"used_percentage":0}}' \ USAGE_FILE="$USAGE_STALE" REFRESH_INTERVAL=300) assert_contains "stale cache shows ⚠" "⚠" "$OUT" @@ -181,7 +193,7 @@ assert_not_contains "fresh cache no ⚠" "⚠" "$OUT" # Test 7 — REFRESH_INTERVAL=0 never shows ⚠ echo "" echo "-- Test 7: REFRESH_INTERVAL=0 no stale indicator --" -touch -d '30 minutes ago' "$USAGE_STALE" +touch_minutes_ago 30 "$USAGE_STALE" OUT=$(run_statusline '{"model":"claude-sonnet-4-6","context_window":{"used_percentage":0}}' \ USAGE_FILE="$USAGE_STALE" REFRESH_INTERVAL=0) assert_not_contains "interval=0 no ⚠" "⚠" "$OUT"