mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Follow-up to #12 / #13. Fixes the IDE detection failures documented in issue #12 section 9.3 (IDE detection coverage matrix). ## What was broken PR-1 fixed the OpenClaw multi-agent auth bug but left section 9 of the report (IDE detection coverage) unaddressed. Empirical testing showed the script's "Other IDEs detected" section had a 25% effective hit rate: - Cline -> grep pattern was `grep -qi cline` but the actual VSCode extension ID is `saoudrizwan.claude-dev`. Hit rate 0%. - Continue.dev -> checked `~/.continue/config.json` but the directory is not created until the user opens the Continue panel for the first time, AND v1.x uses `config.yaml` not `config.json`. Hit rate 0% on a fresh install. - Cursor -> only checked `command -v cursor` and `~/.cursor`. Both fail when the user installs Cursor.app from the official dmg without enabling the optional shell command and without launching the app. brew cask installs work by side effect (it links the binary). Hit rate 100% via brew, 0% via dmg. - opencode -> not detected at all. opencode (https://opencode.ai) is one of the most popular AI CLI tools, missing entirely from the hint list. The hints themselves were also too sparse. Each one was a single line like `Cursor: Settings -> OpenAI Base URL = ...`, with no API key, no model IDs, no path to the actual settings page. Users had to guess. ## What this commit does ### Detection rewrite (Cline / Continue.dev / Cursor / opencode) Cline: prefer `code --list-extensions`, fall back to ls, match pattern `cline|saoudrizwan\.claude-dev`. Real-world Cline detection goes from 0% to 100%. Continue.dev: three-condition OR -- VSCode extension ID `continue.continue` OR `~/.continue/config.yaml` OR `~/.continue/config.json`. Catches all three real-world install states (extension installed but never opened / config.yaml / legacy config.json). Cursor: add `[[ -d "/Applications/Cursor.app" ]]` as a third fallback alongside `command -v cursor` and `~/.cursor`. Now detects Cursor regardless of how it was installed. opencode: NEW detection branch. Checks `command -v opencode`, `~/.opencode/bin/opencode`, and `~/.local/share/opencode`. Catches the official curl install path and any future package manager installs. ### Hint density improvements Each hint now includes: - The exact base URL to paste - The API key (truncated as `${key:0:8}...${key: -4}` if longer than 16 chars to avoid screenshot leakage; or "(none -- anonymous mode)" message when --key was not provided) - The list of available model IDs, extracted from the actual /v1/models response so the user does not have to guess - The exact menu path or config file location Continue.dev hint specifically shows the YAML snippet under the `models:` top-level key (per reviewer feedback) so the user can paste it directly without schema confusion. opencode hint explicitly says "not yet auto-configured by ocp-connect (PR follow-up)" so users know to use `opencode providers login openai` until a future PR adds direct auth.json writing. opencode does NOT read OPENAI_API_KEY env var (empirically verified) so the existing rc-file export strategy does not help it. ### NOT in scope (deferred) - opencode auto-configure (writing auth.json + opencode.json) -- schema needs more investigation, opencode model whitelist gets in the way of custom claude model IDs - Continue.dev auto-configure -- v1.x config.yaml schema is well enough known but parsing/merging YAML in bash without extra dependencies is awkward - Aider, Cody, Zed, etc. detection -- not in this PR scope These are future work tracked in #12 section 9.6. ### Version bump (per dev iron rule #5) OCP_CONNECT_VERSION 1.1.0 -> 1.2.0. ## Test evidence Offline matrix run on macOS 13 with VSCode 1.115.0, opencode 1.4.3, Cline 3.78.0, Continue.dev 1.2.22, Cursor 3.0.16: - All 4 IDEs installed -> all 4 hints fire with full detail - anonymous mode -> hints fire, key display reads "(none -- anonymous mode...)" instead of blank - HOME=tmpdir mock + only Cline mock dir -> Cline + Cursor (system app) hints fire, others suppressed - HOME=tmpdir mock + nothing -> only Cursor (system app) fires - --version -> "ocp-connect 1.2.0" - --help -> still shows the PR-1 wording for item 5 Hit rate matrix on the test machine (5/5 IDEs installed): | IDE | before PR-2a | after PR-2a | |--------------|--------------|-------------| | OpenClaw | YES (PR-1) | YES | | Cline | NO | YES | | Continue.dev | NO | YES | | Cursor | YES (brew) | YES | | opencode | NO | YES | Effective detection rate: 25% -> ~100%. ## Code review One independent opus reviewer (spec compliance + bash 3.2 compat + UX). Verdict: PASS WITH CONCERNS, 0 blockers. Two non-blocking suggestions both addressed in this commit: 1. Continue.dev YAML hint -- added `models:` top-level key so pasted snippet validates against Continue v1.x schema 2. anonymous mode -- key display now shows "(none -- anonymous mode; most external IDEs require a non-empty API Key)" instead of a blank field B1 (OpenClaw per-agent auth) section is intentionally untouched. Verified by diff line count: all changes fall in lines 347-440 (configure_ides function) + lines 9-13 (version constant). No edits inside the Python heredoc block. Co-authored-by: taodeng <taodeng@Taos-MBP> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
683 lines
24 KiB
Bash
Executable File
683 lines
24 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.2.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'}"; 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 "$@"
|