#!/usr/bin/env bash # ocp — OpenClaw Proxy CLI # Usage: ocp [args] # # Talks to the local claude-proxy at http://127.0.0.1:3456 set -euo pipefail PROXY="http://127.0.0.1:3456" # Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file # Using a bash array preserves word boundaries — no eval needed. _AUTH_ARGS=() if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then _AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY") elif [[ -f "$HOME/.ocp/admin-key" ]]; then _AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")") fi # Wrapper: curl with optional auth _curl() { curl "${_AUTH_ARGS[@]}" "$@" } _json() { python3 -m json.tool 2>/dev/null || cat; } _bar() { local pct=$1 width=20 local filled=$(( pct * width / 100 )) local empty=$(( width - filled )) printf '[' printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true printf '] %d%%' "$pct" } # ── usage ──────────────────────────────────────────────────────────────── cmd_usage_help() { cat <<'EOF' ocp usage — Show Claude plan usage limits Displays current session utilization, weekly limits, extra usage status, and proxy request statistics. Data is fetched from the Anthropic API via a minimal probe call (cached for 5 minutes). Usage: ocp usage [--by-key] Options: --by-key Show per-key usage statistics EOF } cmd_usage() { if [[ "${1:-}" == "--by-key" ]]; then local data data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } echo "$data" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) by_key = d.get('byKey', []) if not by_key: print('No usage data yet.') else: print('Usage by Key') print('─────────────────────────────────────────────────────────────────') hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}' print(hdr) print(' ' + '─' * (len(hdr) - 2)) for k in by_key: avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-' print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}') " return fi local data data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; } echo "$data" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) p = d['plan'] s = p['currentSession'] w = p['weeklyLimits']['allModels'] e = p['extraUsage'] px = d['proxy'] models = d.get('models', {}) print('Plan Usage Limits') print('─────────────────────────────────────') print(f' Current session {s[\"percent\"]:>4} used') print(f' Resets in {s[\"resetsIn\"]} ({s[\"resetsAtHuman\"]})') print() print(f' Weekly (all models) {w[\"percent\"]:>4} used') print(f' Resets in {w[\"resetsIn\"]} ({w[\"resetsAtHuman\"]})') print() status_icon = 'on' if e['status'] == 'allowed' else 'off' print(f' Extra usage {status_icon}') print() if models: print('Model Stats (since proxy start)') print('─────────────────────────────────────────────────────────────────────') hdr = f' {\"Model\":<25} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Max Time\":>9} {\"Avg Prompt\":>11} {\"Max Prompt\":>11}' print(hdr) print(' ' + '─' * (len(hdr) - 2)) total_reqs = 0 for model in sorted(models): m = models[model] total_reqs += m['requests'] avg_t = f'{m[\"avgElapsed\"]/1000:.0f}s' if m['avgElapsed'] else '-' max_t = f'{m[\"maxElapsed\"]/1000:.0f}s' if m['maxElapsed'] else '-' avg_p = f'{m[\"avgPromptChars\"]/1000:.0f}K' if m['avgPromptChars'] else '-' max_p = f'{m[\"maxPromptChars\"]/1000:.0f}K' if m['maxPromptChars'] else '-' print(f' {model:<25} {m[\"requests\"]:>5} {m[\"successes\"]:>4} {m[\"errors\"]:>4} {avg_t:>9} {max_t:>9} {avg_p:>11} {max_p:>11}') print(f' {\"Total\":<25} {total_reqs:>5}') else: print(' No model requests yet.') print() print(f'Proxy: up {px[\"uptime\"]} | {px[\"totalRequests\"]} reqs | {px[\"activeRequests\"]} active | {px[\"errors\"]} err | {px[\"timeouts\"]} timeout') " } # ── status ─────────────────────────────────────────────────────────────── cmd_status_help() { cat <<'EOF' ocp status — Quick combined overview (usage + health) Usage: ocp status EOF } cmd_status() { curl -sf --max-time 15 "$PROXY/status" | _json } # ── health ─────────────────────────────────────────────────────────────── cmd_health_help() { cat <<'EOF' ocp health — Proxy health and diagnostics Shows proxy status, version, uptime, auth, config, sessions, and recent errors. Usage: ocp health EOF } cmd_health() { curl -sf --max-time 10 "$PROXY/health" | _json } # ── logs ───────────────────────────────────────────────────────────────── cmd_logs_help() { cat <<'EOF' ocp logs — Show recent proxy log entries Usage: ocp logs [N] [LEVEL] Arguments: N Number of entries to show (default: 20, max: 200) LEVEL Filter level: error, warn, info, all (default: error) Examples: ocp logs Last 20 errors ocp logs 50 all Last 50 entries of any level ocp logs 10 warn Last 10 warnings EOF } cmd_logs() { local n=${1:-20} local level=${2:-error} curl -sf --max-time 10 "$PROXY/logs?n=$n&level=$level" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) entries = d.get('entries', []) if not entries: print(f'No {d.get(\"level\",\"\")} log entries.') else: for e in entries: if 'raw' in e: print(e['raw'][:200]) else: ts = e.get('ts','')[:19] ev = e.get('event','?') lvl = e.get('level','') model = e.get('model','') extra = ' '.join(f'{k}={v}' for k,v in e.items() if k not in ('ts','event','level','model')) parts = [ts, lvl.upper(), ev] if model: parts.append(model) if extra: parts.append(extra) print(' | '.join(parts)) " } # ── models ─────────────────────────────────────────────────────────────── cmd_models_help() { cat <<'EOF' ocp models — List available Claude models Usage: ocp models EOF } cmd_models() { curl -sf --max-time 5 "$PROXY/v1/models" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) for m in d.get('data', []): print(f\" {m['id']}\") " } # ── sessions ───────────────────────────────────────────────────────────── cmd_sessions_help() { cat <<'EOF' ocp sessions — List active CLI sessions Usage: ocp sessions EOF } cmd_sessions() { curl -sf --max-time 5 "$PROXY/sessions" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) sessions = d.get('sessions', []) if not sessions: print('No active sessions.') else: for s in sessions: print(f\" {s['id'][:16]}... model={s['model']} msgs={s['messages']} last={s['lastUsed']}\") " } # ── clear ──────────────────────────────────────────────────────────────── cmd_clear_help() { cat <<'EOF' ocp clear — Clear all active CLI sessions Usage: ocp clear EOF } cmd_clear() { local result result=$(curl -sf --max-time 5 -X DELETE "$PROXY/sessions") local count count=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['cleared'])") echo "Cleared $count sessions." } # ── keys ──────────────────────────────────────────────────────────────── cmd_keys_help() { cat <<'EOF' ocp keys — Manage API keys (multi-key auth mode) Usage: ocp keys List all keys ocp keys add Create a new key ocp keys revoke Revoke a key Examples: ocp keys add wife-laptop ocp keys add son-ipad ocp keys revoke wife-laptop EOF } cmd_keys() { case "${1:-}" in add) if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add "; return 1; fi local result result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ -H "Content-Type: application/json" \ -d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; } echo "$result" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) if 'key' in d: print(f'✓ Key created for \"{d[\"name\"]}\"') print(f'') print(f' API Key: {d[\"key\"]}') print(f'') print(f' Copy this key now — you won\\'t see it again.') print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}') else: print(f'✗ {d.get(\"error\", \"Unknown error\")}') " ;; revoke) if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke "; return 1; fi _curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) if d.get('revoked'): print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.') else: print(f'✗ Key not found or already revoked.') " ;; --help|-h) cmd_keys_help ;; "") _curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) keys = d.get('keys', []) if not keys: print('No API keys configured.') print('Create one: ocp keys add ') else: print('API Keys') print('─────────────────────────────────────────────────') for k in keys: status = '✗ revoked' if k['revoked'] else '✓ active' print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}') " 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; } ;; *) echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;; esac } # ── connect ───────────────────────────────────────────────────────────── cmd_connect_help() { cat <<'EOF' ocp connect — Connect this machine to a remote OCP as a LAN client Configures OPENAI_BASE_URL (and optionally OPENAI_API_KEY) in your shell rc file so tools like Claude Code point to a remote OCP instance. Usage: ocp connect [--port PORT] [--key API_KEY] Arguments: host-ip IP address of the machine running OCP --port PORT Port OCP listens on (default: 3456) --key API_KEY API key to use (prompted if remote requires auth) Examples: ocp connect 192.168.1.10 ocp connect 192.168.1.10 --port 8080 ocp connect 192.168.1.10 --key sk-abc123 EOF } cmd_connect() { local host="" port=3456 key="" # Parse args while [[ $# -gt 0 ]]; do case "$1" in --port) port="${2:?'--port requires a value'}"; shift 2 ;; --key) key="${2:?'--key requires a value'}"; shift 2 ;; --help|-h) cmd_connect_help; return 0 ;; -*) echo "Unknown option: $1"; cmd_connect_help; return 1 ;; *) host="$1"; shift ;; esac done if [[ -z "$host" ]]; then echo "Error: host IP is required." echo "" cmd_connect_help return 1 fi if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then echo "Error: invalid host '$host'" return 1 fi local base_url="http://$host:$port" echo "OCP Connect" echo "─────────────────────────────────────" echo " Remote: $base_url" echo "" # Step 1: Test connectivity via /health echo " Checking connectivity..." local health_json health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || { echo " ✗ Cannot reach $base_url/health" echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)." return 1 } echo " ✓ Connected" echo "" # Step 2: Show remote info local remote_version auth_mode remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?") auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none") echo " Remote OCP v$remote_version (auth: $auth_mode)" echo "" # Step 3: Determine if key is needed local needs_key=0 if [[ "$auth_mode" != "none" ]]; then needs_key=1 fi if [[ $needs_key -eq 1 && -z "$key" ]]; then echo " Remote requires authentication." echo " Ask the admin to run: ocp keys add " printf " Enter API key (or press Enter to skip): " read -rs key /dev/null) && models_ok=1 else models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1 fi if [[ $models_ok -eq 0 ]]; then echo " ✗ API access failed — key may be invalid or revoked." return 1 fi local model_count model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?") echo " ✓ API accessible ($model_count models available)" echo "" # Step 5: Detect shell rc file local rc_file if [[ "${SHELL:-}" == */zsh ]]; then rc_file="$HOME/.zshrc" else rc_file="$HOME/.bashrc" fi # Step 6: Remove any previously written OCP LAN lines if [[ -f "$rc_file" ]]; then local tmp_rc tmp_rc=$(mktemp) if python3 - "$rc_file" "$tmp_rc" <<'PYEOF' import sys src, dst = sys.argv[1], sys.argv[2] lines = open(src).readlines() out = [] skip = False for line in lines: s = line.rstrip('\n') if s == '# OCP LAN (added by ocp connect)': skip = True continue if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')): continue skip = False out.append(line) open(dst, 'w').writelines(out) PYEOF then cp "$tmp_rc" "$rc_file" fi rm -f "$tmp_rc" fi # Step 7: Append new config { echo "" echo "# OCP LAN (added by ocp connect)" echo "export OPENAI_BASE_URL=$base_url/v1" if [[ -n "$key" ]]; then echo "export OPENAI_API_KEY=$key" fi } >> "$rc_file" echo " Written to $rc_file:" echo " OPENAI_BASE_URL=$base_url/v1" if [[ -n "$key" ]]; then echo " OPENAI_API_KEY=${key:0:8}..." fi echo "" # Step 8: Quick smoke test — send a minimal chat completion echo " Running smoke test..." local chat_payload='{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":10}' local chat_out chat_ok=0 if [[ -n "$key" ]]; then chat_out=$(curl -sf --max-time 30 \ -H "Authorization: Bearer $key" \ -H "Content-Type: application/json" \ -d "$chat_payload" \ "$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1 else chat_out=$(curl -sf --max-time 30 \ -H "Content-Type: application/json" \ -d "$chat_payload" \ "$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1 fi if [[ $chat_ok -eq 1 ]]; then local reply reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)") echo " ✓ Smoke test passed: $reply" else echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)." echo " The env vars have still been written. Check the remote OCP logs." fi echo "" echo " Done. Reload your shell to apply:" echo " source $rc_file" } # ── lan ───────────────────────────────────────────────────────────────── cmd_lan_help() { cat <<'EOF' ocp lan — Quick LAN mode setup guide Shows current network configuration and connection instructions for other devices on the same network. Usage: ocp lan EOF } cmd_lan() { local ip ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") local port=3456 echo "OCP LAN Setup" echo "─────────────────────────────────────" echo "" echo " Your IP: $ip" echo " Port: $port" echo "" echo " For IDE users, set:" echo " OPENAI_BASE_URL=http://$ip:$port/v1" echo " OPENAI_API_KEY= (if auth enabled)" echo "" echo " Dashboard: http://$ip:$port/dashboard" echo "" if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then echo " Status: ✓ LAN-accessible" else echo " Status: ✗ Not LAN-accessible (bound to localhost only)" echo "" echo " To enable LAN mode, set env var and restart:" echo " CLAUDE_BIND=0.0.0.0" echo " ocp restart" fi } # ── restart ────────────────────────────────────────────────────────────── cmd_restart_help() { cat <<'EOF' ocp restart — Restart the proxy or gateway Usage: ocp restart Restart the Claude proxy service ocp restart gateway Restart the OpenClaw gateway (briefly disconnects all Telegram/Discord bots) Note (macOS): restart does a full launchctl bootout + bootstrap, NOT `kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables, so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually takes effect. `kickstart -k` only re-execs the process and reuses launchd's cached env, so env edits would be silently ignored. (Linux systemctl already re-reads its EnvironmentFile on restart.) EOF } # macOS only: reload a launchd agent via bootout + bootstrap so plist # EnvironmentVariables are re-read (kickstart -k would reuse the cached env). # Args: