mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
* fix(ocp-connect): seed per-agent auth-profiles for OpenClaw multi-agent + UX hotfixes Fixes #12. ocp-connect bumped to v1.1.0. ## What was broken OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the root openclaw.json's auth.profiles section, NOT env vars). ocp-connect only wrote the root config, so OpenClaw multi-agent setups - any agent with an explicit or implicit agentDir, including the default `main` agent - silently failed with "No API key found for provider X" on every Telegram/IDE message. The smoke test passed but the user's actual workflow was broken. 3 unrelated UX papercuts compounded the bad experience: * Smoke test only verified OCP direct connectivity, never the IDE/agent -> OCP path, so the script printed "Smoke test passed" while the bot was silently broken. * `read ... </dev/tty 2>/dev/null || fallback` leaked `Device not configured` errors from the parent shell in CI/SSH/no-tty environments. The fallback path worked correctly, but the stderr noise looked like a bug. * Help text claimed `Detects and configures IDEs (OpenClaw, Cline, Continue.dev, Cursor)` while only OpenClaw is actually configured. Full investigation, evidence, and reviewer findings in #12. ## What this commit does ### B1: write per-agent auth-profiles.json (CRITICAL) After writing root openclaw.json, iterate `agents.list[]`. For each agent: derive agentDir from the explicit `agentDir` field, OR fall back to `<openclaw_root>/agents/<id>/agent/` for agents that omit it. This catches the default `main` agent, discovered via end-to-end Telegram testing. Merge into existing auth-profiles.json (preserving other providers' keys), upsert `<provider>:default` with the real key, chmod 0600. Anonymous mode (no --key) explicitly skips agentDir writes (empty keys are dropped by OpenClaw's pi-auth-credentials anyway) and prints a clear warning that OpenClaw multi-agent requires --key. This is the "Path C" decision from #12 section 14. Error handling is split for safety: * `JSONDecodeError` on existing file -> back up to `.bak`, rebuild * `OSError` on read -> skip this agent. Do NOT clobber the user's existing profile, which may hold other providers' real keys. * `OSError` on mkdir/write -> record in `_failed` list, surface in output. Not silently swallowed. Three independent report sections (`_seeded` / `_failed` / `_skipped_anonymous`) so mixed scenarios don't hide warnings behind each other. ### B2: smoke test caveat After "Smoke test passed", append three lines explaining that the test only verifies OCP direct connectivity, and instructing the user to restart OpenClaw and send a real bot message to verify end-to-end. ### B3: suppress /dev/tty stderr Wrap all four `read ... </dev/tty` calls as `{ read ... </dev/tty; } 2>/dev/null || fallback` so the brace group captures the shell's own `Device not configured` error before it reaches the user's terminal. The fallback semantics are unchanged. ### B6: help text wording Change item 5 from old wording mentioning IDE auto-configuration to "Configures OpenClaw automatically; prints setup hints for other IDEs - manual configuration required". Detection logic itself is NOT changed in this commit. That is deferred to a future PR. See #12 section 9 for details. ### Version bump Per dev iron rule #5 (version bump before push): * `OCP_CONNECT_VERSION="1.1.0"` constant * `--version` CLI flag * Banner shows version ## Test evidence End-to-end verified on macOS 13 with the actual OpenClaw 2026.4.11 + Telegram bot setup that exposed #12: 1. cold-install state (clean ~/.openclaw, no agent profiles) 2. run `ocp-connect 172.16.2.30 --key ocp_xxx` -> exit 0 3. verify both `~/.openclaw/agents/main/agent/auth-profiles.json` and `~/.openclaw/agents/macbook_bot/agent/auth-profiles.json` are written with mode 0600 and the real key 4. restart gateway -> `agent model: ocp/claude-sonnet-4-6` loads 5. send a real Telegram message -> bot responds via `ocp/claude-sonnet-4-6`, `[telegram] sendMessage ok`, `auth-state.json` updates `lastUsed` and shows `errorCount: 0, lastGood: ocp:default` 6. zero `No API key found` / 401 / lane-error events anywhere Offline test matrix (also all green): * anonymous mode -> warning prints, no agentDir write * corrupted existing auth-profiles.json -> backed up to .bak, rebuilt * no-tty execution -> 0 `Device not configured` leakage * `--help` -> new B6 wording * `--version` -> `ocp-connect 1.1.0` ## Code review Two independent reviewer subagents (opus, spec compliance + code quality) plus one blind implementer subagent (sonnet, control group). The blind implementer reproduced exactly the two MUST-FIX issues the code-quality reviewer flagged in the first draft (silent data loss on `except Exception`, misleading success on per-agent write failure), validating that the review process caught real non-obvious problems. Both must-fixes are addressed in this commit; re-tested green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ocp-connect): drop "with agentDir" from anonymous warning The message previously said "agents with agentDir" but B1.5 now also seeds agents that omit the field (their dir is derived). Reviewer feedback on PR #13. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: taodeng <taodeng@Taos-MBP> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
620 lines
22 KiB
Bash
Executable File
620 lines
22 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.1.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
|
|
|
|
# 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 ;;
|
|
--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 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 "$@"
|