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
165 changes: 0 additions & 165 deletions backend/app/routes/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <BASE_URL>" >&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")
17 changes: 2 additions & 15 deletions backend/app/routes/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
],
)


Expand Down
3 changes: 1 addition & 2 deletions backend/app/utils/models/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
ListAnthropicFallbacksResponse,
UpdateAnthropicFallbackRequest,
)
from .me import MeUsageResponse, PoolAccountStatus, PoolStatus, PoolWindow
from .me import MeUsageResponse, PoolStatus, PoolWindow
from .notifications import (
NotificationRule,
NotificationSettingsResponse,
Expand Down Expand Up @@ -106,7 +106,6 @@
"UpdateAccountRequest",
"MeUsageResponse",
"PoolWindow",
"PoolAccountStatus",
"PoolStatus",
"User",
"BulkUserPriorityRequest",
Expand Down
17 changes: 1 addition & 16 deletions backend/app/utils/models/api/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel

Expand All @@ -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):
Expand Down
17 changes: 16 additions & 1 deletion backend/clients/claude-code-statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand Down Expand Up @@ -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)"
Expand Down
3 changes: 2 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 9 additions & 8 deletions backend/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Loading