diff --git a/backend/app/routes/clients.py b/backend/app/routes/clients.py index 9015fc1..d74d6a6 100644 --- a/backend/app/routes/clients.py +++ b/backend/app/routes/clients.py @@ -17,168 +17,3 @@ def statusline_script() -> PlainTextResponse: """Serve the Claude Code status line script so users can curl it directly.""" return PlainTextResponse(_STATUSLINE_PATH.read_text(), media_type="text/x-shellscript") - - -@router.get( - "/install.sh", - response_class=PlainTextResponse, - responses={200: {"description": "One-line installer for Claude Code"}}, -) -def install_script() -> PlainTextResponse: - """Installer that configures Claude Code to use this proxy and installs the status line.""" - script = r"""#!/usr/bin/env bash -set -euo pipefail - -usage() { - echo "Usage: curl -fsSL .../install.sh | bash -s -- " >&2 - echo "The installer prompts for the API key without echoing it." >&2 - echo "For automation, set CLAUDE_PROXY_KEY_FILE to a protected file containing the key." >&2 - exit 1 -} - -if [ "$#" -ne 1 ]; then - usage -fi - -BASE_URL="$1" -API_KEY="" -case "$BASE_URL" in - http://*|https://*) ;; - *) echo "Base URL must start with http:// or https://" >&2; exit 1 ;; -esac - -read_proxy_key() { - if [ -n "${CLAUDE_PROXY_KEY_FILE:-}" ]; then - if [ ! -f "$CLAUDE_PROXY_KEY_FILE" ] || [ ! -r "$CLAUDE_PROXY_KEY_FILE" ]; then - echo "CLAUDE_PROXY_KEY_FILE must name a readable regular file." >&2 - exit 1 - fi - IFS= read -r API_KEY < "$CLAUDE_PROXY_KEY_FILE" || [ -n "$API_KEY" ] - elif [ -t 2 ]; then - printf 'Paste the Claude Code Proxy API key (input hidden): ' > /dev/tty - if ! IFS= read -r -s API_KEY < /dev/tty; then - printf '\nUnable to read the API key from the terminal.\n' > /dev/tty - exit 1 - fi - printf '\n' > /dev/tty - else - echo "No interactive terminal is available." >&2 - echo "Set CLAUDE_PROXY_KEY_FILE to a protected file containing the key." >&2 - exit 1 - fi - if [ -z "$API_KEY" ]; then - echo "API key cannot be empty." >&2 - exit 1 - fi -} - -read_proxy_key - -SETTINGS="$HOME/.claude/settings.json" -STATUSLINE_DIR="$HOME/.claude/bin" -STATUSLINE="$STATUSLINE_DIR/claude-code-statusline.sh" - -umask 077 -mkdir -p "$HOME/.claude" "$STATUSLINE_DIR" - -SCRIPT_URL="${BASE_URL%/}/v1/clients/claude-code-statusline.sh" -echo "Downloading status line script..." -curl -fsSL "$SCRIPT_URL" -o "$STATUSLINE" -chmod +x "$STATUSLINE" - -# Keep unrelated settings and write atomically. The key is supplied via a -# protected file, never as a command-line argument or generated command text. -KEY_FILE="$(mktemp "$HOME/.claude/proxy-key.XXXXXX")" -TEMP_SETTINGS="" -cleanup() { - [ -z "${KEY_FILE:-}" ] || rm -f -- "$KEY_FILE" - [ -z "${TEMP_SETTINGS:-}" ] || rm -f -- "$TEMP_SETTINGS" -} -trap cleanup EXIT -printf '%s\n' "$API_KEY" > "$KEY_FILE" -unset API_KEY - -if command -v python3 >/dev/null 2>&1; then - CC_INSTALL_BASE_URL="$BASE_URL" \ - CC_INSTALL_KEY_FILE="$KEY_FILE" \ - CC_INSTALL_SETTINGS="$SETTINGS" \ - CC_INSTALL_STATUSLINE="$STATUSLINE" \ - python3 - <<'PY' -import json -import os -import shutil -import tempfile - -path = os.environ["CC_INSTALL_SETTINGS"] -with open(os.environ["CC_INSTALL_KEY_FILE"], encoding="utf-8") as handle: - api_key = handle.readline().rstrip("\r\n") -if not api_key: - raise SystemExit("API key cannot be empty") -if os.path.exists(path): - try: - with open(path, encoding="utf-8") as handle: - settings = json.load(handle) - except (OSError, json.JSONDecodeError) as exc: - raise SystemExit(f"Could not parse {path}; it was left unchanged: {exc}") - if not isinstance(settings, dict): - raise SystemExit(f"Could not update {path}: its top-level value is not an object") - shutil.copy2(path, path + ".bak") -else: - settings = {} -env = settings.get("env") -if env is None: - env = {} - settings["env"] = env -elif not isinstance(env, dict): - raise SystemExit(f"Could not update {path}: env is not an object") -env["ANTHROPIC_BASE_URL"] = os.environ["CC_INSTALL_BASE_URL"] -env["ANTHROPIC_AUTH_TOKEN"] = api_key -settings["statusLine"] = { - "type": "command", - "command": os.environ["CC_INSTALL_STATUSLINE"], - "refreshInterval": 1, -} -directory = os.path.dirname(path) or "." -fd, temporary = tempfile.mkstemp(prefix="settings.json.tmp.", dir=directory) -try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(settings, handle, indent=2) - handle.write("\n") - os.chmod(temporary, 0o600) - os.replace(temporary, path) -finally: - if os.path.exists(temporary): - os.unlink(temporary) -PY -elif command -v jq >/dev/null 2>&1; then - if [ -f "$SETTINGS" ]; then - jq empty "$SETTINGS" || { echo "Could not parse $SETTINGS; it was left unchanged." >&2; exit 1; } - cp -p "$SETTINGS" "$SETTINGS.bak" - else - printf '{}\n' > "$SETTINGS" - fi - TEMP_SETTINGS="$(mktemp "$HOME/.claude/settings.json.tmp.XXXXXX")" - jq --rawfile key "$KEY_FILE" --arg base "$BASE_URL" --arg statusline "$STATUSLINE" \ - '.env = ((.env // {}) + {ANTHROPIC_BASE_URL: $base, ANTHROPIC_AUTH_TOKEN: ($key | rtrimstr("\\n"))}) - | .statusLine = {type: "command", command: $statusline, refreshInterval: 1}' \ - "$SETTINGS" > "$TEMP_SETTINGS" - mv "$TEMP_SETTINGS" "$SETTINGS" - TEMP_SETTINGS="" - chmod 600 "$SETTINGS" -else - echo "python3 or jq is required to safely update $SETTINGS." >&2 - exit 1 -fi - -cleanup -trap - EXIT - -echo "" -echo "Claude Code configured!" -echo " Base URL: $BASE_URL" -echo " Status line: $STATUSLINE" -echo " Settings: $SETTINGS" -echo "" -echo "Run 'claude' to start using the proxy." -""" - return PlainTextResponse(script, media_type="text/x-shellscript") diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 1568ebc..c7f4cba 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -10,8 +10,8 @@ from sqlalchemy import func from sqlalchemy.orm import Session -from app.utils import rotation, security, usage -from app.utils.models.api import MeUsageResponse, PoolAccountStatus, PoolStatus, PoolWindow +from app.utils import security, usage +from app.utils.models.api import MeUsageResponse, PoolStatus, PoolWindow from app.utils.postgres import AccountDb, ApiKeyDb, UsageRecordDb, UserDb, get_db router = APIRouter(tags=["Me"], prefix="/me") @@ -82,19 +82,6 @@ def build_pool_status(db: Session, now: datetime | None = None) -> PoolStatus | account_count=len(active_accounts), five_hour=five_hour, weekly=weekly, - accounts=[ - PoolAccountStatus( - id=account.id, - label=account.label, - email=account.account_email, - priority=account.priority, - five_hour_used_pct=account.session_used_pct, - five_hour_reset_at=account.session_reset_at, - weekly_used_pct=account.weekly_used_pct, - weekly_reset_at=account.weekly_reset_at, - ) - for account in sorted(active_accounts, key=rotation.account_selection_key) - ], ) diff --git a/backend/app/utils/models/api/__init__.py b/backend/app/utils/models/api/__init__.py index 9967c0c..6fc06d7 100644 --- a/backend/app/utils/models/api/__init__.py +++ b/backend/app/utils/models/api/__init__.py @@ -26,7 +26,7 @@ ListAnthropicFallbacksResponse, UpdateAnthropicFallbackRequest, ) -from .me import MeUsageResponse, PoolAccountStatus, PoolStatus, PoolWindow +from .me import MeUsageResponse, PoolStatus, PoolWindow from .notifications import ( NotificationRule, NotificationSettingsResponse, @@ -106,7 +106,6 @@ "UpdateAccountRequest", "MeUsageResponse", "PoolWindow", - "PoolAccountStatus", "PoolStatus", "User", "BulkUserPriorityRequest", diff --git a/backend/app/utils/models/api/me.py b/backend/app/utils/models/api/me.py index 1af9ece..8aa4c2b 100644 --- a/backend/app/utils/models/api/me.py +++ b/backend/app/utils/models/api/me.py @@ -5,7 +5,6 @@ from datetime import datetime from typing import Optional -from uuid import UUID from pydantic import BaseModel @@ -20,26 +19,12 @@ class PoolWindow(BaseModel): next_reset_at: Optional[datetime] -class PoolAccountStatus(BaseModel): - """Per-account quota data included with the pool aggregate.""" - - id: UUID - label: str - email: Optional[str] - priority: int - five_hour_used_pct: Optional[float] - five_hour_reset_at: Optional[datetime] - weekly_used_pct: Optional[float] - weekly_reset_at: Optional[datetime] - - class PoolStatus(BaseModel): - """Average rate-limit headroom plus per-account details for the available pool.""" + """Average rate-limit headroom for the available pool.""" account_count: int five_hour: PoolWindow weekly: PoolWindow - accounts: list[PoolAccountStatus] class MeUsageResponse(BaseModel): diff --git a/backend/clients/claude-code-statusline.sh b/backend/clients/claude-code-statusline.sh index d33e876..6f3a889 100755 --- a/backend/clients/claude-code-statusline.sh +++ b/backend/clients/claude-code-statusline.sh @@ -28,6 +28,8 @@ set -uo pipefail # deliberately NOT -e: every segment degrades to empty rather # Claude Code pipes a session JSON object on stdin. Capture it (may be empty when run by hand). stdin_json="$(cat 2>/dev/null || true)" +# Cache files live in a shared temp dir and hold pool data fetched with this user's key; keep them private. +umask 077 cachedir="${TMPDIR:-/tmp}" now="$(date +%s)" @@ -97,7 +99,20 @@ if [ -n "$base" ] && [ -n "$token" ]; then usage_cache="${cachedir}/ccproxy-usage-$(id -u).json" umtime="$(stat -c %Y "$usage_cache" 2>/dev/null || stat -f %m "$usage_cache" 2>/dev/null || echo 0)" if [ ! -s "$usage_cache" ] || [ "$(( now - umtime ))" -ge 30 ]; then - curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "${base%/}/v1/me/usage" -o "$usage_cache" 2>/dev/null || true + # Never write directly to the cache: curl truncates its output file before + # connecting, so a transient outage would erase the last good pool value. + usage_tmp="${usage_cache}.tmp.$$" + usage_url="${base%/}" + case "$usage_url" in + */api/v1|*/v1) usage_url+="/me/usage" ;; + */api) usage_url+="/v1/me/usage" ;; + *) usage_url+="/v1/me/usage" ;; + esac + if curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "$usage_url" -o "$usage_tmp" 2>/dev/null; then + mv -f "$usage_tmp" "$usage_cache" + else + rm -f "$usage_tmp" + fi fi if [ -s "$usage_cache" ]; then s_pct="$(jq -r '.pool.five_hour.used_pct // empty' "$usage_cache" 2>/dev/null)" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a789b7c..74d079f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -550,7 +550,8 @@ def test_me_usage_aggregates_active_pool_and_earliest_reset(client, seed_account weekly_reset_values = [datetime.fromisoformat(value.replace("Z", "+00:00")) for value in pool["weekly"]["reset_at"]] assert weekly_reset_values == [weekly_exhausted_reset, weekly_second_reset, weekly_first_reset] assert datetime.fromisoformat(pool["weekly"]["next_reset_at"].replace("Z", "+00:00")) == weekly_exhausted_reset - assert len(pool["accounts"]) == 4 + # API-key holders only see pool aggregates, never which Anthropic logins back the pool. + assert "accounts" not in pool def test_me_usage_requires_key(client): diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py index 9524c69..c51fb76 100644 --- a/backend/tests/test_clients.py +++ b/backend/tests/test_clients.py @@ -12,14 +12,6 @@ def _assert_unix_installer_keeps_key_out_of_argv(script: str) -> None: assert 'jq --rawfile key "$KEY_FILE"' in script -def test_backend_installer_prompts_for_key_instead_of_accepting_it_in_argv(client): - response = client.get("/api/v1/clients/install.sh") - - assert response.status_code == 200 - assert response.headers["content-type"].startswith("text/x-shellscript") - _assert_unix_installer_keeps_key_out_of_argv(response.text) - - def test_public_installers_never_put_key_in_command_or_process_arguments(): unix_script = (REPOSITORY_ROOT / "frontend/public/install.sh").read_text() powershell_script = (REPOSITORY_ROOT / "frontend/public/install.ps1").read_text() @@ -38,3 +30,12 @@ def test_dashboard_generated_commands_do_not_interpolate_revealed_secret(): assert "${secret}" not in commands assert "CC_PROXY_KEY" not in commands assert "input hidden" in users_page + + +def test_served_statusline_is_the_repository_copy(client): + response = client.get("/api/v1/clients/claude-code-statusline.sh") + + assert response.status_code == 200 + assert response.text == (REPOSITORY_ROOT / "backend/clients/claude-code-statusline.sh").read_text() + assert 'usage_tmp="${usage_cache}.tmp.$$"' in response.text + assert "umask 077" in response.text diff --git a/clients/claude-code-statusline.sh b/clients/claude-code-statusline.sh deleted file mode 100755 index 61fa161..0000000 --- a/clients/claude-code-statusline.sh +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env bash -# Claude Code status line for the Claude Code Proxy. -# -# Reproduces the look of https://github.com/leeguooooo/claude-code-usage-bar, but sourced for a relay: -# the proxy is not api.anthropic.com, so Anthropic's official 5h/7d rate-limit headers never reach Claude -# Code's stdin. Instead the two rate-limit bars come from the proxy's own pool telemetry via GET /v1/me/usage -# (pool.five_hour.used_pct / pool.weekly.used_pct + reset timestamps), while the model, context window, and the -# prompt-cache countdown come from the session JSON Claude Code pipes in on stdin. So `5h`/`7d` here are the -# AVAILABLE pool's average usage, not a personal Anthropic window. -# -# 5h[██░░░░░░] 38% ⏰3h12m │ 7d[███████░] 93% ⏰2d3h │ Opus 4.8 (64.6k/1.0M) │ cache 4m23s -# ⤷ claude-code-proxy ⎇ main● · +182 -47 · ⏱ 12m -# -# Data sources: -# - 5h / 7d % + reset timers ......... GET /v1/me/usage (disk-cached 30s; degrades quietly if unreachable) -# - model name + context window ...... Claude Code stdin (model.display_name, context_window.*) -# - cache 4m23s / COLD ............... tail-read of the active transcript JSONL (TTL auto-detected 1h vs 5m) -# - project / branch / lines / ⏱ ..... stdin workspace+cost, plus a 5s-cached `git` for branch & dirty dot -# -# Requirements: bash, curl, jq, coreutils (GNU date). Honors NO_COLOR. -# -# Install: copy somewhere, `chmod +x`, and point Claude Code at it in ~/.claude/settings.json. For the cache -# countdown to tick every second, also set refreshInterval to 1: -# { "statusLine": { "type": "command", "command": "/abs/path/claude-code-statusline.sh", "refreshInterval": 1 } } - -set -uo pipefail # deliberately NOT -e: every segment degrades to empty rather than aborting the line. - -# Claude Code pipes a session JSON object on stdin. Capture it (may be empty when run by hand). -stdin_json="$(cat 2>/dev/null || true)" - -cachedir="${TMPDIR:-/tmp}" -now="$(date +%s)" - -# ---- colors ------------------------------------------------------------------ -if [ -n "${NO_COLOR:-}" ]; then color=0; else color=1; fi -ESC=$'\033' -DIM=90; GREEN=32; YELLOW=33; RED=31; CYAN=36; GREY=90 -paint() { # paint - local code="$1"; shift - if [ "$color" = 1 ]; then printf '%s' "${ESC}[${code}m$*${ESC}[0m"; else printf '%s' "$*"; fi -} -sev_color() { # echo an ANSI code for a "% used" value (repo defaults: 30 / 70) - local p="${1%.*}"; p="${p:-0}" - if [ "$p" -ge 70 ] 2>/dev/null; then echo "$RED" - elif [ "$p" -ge 30 ] 2>/dev/null; then echo "$YELLOW" - else echo "$GREEN"; fi -} - -# ---- small helpers ----------------------------------------------------------- -sj() { printf '%s' "$stdin_json" | jq -r "$1" 2>/dev/null || true; } # query the stdin JSON - -human() { # 64586 -> 64.6k ; 1000000 -> 1.0M ; 0 -> 0 - awk -v n="${1:-0}" 'BEGIN{ - n=n+0; - if (n>=1000000) printf "%.1fM", n/1000000; - else if (n>=1000) printf "%.1fk", n/1000; - else printf "%d", n; - }' -} - -fmt_eta() { # seconds-until -> "2d3h" / "3h12m" / "12m" / "now" - local s="${1:-0}"; [ "$s" -le 0 ] 2>/dev/null && { printf 'now'; return; } - local d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60)) - if [ "$d" -gt 0 ]; then printf '%dd%dh' "$d" "$h" - elif [ "$h" -gt 0 ]; then printf '%dh%dm' "$h" "$m" - else printf '%dm' "$m"; fi -} - -bar() { # bar -> 8-cell battery, colored by severity - local pct="${1:-0}"; pct="${pct%.*}"; [ -z "$pct" ] && pct=0 - [ "$pct" -lt 0 ] 2>/dev/null && pct=0; [ "$pct" -gt 100 ] 2>/dev/null && pct=100 - local cells=8 filled - filled=$(( (pct * cells + 50) / 100 )) - [ "$filled" -gt "$cells" ] && filled=$cells - local i out="" - for ((i=0; i seconds from now (empty if unparseable / absent) - local iso="$1" ep - [ -n "$iso" ] && [ "$iso" != "null" ] || return 0 - ep="$(date -d "$iso" +%s 2>/dev/null)" || return 0 - echo $(( ep - now )) -} - -# ---- rate-limit segments (5h / 7d) from the proxy ---------------------------- -base="${ANTHROPIC_BASE_URL:-}"; token="${ANTHROPIC_AUTH_TOKEN:-}" -settings="${HOME}/.claude/settings.json" -if { [ -z "$base" ] || [ -z "$token" ]; } && [ -f "$settings" ]; then - [ -n "$base" ] || base="$(jq -r '.env.ANTHROPIC_BASE_URL // empty' "$settings" 2>/dev/null)" - [ -n "$token" ] || token="$(jq -r '.env.ANTHROPIC_AUTH_TOKEN // empty' "$settings" 2>/dev/null)" -fi - -ratelimit_seg="" -if [ -n "$base" ] && [ -n "$token" ]; then - usage_cache="${cachedir}/ccproxy-usage-$(id -u).json" - umtime="$(stat -c %Y "$usage_cache" 2>/dev/null || stat -f %m "$usage_cache" 2>/dev/null || echo 0)" - if [ ! -s "$usage_cache" ] || [ "$(( now - umtime ))" -ge 30 ]; then - # Never write directly to the cache: curl truncates its output file before - # connecting, so a transient outage would erase the last good pool value. - usage_tmp="${usage_cache}.tmp.$$" - usage_url="${base%/}" - case "$usage_url" in - */api/v1|*/v1) usage_url+="/me/usage" ;; - */api) usage_url+="/v1/me/usage" ;; - *) usage_url+="/v1/me/usage" ;; - esac - if curl -fsS --max-time 5 -H "Authorization: Bearer ${token}" "$usage_url" -o "$usage_tmp" 2>/dev/null; then - mv -f "$usage_tmp" "$usage_cache" - else - rm -f "$usage_tmp" - fi - fi - if [ -s "$usage_cache" ]; then - s_pct="$(jq -r '.pool.five_hour.used_pct // empty' "$usage_cache" 2>/dev/null)" - w_pct="$(jq -r '.pool.weekly.used_pct // empty' "$usage_cache" 2>/dev/null)" - s_reset="$(jq -r '.pool.five_hour.next_reset_at // empty' "$usage_cache" 2>/dev/null)" - w_reset="$(jq -r '.pool.weekly.next_reset_at // empty' "$usage_cache" 2>/dev/null)" - if [ -n "$s_pct" ]; then - sp=$(awk -v x="$s_pct" 'BEGIN{printf "%d", x*100+0.5}') - seg="$(paint "$DIM" 5h)[$(bar "$sp")] $(paint "$(sev_color "$sp")" "${sp}%")" - eta="$(eta_until "$s_reset")"; [ -n "$eta" ] && seg+=" $(paint "$DIM" "⏰$(fmt_eta "$eta")")" - ratelimit_seg="$seg" - fi - if [ -n "$w_pct" ]; then - wp=$(awk -v x="$w_pct" 'BEGIN{printf "%d", x*100+0.5}') - seg="$(paint "$DIM" 7d)[$(bar "$wp")] $(paint "$(sev_color "$wp")" "${wp}%")" - eta="$(eta_until "$w_reset")"; [ -n "$eta" ] && seg+=" $(paint "$DIM" "⏰$(fmt_eta "$eta")")" - [ -n "$ratelimit_seg" ] && ratelimit_seg+="$(paint "$DIM" ' │ ')" - ratelimit_seg+="$seg" - fi - fi -fi - -# ---- model + context window from stdin --------------------------------------- -model_seg="" -model="$(sj '.model.display_name // empty')" -# Drop a "(... context)" suffix — the context readout below already conveys size. -model="$(printf '%s' "$model" | sed -E 's/[[:space:]]*\([^)]*[Cc]ontext[^)]*\)//')" -if [ -n "$model" ]; then - ctx_size="$(sj '.context_window.context_window_size // empty')" - ctx_in="$(sj '.context_window.total_input_tokens // 0')" - ctx_out="$(sj '.context_window.total_output_tokens // 0')" - ctx_pct="$(sj '.context_window.used_percentage // empty')" - model_seg="$(paint "$CYAN" "$model")" - if [ -n "$ctx_size" ] && [ "$ctx_size" != "0" ]; then - used=$(( ${ctx_in:-0} + ${ctx_out:-0} )) - cc="$DIM"; [ -n "$ctx_pct" ] && cc="$(sev_color "${ctx_pct%.*}")" - model_seg+=" $(paint "$cc" "($(human "$used")/$(human "$ctx_size"))")" - fi -fi - -# ---- prompt-cache countdown from the transcript ------------------------------ -cache_seg="" -tp="$(sj '.transcript_path // empty')" -if [ -n "$tp" ] && [ -f "$tp" ]; then - read -r c_ts c_ttl <<<"$(tail -n 400 "$tp" 2>/dev/null | jq -rs ' - (map(select(.type=="assistant"))) as $a - | (($a | map(select(.timestamp)) | last | .timestamp) // "") as $ts - | (($a | map(.message.usage.cache_creation) | map(select(. != null)) - | map(if .ephemeral_1h_input_tokens > 0 then 3600 - elif .ephemeral_5m_input_tokens > 0 then 300 else empty end) - | last) // 0) as $ttl - | "\($ts) \($ttl)"' 2>/dev/null)" - if [ -n "${c_ts:-}" ] && [ "$c_ts" != "null" ]; then - c_ttl="${c_ttl:-0}"; [ "$c_ttl" = 0 ] && c_ttl=300 - c_ep="$(date -d "$c_ts" +%s 2>/dev/null || echo 0)" - if [ "$c_ep" != 0 ]; then - age=$(( now - c_ep )); [ "$age" -lt 0 ] && age=0 - rem=$(( c_ttl - age )) - if [ "$rem" -le 0 ]; then - cache_seg="$(paint "$RED" 'cache COLD')" - else - if [ "$rem" -ge 3600 ]; then ctxt=$(printf 'cache %dh%02dm%02ds' $((rem/3600)) $(((rem%3600)/60)) $((rem%60))) - elif [ "$rem" -ge 60 ]; then ctxt=$(printf 'cache %dm%02ds' $((rem/60)) $((rem%60))) - else ctxt=$(printf 'cache %ds' "$rem"); fi - [ "$rem" -lt 60 ] && cache_seg="$(paint "$YELLOW" "$ctxt")" || cache_seg="$(paint "$GREEN" "$ctxt")" - fi - fi - fi -fi - -# ---- assemble line 1 --------------------------------------------------------- -SEP="$(paint "$DIM" ' │ ')" -line1="" -for s in "$ratelimit_seg" "$model_seg" "$cache_seg"; do - [ -n "$s" ] || continue - [ -n "$line1" ] && line1+="$SEP" - line1+="$s" -done - -# ---- identity line (project ⎇ branch · +/- · ⏱) ----------------------------- -proj="$(sj '.workspace.repo.name // empty')" -cwd="$(sj '.workspace.current_dir // .cwd // empty')" -[ -n "$proj" ] || { [ -n "$cwd" ] && proj="$(basename "$cwd")"; } - -branch=""; dirty=0 -if [ -n "$cwd" ] && [ -d "$cwd" ]; then - gkey="$(printf '%s' "$cwd" | cksum | cut -d' ' -f1)" - gcache="${cachedir}/ccproxy-git-${gkey}" - gmtime="$(stat -c %Y "$gcache" 2>/dev/null || stat -f %m "$gcache" 2>/dev/null || echo 0)" - if [ -s "$gcache" ] && [ "$(( now - gmtime ))" -lt 5 ]; then - IFS=$'\t' read -r branch dirty <"$gcache" - else - branch="$(git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null || git -C "$cwd" rev-parse --short HEAD 2>/dev/null || true)" - if [ -n "$branch" ] && [ -n "$(git -C "$cwd" status --porcelain 2>/dev/null)" ]; then dirty=1; fi - printf '%s\t%s' "$branch" "$dirty" >"$gcache" 2>/dev/null || true - fi -fi - -line2="" -if [ -n "$proj" ]; then - line2="$(paint "$DIM" '⤷ ')$(paint "$CYAN" "$proj")" - if [ -n "$branch" ]; then - line2+=" $(paint "$DIM" '⎇') $(paint "$GREY" "$branch")" - [ "${dirty:-0}" = 1 ] && line2+="$(paint "$YELLOW" '●')" - fi - add="$(sj '.cost.total_lines_added // 0')"; rml="$(sj '.cost.total_lines_removed // 0')" - if [ "${add:-0}" != 0 ] || [ "${rml:-0}" != 0 ]; then - line2+=" $(paint "$DIM" '·') $(paint "$GREEN" "+${add}") $(paint "$RED" "-${rml}")" - fi - dur_ms="$(sj '.cost.total_duration_ms // 0')" - if [ "${dur_ms:-0}" != 0 ]; then - dsec=$(( dur_ms / 1000 )) - if [ "$dsec" -ge 3600 ]; then dtxt=$(printf '%dh%dm' $((dsec/3600)) $(((dsec%3600)/60))) - elif [ "$dsec" -ge 60 ]; then dtxt=$(printf '%dm' $((dsec/60))) - else dtxt=$(printf '%ds' "$dsec"); fi - line2+=" $(paint "$DIM" '·') $(paint "$DIM" "⏱ $dtxt")" - fi -fi - -# ---- emit -------------------------------------------------------------------- -if [ -z "$line1" ] && [ -z "$line2" ]; then - echo "ccproxy: status unavailable (need ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN + jq, or Claude Code stdin)" - exit 0 -fi -[ -n "$line1" ] && printf '%s\n' "$line1" -[ -n "$line2" ] && printf '%s\n' "$line2" -exit 0