From f8c290224314cc01ded8a1b071148b05a373028c Mon Sep 17 00:00:00 2001 From: Joel Rodriguez Date: Sat, 18 Apr 2026 16:34:21 -0400 Subject: [PATCH] macOS compatibility: Keychain credential fallback + BSD-portable commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues prevented the statusline from working correctly on macOS: 1. **Credentials are in Keychain on newer Claude Code installs, not on disk.** `~/.claude/.credentials.json` is only created by older Claude Code versions. Current Darwin builds store the subscription OAuth token in macOS Keychain under service name `Claude Code-credentials` instead. With no fallback, `refresh_usage_api` returned 1 on every invocation and the cache was never populated — the session/weekly usage sections silently never rendered. Added a `read_oauth_token` helper that tries the legacy file path first, then falls back to `security find-generic-password` on Darwin. 2. **`flock` is not installed on macOS by default.** It's a GNU/Linux util from util-linux and isn't in the default Darwin toolchain. The `( flock -n 9 || exit 0; refresh_usage_api ) 9>"$LOCK_FILE"` line errored out on Mac and the refresh was skipped for a subtly different reason than #1. Added a `command -v flock` check; when absent, call `refresh_usage_api` directly. The `cache_age_sec` gate above already throttles refresh to one per `REFRESH_INTERVAL`, so the worst case for a single-user system is 1–2 duplicate API calls per interval. 3. **`sed 's/A\|B/C/'` alternation isn't portable.** BSD sed (macOS) treats `\|` as literal characters in basic regex; only GNU sed accepts it as alternation. The stale-indicator substitution silently became a no-op on Mac, so the ⚠ never replaced the color dot. Switched to `sed -E 's/(A|B|C)/.../ '` which works on both. 4. **Test-suite portability.** Two remaining macOS-compat quirks in test_statusline.sh: - `touch -d '30 minutes ago'` only works with GNU coreutils; BSD touch requires `-t [[CC]YY]MMDDhhmm[.SS]`. Added a `touch_minutes_ago` helper that tries GNU `date -d` first, falls back to BSD `date -v`. - `wc -l` right-pads output with whitespace on BSD but not on GNU; `count_char` returned `" 3"` instead of `"3"` on Mac, failing two make_bar assertions. Piped through `tr -d ' '`. Before: 53 passed / 3 failed on macOS. After: 56 passed / 0 failed on macOS. (Linux behavior unchanged — the fallbacks only activate when GNU tools are absent or the credentials file is missing.) Verified on macOS 25.3 (Darwin), bash 3.2 + jq 1.7.1. Not tested on Linux in this PR — but none of the changes alter the happy path where `$CREDENTIALS_FILE` exists, `flock` is installed, and GNU tools are available. Windows support (Credential Manager) is out of scope for this PR; would be a separate branch once someone with a Windows environment can test it. Co-Authored-By: Claude Opus 4.7 (1M context) --- statusline.sh | 29 +++++++++++++++++++++++++---- test_statusline.sh | 18 +++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) 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"