Files
ocp/ocp-connect
T
d71e0455e3 feat(ocp-connect): auto-discover anonymous key from /health (v1.3.0) (#16)
Closes the zero-config loop opened by #15 (server anonymous allowlist).

## Why

After #15 (v3.7.0) the OCP server exposes the admin-chosen anonymous
key via `GET /health.anonymousKey` whenever `PROXY_ANONYMOUS_KEY` is
set. Before this PR, users still had to copy that key manually and
pass it as `--key` every time they ran `ocp-connect`. This commit
closes the last mile: `ocp-connect <host>` now reads the key from
the server and uses it automatically when no `--key` was provided.

Result: `ocp-connect 172.16.2.30` (zero args) on a fresh machine
configures OpenClaw multi-agent correctly against a v3.7.0 OCP
instance that has `PROXY_ANONYMOUS_KEY` set. No admin coordination,
no per-user keys, no manual flags.

## What changes

`OCP_CONNECT_VERSION` 1.2.0 to 1.3.0.

New Step 2.5 between the existing Step 2 (Show remote info) and
Step 3 (Determine if key is needed). The block:

1. Short-circuits if `--key` was passed explicitly, so user intent
   always wins over auto-discovery.
2. Parses `health_json.anonymousKey` via python3 with try/except,
   defensively treating any parse error as "no anon key" so the
   script falls through to the existing Step 3 path.
3. If the server advertised a non-empty anon key, assigns it to the
   internal `key` variable and prints an info banner with the
   truncated key value and a reference to issue #12 section 14
   Path A so users can understand what happened.

All downstream code (key verification via /v1/models, rc file
exports, launchctl setenv, OpenClaw agentDir profile seeding, smoke
test, IDE hints) works unchanged because it reads the same `key`
variable.

Also: defensive check on `--key` to reject explicit empty values
(`--key ""`) with a clear error message. Existing bash `${2:?msg}`
expansion already rejects empty strings, but the new check is an
extra safety net and gives a friendlier error (`omit --key entirely
for anonymous mode`).

## Backward compatibility

Old OCP servers (less than 3.7.0) that don't have the
`anonymousKey` field in `/health`: `python3 d.get("anonymousKey")`
returns None, new block outputs empty string, `[[ -n "" ]]` is
false, block falls through to the existing Step 3 path. Script
behavior is 100% identical to v1.2.0 for these users.

New OCP servers that haven't set `PROXY_ANONYMOUS_KEY`: field is
null, same fall-through path as above.

No user-visible regression in either case. No new CLI flag, no new
prompt, no new error path for existing users.

## Test evidence

Live offline test on macOS 13 against real 172.16.2.30 (OCP v3.7.0
with `PROXY_ANONYMOUS_KEY=ocp_public_anon_v1`):

Step 1: cold install state
  - stopped OpenClaw gateway
  - rm ~/.openclaw/agents/{main,macbook_bot}/agent/auth-profiles.json
  - stripped models.* and agents.defaults.{model,models} from openclaw.json
  - cleaned OCP LAN block from .bashrc
  - unset OPENAI_BASE_URL and OPENAI_API_KEY from launchctl env

Step 2: ran ~/projects/ocp/ocp-connect 172.16.2.30 (no args)

Output:
  OCP Connect v1.3.0
  Remote OCP v3.7.0 (auth: multi)
  Using server-advertised anonymous key: ocp_publ...n_v1
    (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 section 14 Path A)
  API accessible (3 models available)
  Per-agent auth profile seeded (2):
    - ~/.openclaw/agents/main/agent/auth-profiles.json
    - ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
  Smoke test passed: OK
  EXIT 0

Step 3: verified profiles contain the anon key
  macbook_bot: key = ocp_public_anon_v1
  main: key = ocp_public_anon_v1

Additional regression tests:
  - ocp-connect 172.16.2.30 --key ocp_real_admin_key -> still uses
    the explicit key, auto-discovery banner NOT shown
  - ocp-connect --version -> "ocp-connect 1.3.0"
  - ocp-connect 172.16.2.30 --key "" -> rejected with clear error

## Code review

One independent opus reviewer. Verdict PASS WITH CONCERNS, 0
blockers. Reviewer's Concern B (--key "" edge case) turned out to
be a false alarm on my side verification: bash `${2:?}` expansion
already rejects empty strings. I added an extra defensive check
anyway for clearer error message and safety if the arg parser is
refactored in the future. Other concerns (nice-to-have
simplifications like `print(k or '')`) are cosmetic and deferred.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:12:44 +10:00

712 lines
26 KiB
Bash
Executable File

#!/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 -- <host-ip> --key <key>
#
set -euo pipefail
OCP_CONNECT_VERSION="1.3.0"
show_version() {
echo "ocp-connect $OCP_CONNECT_VERSION"
}
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 <host-ip> [options]
Options:
--port PORT Port OCP listens on (default: 3456)
--key API_KEY API key (prompted interactively if remote requires auth)
--version Print version and exit
--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. Configures OpenClaw automatically; prints setup hints for other IDEs
(Cline, Continue.dev, Cursor — manual configuration required)
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/tty; } 2>/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 <name>/model-id) [ocp]: "
local provider_name
{ read -r provider_name </dev/tty; } 2>/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/tty; } 2>/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")
# === B1 fix: seed per-agent auth-profiles.json for OpenClaw multi-agent setups ===
# OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the
# root openclaw.json's auth.profiles section). Without a real key in each agent's
# agentDir, OpenClaw rejects the lane with "No API key found for provider X".
# See https://github.com/dtzp555-max/ocp/issues/12 for the full investigation.
_seeded = []
_failed = []
_skipped_anonymous = []
_profile_key = provider_name + ":default"
# OpenClaw stores per-agent auth in <openclaw_root>/agents/<id>/agent/ when
# the agent has no explicit `agentDir` field — derive that path so the
# default `main` agent (which never sets agentDir) is also seeded.
_openclaw_root = os.path.dirname(config_path)
for _agent in config.get("agents", {}).get("list", []):
_agent_dir = _agent.get("agentDir")
if not _agent_dir:
_agent_id = _agent.get("id")
if not _agent_id:
continue
_agent_dir = os.path.join(_openclaw_root, "agents", _agent_id, "agent")
if not api_key:
# Anonymous mode is incompatible with OpenClaw per-agent auth (empty key
# is dropped by OpenClaw's pi-auth-credentials). Per OCP issue #12 §14
# decision: take Path C (require --key for OpenClaw multi-agent setups).
_skipped_anonymous.append(_agent_dir)
continue
_profiles_path = os.path.join(_agent_dir, "auth-profiles.json")
try:
os.makedirs(_agent_dir, exist_ok=True)
except OSError as _e:
_failed.append((_profiles_path, "mkdir: " + str(_e)))
continue
if os.path.exists(_profiles_path):
try:
with open(_profiles_path) as _f:
_ap = json.load(_f)
except json.JSONDecodeError:
# Corrupted JSON — back up and rebuild from scratch.
_bak = _profiles_path + ".bak"
try:
os.rename(_profiles_path, _bak)
print(f" ⚠ {_profiles_path} was corrupt; backed up to {_bak}")
except OSError:
pass
_ap = {"version": 1, "profiles": {}}
except OSError as _e:
# Read failure (permissions, disk) — skip to AVOID clobbering
# the user's existing profile (which may hold other providers' keys).
_failed.append((_profiles_path, "read: " + str(_e)))
continue
else:
_ap = {"version": 1, "profiles": {}}
_ap.setdefault("version", 1)
_ap.setdefault("profiles", {})
_ap["profiles"][_profile_key] = {
"type": "api_key",
"provider": provider_name,
"key": api_key
}
try:
with open(_profiles_path, "w") as _f:
json.dump(_ap, _f, indent=2, ensure_ascii=False)
_f.write("\n")
os.chmod(_profiles_path, 0o600)
_seeded.append(_profiles_path)
except OSError as _e:
_failed.append((_profiles_path, "write: " + str(_e)))
# Report — all three sections are independent (mixed scenarios are surfaced).
if _seeded:
print(f" ✓ Per-agent auth profile seeded ({len(_seeded)}):")
for _p in _seeded:
print(f" • {_p}")
if _failed:
print(f" ⚠ Per-agent auth profile FAILED ({len(_failed)}):")
for _p, _err in _failed:
print(f" • {_p}: {_err}")
print(" OpenClaw will report \"No API key found\" for the failed agents.")
print(" Fix the underlying error (permissions / disk) and re-run ocp-connect.")
if _skipped_anonymous:
print(f" ⚠ OpenClaw multi-agent mode detected ({len(_skipped_anonymous)} agents).")
print(" Anonymous mode does not work for OpenClaw per-agent auth.")
print(" Re-run with: ocp-connect <host> --key ocp_xxx")
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
# Collect VS Code extension list once (reused by Cline and Continue.dev checks)
local _vscode_exts=""
if command -v code &>/dev/null; then
_vscode_exts=$(code --list-extensions 2>/dev/null || true)
fi
if [[ -z "$_vscode_exts" && -d "$HOME/.vscode/extensions" ]]; then
_vscode_exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
fi
# Extract model IDs from /v1/models response for use in hints
local _model_ids
_model_ids=$(echo "$models_out" | python3 -c "
import sys,json
try:
d=json.loads(sys.stdin.read())
print(', '.join(m['id'] for m in d.get('data', [])))
except Exception:
print('claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001')
" 2>/dev/null || echo "claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001")
# Key display: truncate if longer than 16 chars to avoid screenshot leakage,
# show explicit "(none)" in anonymous mode so users don't paste blank fields.
local _key_display
if [[ -z "$key" ]]; then
_key_display="(none — anonymous mode; most external IDEs require a non-empty API Key)"
elif [[ ${#key} -gt 16 ]]; then
_key_display="${key:0:8}...${key: -4}"
else
_key_display="$key"
fi
# Detect Cline (VS Code extension saoudrizwan.claude-dev, see issue #12)
if echo "$_vscode_exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
if ! $other_ides_shown; then
echo " Other IDEs detected:"
other_ides_shown=true
fi
echo " • Cline: VSCode → Cline panel → Settings → API Provider = \"OpenAI Compatible\""
echo " Base URL: $base_url/v1"
echo " API Key: $_key_display"
echo " Model ID: $_model_ids"
fi
# Detect Continue.dev (extension ID continue.continue or config file, see issue #12)
if echo "$_vscode_exts" | grep -qi 'continue\.continue' \
|| [[ -f "$HOME/.continue/config.yaml" ]] \
|| [[ -f "$HOME/.continue/config.json" ]]; then
if ! $other_ides_shown; then
echo " Other IDEs detected:"
other_ides_shown=true
fi
echo " • Continue.dev: edit ~/.continue/config.yaml — add under \`models:\` (top-level key):"
echo " models:"
echo " - name: OCP Sonnet"
echo " provider: openai"
echo " model: claude-sonnet-4-6"
echo " apiBase: $base_url/v1"
echo " apiKey: $_key_display"
echo " (other model IDs: $_model_ids)"
fi
# Detect Cursor (command, ~/.cursor dir, or /Applications/Cursor.app, see issue #12)
if command -v cursor &>/dev/null \
|| [[ -d "$HOME/.cursor" ]] \
|| [[ -d "/Applications/Cursor.app" ]]; then
if ! $other_ides_shown; then
echo " Other IDEs detected:"
other_ides_shown=true
fi
echo " • Cursor: Cmd+Shift+P → 'Cursor Settings' → Models →"
echo " OpenAI API Key: $_key_display"
echo " Override OpenAI Base URL: $base_url/v1"
echo " Custom OpenAI Models: $_model_ids"
fi
# Detect opencode (https://opencode.ai, SST team CLI, see issue #12)
if command -v opencode &>/dev/null \
|| [[ -x "$HOME/.opencode/bin/opencode" ]] \
|| [[ -d "$HOME/.local/share/opencode" ]]; then
if ! $other_ides_shown; then
echo " Other IDEs detected:"
other_ides_shown=true
fi
echo " • opencode: not yet auto-configured by ocp-connect (PR follow-up)."
echo " Run \`opencode providers login openai\` and provide:"
echo " Base URL: $base_url/v1"
echo " API Key: $_key_display"
echo " Available model IDs: $_model_ids"
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'}"
[[ -z "$key" ]] && { echo "Error: --key cannot be empty (omit --key entirely for anonymous mode)"; exit 1; }
shift 2 ;;
--version) show_version; exit 0 ;;
--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 v$OCP_CONNECT_VERSION"
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 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
if [[ -z "$key" ]]; then
local anon_key
anon_key=$(echo "$health_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
k = d.get('anonymousKey')
except Exception:
k = None
print(k if k else '')
" 2>/dev/null || echo "")
if [[ -n "$anon_key" ]]; then
key="$anon_key"
local _anon_display="$anon_key"
if [[ ${#anon_key} -gt 16 ]]; then
_anon_display="${anon_key:0:8}...${anon_key: -4}"
fi
echo " ⓘ Using server-advertised anonymous key: $_anon_display"
echo " (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)"
echo ""
fi
fi
# 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 <name>"
printf " Enter API key (or press Enter to skip): "
{ read -rs key </dev/tty; } 2>/dev/null || key=""
echo
if [[ -z "$key" ]]; then
echo ""
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
echo " Use: ocp-connect $host --key <key>"
exit 1
fi
fi
fi
# Step 4: Test API access via /v1/models
echo " Testing API access..."
local models_out models_ok=0
if [[ -n "$key" ]]; then
models_out=$(curl -sf --max-time 10 \
-H "Authorization: Bearer $key" \
"$base_url/v1/models" 2>/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"
echo " Note: smoke test only verifies OCP is reachable and the key is valid."
echo " It does not verify your IDE/agent end-to-end. To verify OpenClaw works,"
echo " restart it (\`openclaw gateway restart\`) and send a test message to your bot."
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 "$@"