# claude-accounts.zsh # Run Claude Code as any of the shared accounts, with telemetry wired up, no secrets on disk. # # c # default account (whatever `claude` is logged in as) # c /JACK # run as JACK # c /JACK2 # run as JACK2 # cx /ROB # run as ROB, with --dangerously-skip-permissions # claude-accounts # show reachable accounts and telemetry status # # Install: # 1. Save this file, e.g. as ~/claude-accounts.zsh # 2. Add to ~/.zshrc: # [ -r "$HOME/claude-accounts.zsh" ] && source "$HOME/claude-accounts.zsh" # 3. Restart your shell (or: source ~/.zshrc) # # Requirements: # - zsh (uses zsh associative arrays and ${(P)} indirection) # - Claude Code on PATH https://claude.com/claude-code # - gcloud, logged in: gcloud auth login # with roles/secretmanager.secretAccessor on the project below. # # THIS FILE CONTAINS NO SECRETS. Both the per-account auth tokens and the # telemetry bearer token are fetched from Google Secret Manager on first use, # memoised in a shell variable for that shell session only, and passed to a # single `claude` process. They are never written to disk, never exported into # your wider shell environment, and never enter shell history. typeset -g CLAUDE_ACCOUNTS_VERSION=1 if [[ -z "$ZSH_VERSION" ]]; then printf '%s\n' "claude-accounts: requires zsh (macOS default shell); not loaded." >&2 return 1 2>/dev/null || exit 1 fi # ---------------------------------------------------------------- configuration # Google Cloud project holding the secrets. Override before sourcing if needed. : ${CLAUDE_ACCOUNTS_PROJECT:=wattstack-internal} # Token cache. Fetched tokens are kept in your macOS login keychain so gcloud # is needed roughly once a month, not once per shell. Set to 0 to always hit # Secret Manager; lower the TTL if you rotate tokens more often. : ${CLAUDE_ACCOUNTS_KEYCHAIN:=1} : ${CLAUDE_ACCOUNTS_TTL_DAYS:=30} # Telemetry. Set CLAUDE_ACCOUNTS_TELEMETRY=0 to opt out entirely. : ${CLAUDE_ACCOUNTS_TELEMETRY:=1} : ${CLAUDE_ACCOUNTS_OTEL_SECRET:=CLAUDE_OTEL_TOKEN} : ${CLAUDE_ACCOUNTS_OTEL_ENDPOINT:=https://internal-telemetry-uxvnahu4sq-ew.a.run.app} # --------------------------------------------------------------------- internals # --- keychain helpers ------------------------------------------------------- # Entries are stored as ":" so we can age them out. # Writes go through `security -i` (commands on stdin) rather than argv, so the # token never appears in `ps` output. [[ "$(uname -s)" == "Darwin" ]] || CLAUDE_ACCOUNTS_KEYCHAIN=0 _ca_kc_get() { security find-generic-password -a "$USER" -s "claude-accounts:$1" -w 2>/dev/null } _ca_kc_put() { print -r -- "add-generic-password -a \"$USER\" -s \"claude-accounts:$1\" -w \"$(date +%s):$2\" -U" \ | security -i >/dev/null 2>&1 } _ca_kc_del() { security delete-generic-password -a "$USER" -s "claude-accounts:$1" >/dev/null 2>&1 } # Fetch a secret's value. Looks in this shell, then the keychain, then Secret # Manager. If Secret Manager is unreachable but we hold an expired keychain # copy, that copy is used: a lapsed gcloud session must never block a launch. typeset -g _claude_stale_warned=0 _claude_token() { local secret="$1" cache="_claude_token_cache_${1}" v err entry ts stale now age if [[ -n "${(P)cache}" ]]; then print -r -- "${(P)cache}" return 0 fi now=$(date +%s) if [[ "$CLAUDE_ACCOUNTS_KEYCHAIN" == "1" ]]; then entry=$(_ca_kc_get "$secret") if [[ -n "$entry" && "$entry" == <->:* ]]; then ts="${entry%%:*}"; stale="${entry#*:}" age=$(( (now - ts) / 86400 )) if (( age < CLAUDE_ACCOUNTS_TTL_DAYS )); then typeset -g "$cache=$stale" print -r -- "$stale" return 0 fi fi fi if command -v gcloud >/dev/null 2>&1; then err=$(gcloud secrets versions access latest \ --secret="$secret" --project="$CLAUDE_ACCOUNTS_PROJECT" 2>&1 >/dev/null) v=$(gcloud secrets versions access latest \ --secret="$secret" --project="$CLAUDE_ACCOUNTS_PROJECT" 2>/dev/null) else err="gcloud is not installed or not on PATH" fi if [[ -n "$v" ]]; then [[ "$CLAUDE_ACCOUNTS_KEYCHAIN" == "1" ]] && _ca_kc_put "$secret" "$v" typeset -g "$cache=$v" print -r -- "$v" return 0 fi # Secret Manager failed. Fall back to an expired keychain copy if we have one. if [[ -n "$stale" ]]; then if (( ! _claude_stale_warned )); then print -u2 "claude-accounts: using cached credentials (${age}d old); Secret Manager unreachable." print -u2 " Refresh when convenient: gcloud auth login && claude-accounts-refresh" _claude_stale_warned=1 fi typeset -g "$cache=$stale" print -r -- "$stale" return 0 fi print -u2 "claude-accounts: could not read secret '$secret' from project '$CLAUDE_ACCOUNTS_PROJECT'." case "$err" in *"not installed"*) print -u2 " Install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install" ;; *"was not found"*|*"NOT_FOUND"*) print -u2 " That secret does not exist. Run 'claude-accounts' to see what does." ;; *"PERMISSION_DENIED"*|*"does not have"*|*"Permission"*) print -u2 " Your gcloud account lacks access. Ask for the" print -u2 " roles/secretmanager.secretAccessor role on '$CLAUDE_ACCOUNTS_PROJECT'." ;; *"credentials"*|*"reauth"*|*"login"*|*"UNAUTHENTICATED"*) print -u2 " You are not logged in to gcloud. Run: gcloud auth login" ;; *) [[ -n "$err" ]] && print -u2 " ${err%%$'\n'*}" ;; esac return 1 } # Account names available in the project (secret names only, never values). _claude_known_accounts() { command -v gcloud >/dev/null 2>&1 || return 1 gcloud secrets list --project="$CLAUDE_ACCOUNTS_PROJECT" \ --format="value(name)" 2>/dev/null \ | sed -n 's/^CLAUDE_TOKEN_//p' | sort } # Build the telemetry environment for one launch. Telemetry is best-effort: # if the token is unreachable we warn once and still start Claude. typeset -ga _claude_otel_env=() typeset -g _claude_otel_warned=0 _claude_load_telemetry() { # Explicitly OFF rather than empty: ~/.claude/settings.json or an inherited # environment may still point Claude at an endpoint. Without a valid token # that produces a silent 401 on every export, so we disable it outright. _claude_otel_env=(CLAUDE_CODE_ENABLE_TELEMETRY=0) [[ "$CLAUDE_ACCOUNTS_TELEMETRY" == "1" ]] || return 0 local tok tok=$(_claude_token "$CLAUDE_ACCOUNTS_OTEL_SECRET" 2>/dev/null) || { if (( ! _claude_otel_warned )); then print -u2 "claude-accounts: telemetry off — cannot read '$CLAUDE_ACCOUNTS_OTEL_SECRET'." print -u2 " Claude will still run. Set CLAUDE_ACCOUNTS_TELEMETRY=0 to silence this." _claude_otel_warned=1 fi return 0 } _claude_otel_env=( CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf "OTEL_EXPORTER_OTLP_ENDPOINT=$CLAUDE_ACCOUNTS_OTEL_ENDPOINT" "OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer $tok" ) } _claude_runner() { if ! command -v claude >/dev/null 2>&1; then print -u2 "claude-accounts: 'claude' is not on your PATH." print -u2 " Install Claude Code: https://claude.com/claude-code" return 1 fi # caffeinate keeps macOS awake through long runs; skip where unavailable. local -a cmd if command -v caffeinate >/dev/null 2>&1; then cmd=(caffeinate -i claude) else cmd=(claude) fi if [[ "$1" == "--skip-perms" ]]; then cmd+=(--dangerously-skip-permissions) export CLAUDE_ACCOUNT_PERMS="⚠️ PERMS SKIPPED" shift else export CLAUDE_ACCOUNT_PERMS="✅ PERMS ON" fi _claude_load_telemetry # A leading UPPERCASE /ARG selects an account. Lowercase /args pass through, # so Claude's own slash commands (/clear, /compact) still work. if [[ "$1" == /[A-Z]* ]]; then local suffix="${1#/}" shift suffix="${suffix:u}" local tok tok=$(_claude_token "CLAUDE_TOKEN_${suffix}") || { local known; known=$(_claude_known_accounts) [[ -n "$known" ]] && print -u2 " Available accounts: ${known//$'\n'/ }" return 1 } export CLAUDE_ACTIVE_ACCOUNT="$suffix" env "${_claude_otel_env[@]}" ANTHROPIC_AUTH_TOKEN="$tok" "${cmd[@]}" "$@" else # No account argument: run as whatever Claude Code is natively logged in as. # ANTHROPIC_AUTH_TOKEN is cleared rather than merely left unset, so a token # inherited from the surrounding environment cannot silently take over the # session. Telemetry is still applied, exactly as in the account branches. export CLAUDE_ACTIVE_ACCOUNT="D" env -u ANTHROPIC_AUTH_TOKEN "${_claude_otel_env[@]}" "${cmd[@]}" "$@" fi } # ---------------------------------------------------------------------- commands unalias c cx 2>/dev/null # c [/ACCOUNT] [claude args...] c() { _claude_runner "$@" } # Same as c, but with --dangerously-skip-permissions. cx() { _claude_runner --skip-perms "$@" } # Show reachable accounts, their short forms, and telemetry status. claude-accounts() { print -r -- "Project: $CLAUDE_ACCOUNTS_PROJECT" if [[ "$CLAUDE_ACCOUNTS_KEYCHAIN" == "1" ]]; then local e=$(_ca_kc_get "CLAUDE_TOKEN_$(_claude_known_accounts 2>/dev/null | head -1)") if [[ -n "$e" && "$e" == <->:* ]]; then print -r -- "Cache: keychain, $(( ( $(date +%s) - ${e%%:*} ) / 86400 ))d old (TTL ${CLAUDE_ACCOUNTS_TTL_DAYS}d)" else print -r -- "Cache: keychain, empty — run claude-accounts-refresh" fi else print -r -- "Cache: off (every launch hits Secret Manager)" fi if [[ "$CLAUDE_ACCOUNTS_TELEMETRY" != "1" ]]; then print -r -- "Telemetry: off (CLAUDE_ACCOUNTS_TELEMETRY=$CLAUDE_ACCOUNTS_TELEMETRY)" elif _claude_token "$CLAUDE_ACCOUNTS_OTEL_SECRET" >/dev/null 2>&1; then print -r -- "Telemetry: on -> $CLAUDE_ACCOUNTS_OTEL_ENDPOINT" else print -r -- "Telemetry: UNAVAILABLE (cannot read $CLAUDE_ACCOUNTS_OTEL_SECRET)" fi local known; known=$(_claude_known_accounts) if [[ -z "$known" ]]; then print -u2 "Could not list secrets. Check 'gcloud auth login' and your project access." return 1 fi print -r -- "Accounts (use as 'c /NAME' or 'cx /NAME'):" local acct while IFS= read -r acct; do [[ -n "$acct" ]] && print -r -- " /$acct" done <<< "$known" } # Pull every account token (and the telemetry token) into the keychain in one # go. Run this once after `gcloud auth login`; you then won't need gcloud again # until the cache expires. claude-accounts-refresh() { if ! command -v gcloud >/dev/null 2>&1; then print -u2 "claude-accounts: gcloud is not installed."; return 1 fi if ! gcloud auth print-access-token >/dev/null 2>&1; then print -u2 "claude-accounts: gcloud needs a login first. gcloud auth login"; return 1 fi local known acct n=0 known=$(_claude_known_accounts) || { print -u2 "claude-accounts: could not list secrets."; return 1 } local -a secrets=("$CLAUDE_ACCOUNTS_OTEL_SECRET") while IFS= read -r acct; do [[ -n "$acct" ]] && secrets+=("CLAUDE_TOKEN_${acct}"); done <<< "$known" for acct in "${secrets[@]}"; do unset "_claude_token_cache_${acct}" _ca_kc_del "$acct" if _claude_token "$acct" >/dev/null 2>&1; then print -r -- " cached $acct"; (( n++ )) else print -r -- " SKIPPED $acct (no access)" fi done print -r -- "claude-accounts: cached $n secret(s); good for ${CLAUDE_ACCOUNTS_TTL_DAYS} days." } # Remove every cached token from the keychain. claude-accounts-logout() { local known acct n=0 known=$(_claude_known_accounts 2>/dev/null) local -a secrets=("$CLAUDE_ACCOUNTS_OTEL_SECRET") while IFS= read -r acct; do [[ -n "$acct" ]] && secrets+=("CLAUDE_TOKEN_${acct}"); done <<< "$known" for acct in "${secrets[@]}"; do unset "_claude_token_cache_${acct}" _ca_kc_del "$acct" && (( n++ )) done print -r -- "claude-accounts: cleared cached credentials." } # ---------------------------------------------------------------- usage report # Show how much of each account's 5-hour and 7-day allowance is spent. # # Anthropic returns the quota state in response headers on any ordinary API # call (anthropic-ratelimit-unified-*), so this sends the smallest possible # request — one token of Haiku — per account and reads the headers off it. # # The documented-looking alternative, GET /api/oauth/usage, needs the # user:profile scope. The tokens minted for programmatic use carry inference # scope only and get a 403 there, which is why this reads headers instead. # # Each check is itself a billable request, but a negligible one: it moves the # 5-hour figure by well under a tenth of a percent. claude-usage() { local ver accounts acct tok hdr now typeset -g _ca_best="" _ca_best_u="" ver=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) : ${ver:=2.1.272} accounts=$(_claude_known_accounts) || { print -u2 "claude-usage: could not list accounts."; return 1 } now=$(date +%s) printf '%-9s %-17s %-17s %-11s %s\n' "ACCOUNT" "5-HOUR" "7-DAY" "5H RESET" "7D RESET" printf '%-9s %-17s %-17s %-11s %s\n' "───────" "──────" "─────" "────────" "────────" while IFS= read -r acct; do [[ -n "$acct" ]] || continue tok=$(_claude_token "CLAUDE_TOKEN_${acct}" 2>/dev/null) || { printf '%-9s %s\n' "$acct" "no token available"; continue } hdr=$(curl -sS -D - -o /dev/null --max-time 20 -X POST \ -H "Authorization: Bearer $tok" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "User-Agent: claude-cli/${ver} (external, cli)" \ -H "content-type: application/json" \ -H "anthropic-version: 2023-06-01" \ -d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \ https://api.anthropic.com/v1/messages 2>/dev/null) _ca_usage_row "$acct" "$hdr" "$now" # Track the least-used 7-day window, so the report can end with the # actionable bit rather than leaving you to scan the column. local u7=$(_ca_hdr anthropic-ratelimit-unified-7d-utilization "$hdr") if [[ -n "$u7" ]] && { [[ -z "$_ca_best" ]] || (( u7 < _ca_best_u )) }; then _ca_best="$acct"; _ca_best_u="$u7" fi done <<< "$accounts" if [[ -n "$_ca_best" ]]; then print printf 'Most headroom: %s (%.0f%% of its 7-day window used)\n' "$_ca_best" $(( _ca_best_u * 100 )) fi unset _ca_best _ca_best_u } # Pull one header value out of a raw response-header blob. _ca_hdr() { print -r -- "$2" | grep -i "^$1:" | sed 's/^[^:]*: *//' | tr -d '\r' | tail -1 } # Turn an absolute epoch into "in 2h14m", or an em dash if it has passed. _ca_until() { local t="$1" now="$2" d [[ -n "$t" ]] && (( t > now )) || { print -r -- "—"; return } d=$(( t - now )) if (( d >= 86400 )); then print -r -- "in $(( d / 86400 ))d$(( (d % 86400) / 3600 ))h" else print -r -- "in $(( d / 3600 ))h$(( (d % 3600) / 60 ))m"; fi } # Ten-cell bar for a 0..1 utilisation figure. A non-zero value below one cell # still shows a sliver, so "barely used" reads differently from "unused". _ca_bar() { local v=$(( ${1:-0} * 10 )) i out="" for i in {1..10}; do if (( i <= v )); then out+="█" elif (( i - 1 < v )); then out+="▌" else out+="·"; fi done print -r -- "$out" } # Render one account's row. A 429 still carries the quota headers, so an # exhausted account reports its numbers rather than just an error. _ca_usage_row() { local acct="$1" hdr="$2" now="$3" code h5 h7 r5 r7 ustate note code=$(print -r -- "$hdr" | awk 'NR==1{print $2}') h5=$(_ca_hdr anthropic-ratelimit-unified-5h-utilization "$hdr") h7=$(_ca_hdr anthropic-ratelimit-unified-7d-utilization "$hdr") r5=$(_ca_hdr anthropic-ratelimit-unified-5h-reset "$hdr") r7=$(_ca_hdr anthropic-ratelimit-unified-7d-reset "$hdr") ustate=$(_ca_hdr anthropic-ratelimit-unified-status "$hdr") if [[ -z "$h5" && -z "$h7" ]]; then case "$code" in 401|403) note="token rejected (HTTP $code) — re-mint it" ;; 429) note="rate limited, no figures returned" ;; "") note="no response (timeout)" ;; *) note="no reading (HTTP $code)" ;; esac printf '%-9s %s\n' "$acct" "$note" return fi [[ "$ustate" == "allowed" || -z "$ustate" ]] && note="" || note=" ($ustate)" printf '%-9s %-17s %-17s %-11s %s%s\n' \ "$acct" \ "$(printf '%3.0f%% %s' $(( ${h5:-0} * 100 )) "$(_ca_bar $h5)")" \ "$(printf '%3.0f%% %s' $(( ${h7:-0} * 100 )) "$(_ca_bar $h7)")" \ "$(_ca_until "$r5" "$now")" \ "$(_ca_until "$r7" "$now")" "$note" }