#!/usr/bin/env bash # ocp-connect — Lightweight client script to connect to a remote OCP instance # No dependencies beyond curl and python3 (available on most Linux/Mac systems) # # Install: # curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect # chmod +x ocp-connect # # Or run directly: # curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- --key # set -euo pipefail show_help() { cat <<'EOF' ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy) Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file so tools like Claude Code, Cline, Aider, etc. point to the remote OCP. Usage: ocp-connect [options] Options: --port PORT Port OCP listens on (default: 3456) --key API_KEY API key (prompted interactively if remote requires auth) --help, -h Show this help Examples: ocp-connect 192.168.1.10 ocp-connect 192.168.1.10 --port 8080 ocp-connect 192.168.1.10 --key ocp_abc123 What it does: 1. Tests connectivity to the remote OCP 2. Verifies your API key (if auth is enabled) 3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc 4. Sets system-level env vars (launchctl on macOS, systemd on Linux) 5. Detects and configures IDEs (OpenClaw, Cline, Continue.dev, Cursor) 6. Runs a smoke test to confirm everything works After running, reload your shell: source ~/.bashrc (or ~/.zshrc) EOF } configure_ides() { local base_url="$1" key="$2" models_out="$3" # --- OpenClaw --- local oc_config="$HOME/.openclaw/openclaw.json" local oc_found=false if command -v openclaw &>/dev/null || [[ -f "$oc_config" ]]; then oc_found=true fi if $oc_found; then echo " IDE Configuration" echo " ─────────────────────────────────────" echo " Detected: OpenClaw ($oc_config)" echo "" printf " Configure OpenClaw to use this OCP? [Y/n] " local oc_answer read -r oc_answer /dev/null || oc_answer="y" oc_answer="${oc_answer:-y}" if [[ "$oc_answer" =~ ^[Yy]$ ]]; then # Ask for provider name printf " Provider name (models show as /model-id) [ocp]: " local provider_name read -r provider_name /dev/null || provider_name="" provider_name="${provider_name:-ocp}" # Sanitize: only allow alphanumeric, dash, underscore provider_name=$(echo "$provider_name" | tr -cd 'a-zA-Z0-9_-') [[ -z "$provider_name" ]] && provider_name="ocp" # Ask for priority echo "" echo " How should OCP models be configured?" echo " 1) Primary — use OCP by default, keep existing models as backup" echo " 2) Backup — keep current primary, add OCP as additional option" echo "" printf " Choice [1]: " local priority_choice read -r priority_choice /dev/null || priority_choice="1" priority_choice="${priority_choice:-1}" echo "" echo " Writing OpenClaw config..." # Use python3 to safely manipulate JSON local py_ok=0 python3 - "$oc_config" "$base_url" "$key" "$provider_name" "$priority_choice" "$models_out" <<'PYEOF' && py_ok=1 import sys, json, os config_path = sys.argv[1] base_url = sys.argv[2] api_key = sys.argv[3] provider_name = sys.argv[4] priority = sys.argv[5] # "1" = primary, "2" = backup models_json_str = sys.argv[6] # Parse models from OCP /v1/models response try: models_data = json.loads(models_json_str) model_ids = [m["id"] for m in models_data.get("data", [])] except: model_ids = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"] # Build provider entry provider = { "baseUrl": base_url + "/v1", "api": "openai-completions", "authHeader": bool(api_key), "models": [] } # Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001) model_meta = { "claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192}, } def get_model_meta(mid): """Match model metadata by prefix.""" for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])): if mid.startswith(prefix): return meta return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192} for mid in model_ids: meta = get_model_meta(mid) provider["models"].append({ "id": mid, "name": meta["name"], "reasoning": meta["reasoning"], "input": ["text"], "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, "contextWindow": 200000, "maxTokens": meta["maxTokens"] }) # Load or create config if os.path.exists(config_path): with open(config_path, "r") as f: config = json.load(f) else: os.makedirs(os.path.dirname(config_path), exist_ok=True) config = {} # Ensure models.providers exists config.setdefault("models", {}) config["models"].setdefault("mode", "merge") config["models"].setdefault("providers", {}) # Remove any previous OCP provider with the same name config["models"]["providers"][provider_name] = provider # Set up auth profile if key is provided if api_key: config.setdefault("auth", {}) config["auth"].setdefault("profiles", {}) config["auth"]["profiles"][provider_name + ":default"] = { "provider": provider_name, "mode": "api_key" } # Configure agent defaults — add model aliases config.setdefault("agents", {}) config["agents"].setdefault("defaults", {}) config["agents"]["defaults"].setdefault("models", {}) # Build alias map (prefix match) alias_prefixes = { "claude-opus-4": "Claude Opus", "claude-sonnet-4": "Claude Sonnet", "claude-haiku-4": "Claude Haiku", } for mid in model_ids: full_id = provider_name + "/" + mid alias = mid # fallback for prefix, name in sorted(alias_prefixes.items(), key=lambda x: -len(x[0])): if mid.startswith(prefix): alias = name break config["agents"]["defaults"]["models"][full_id] = {"alias": alias} # Handle primary/backup if priority == "1": # OCP as primary — pick the best model (prefer sonnet for daily use) primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0] config["agents"]["defaults"].setdefault("model", {}) config["agents"]["defaults"]["model"]["primary"] = primary_model # Keep existing fallbacks config["agents"]["defaults"]["model"].setdefault("fallbacks", []) # If backup (priority == "2"), don't change the primary — just add models to the list # Update agent list entries that use old provider name patterns # (only update if agents.list exists and has entries using old OCP-like providers) if "list" in config.get("agents", {}): for agent in config["agents"]["list"]: agent_model = agent.get("model", {}) if priority == "1" and agent_model.get("primary", "").startswith("claude-local/"): # Migrate from claude-local to new provider old_model_id = agent_model["primary"].split("/", 1)[1] if old_model_id in model_ids: agent_model["primary"] = provider_name + "/" + old_model_id # Also update subagents if they use claude-local sub = agent.get("subagents", config["agents"]["defaults"].get("subagents", {})) # Don't modify subagents in agent entries — they inherit from defaults # Update defaults subagents model if using claude-local if priority == "1": sub = config["agents"]["defaults"].get("subagents", {}) if sub.get("model", "").startswith("claude-local/"): old_id = sub["model"].split("/", 1)[1] if old_id in model_ids: sub["model"] = provider_name + "/" + old_id with open(config_path, "w") as f: json.dump(config, f, indent=2, ensure_ascii=False) f.write("\n") PYEOF if [[ $py_ok -eq 1 ]]; then echo " ✓ OpenClaw configured" echo " Provider: $provider_name" echo " Models:" # List models from the already-fetched models_out echo "$models_out" | python3 -c " import sys,json d=json.loads(sys.stdin.read()) pn='$provider_name' for m in d.get('data',[]): print(' • ' + pn + '/' + m['id']) " 2>/dev/null if [[ "$priority_choice" == "1" ]]; then echo " Priority: PRIMARY (default model)" else echo " Priority: BACKUP (available in model selector)" fi echo "" echo " Restart OpenClaw to apply: openclaw gateway restart" else echo " ⚠ Failed to write OpenClaw config. You can configure it manually." fi else echo " Skipped OpenClaw configuration." fi echo "" fi # --- Other IDEs: print manual instructions --- local other_ides_shown=false # Detect Cline (VS Code extension) if [[ -d "$HOME/.vscode/extensions" ]] && ls "$HOME/.vscode/extensions/" 2>/dev/null | grep -qi cline; then if ! $other_ides_shown; then echo " Other IDEs detected:" other_ides_shown=true fi echo " • Cline: Settings → OPENAI_BASE_URL = $base_url/v1" fi # Detect Continue.dev if [[ -f "$HOME/.continue/config.json" ]]; then if ! $other_ides_shown; then echo " Other IDEs detected:" other_ides_shown=true fi echo " • Continue.dev: ~/.continue/config.json → apiBase: \"$base_url/v1\"" fi # Detect Cursor if command -v cursor &>/dev/null || [[ -d "$HOME/.cursor" ]]; then if ! $other_ides_shown; then echo " Other IDEs detected:" other_ides_shown=true fi echo " • Cursor: Settings → OpenAI Base URL = $base_url/v1" fi if $other_ides_shown; then echo "" fi } main() { 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) show_help; exit 0 ;; -*) echo "Unknown option: $1"; show_help; exit 1 ;; *) host="$1"; shift ;; esac done if [[ -z "$host" ]]; then echo "Error: host IP is required." echo "" show_help exit 1 fi if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then echo "Error: invalid host '$host'" exit 1 fi # Check dependencies for cmd in curl python3; do if ! command -v "$cmd" &>/dev/null; then echo "Error: '$cmd' is required but not found." exit 1 fi done 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)." exit 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 if [[ "$auth_mode" != "none" && -z "$key" ]]; then # Try anonymous access first (zero-config: server may allow it) if curl -sf --max-time 5 "$base_url/v1/models" >/dev/null 2>&1; then echo " Server allows anonymous access — no key needed." echo "" else echo " Remote requires authentication." echo " Ask the OCP 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." exit 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 files and OS local rc_files=() local is_mac=false [[ "$(uname)" == "Darwin" ]] && is_mac=true # Write to all relevant rc files if [[ "${SHELL:-}" == */fish ]]; then echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually." rc_files+=("$HOME/.bashrc") else # Always write both on macOS (default shell is zsh but some tools source bashrc) [[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc") [[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc") # If neither exists, create for current shell [[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc") fi # Step 6: Remove any previously written OCP LAN lines (idempotent) from all rc files for rc_file in "${rc_files[@]}"; do 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 done # Step 7: Append new config to all rc files for rc_file in "${rc_files[@]}"; do { 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" done echo " Shell config:" for rc_file in "${rc_files[@]}"; do echo " ✓ $(basename "$rc_file")" done echo " OPENAI_BASE_URL=$base_url/v1" if [[ -n "$key" ]]; then echo " OPENAI_API_KEY=${key:0:8}..." fi # Step 7b: System-level env vars (for IDEs, daemons, GUI apps) if $is_mac; then # macOS: launchctl setenv makes vars visible to all GUI apps and launchd services launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null if [[ -n "$key" ]]; then launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null fi echo "" echo " System-level (launchctl):" echo " ✓ OPENAI_BASE_URL set for GUI apps and daemons" echo " Note: launchctl vars reset on reboot. Add to Login Items or re-run ocp-connect." else # Linux: write to environment.d for systemd user services local env_dir="$HOME/.config/environment.d" mkdir -p "$env_dir" 2>/dev/null { echo "OPENAI_BASE_URL=$base_url/v1" if [[ -n "$key" ]]; then echo "OPENAI_API_KEY=$key" fi } > "$env_dir/ocp.conf" echo "" echo " System-level (systemd):" echo " ✓ $env_dir/ocp.conf" echo " Applies to systemd user services after re-login." fi echo "" # Step 7c: Interactive IDE configuration configure_ides "$base_url" "$key" "$models_out" # Step 8: Quick smoke test echo " Running smoke test..." # Pick the first available model from /v1/models local smoke_model smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001") local chat_payload="{\"model\":\"$smoke_model\",\"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" } main "$@"