#!/usr/bin/env bash # KingClaw — one-line installer + pairing # Usage: # curl -fsSL https://get.kingclaw.app | bash # install only # curl -fsSL https://get.kingclaw.app | bash -s -- kcpair-xxx # install + pair # # The main() wrapper protects against partial downloads via `curl | bash`. set -euo pipefail PAIRING_API="https://pair.kingclaw.app" PLUGIN_CDN="https://get.kingclaw.app/plugin/latest.tar.gz" PLUGIN_ID="kingclaw" # ── Colors ────────────────────────────────────────────────── if [[ -t 1 ]]; then BOLD='\033[1m' CYAN='\033[1;36m' GREEN='\033[1;32m' YELLOW='\033[1;33m' RED='\033[1;31m' RESET='\033[0m' else BOLD='' CYAN='' GREEN='' YELLOW='' RED='' RESET='' fi log() { printf "${CYAN}▸ %s${RESET}\n" "$*"; } ok() { printf "${GREEN}✔ %s${RESET}\n" "$*"; } warn() { printf "${YELLOW}⚠ %s${RESET}\n" "$*"; } err() { printf "${RED}✖ %s${RESET}\n" "$*" >&2; } die() { err "$*"; fail_pairing "$*"; exit 1; } # ── Pairing helpers ───────────────────────────────────────── PAIRING_TOKEN="" fail_pairing() { if [[ -n "$PAIRING_TOKEN" ]]; then curl -sf -X POST "${PAIRING_API}/pair/${PAIRING_TOKEN}/fail" \ -H "Content-Type: application/json" \ -d "{\"error\":\"$1\"}" >/dev/null 2>&1 || true fi } read_openclaw_config_raw() { local path="$1" node -e ' const fs = require("fs"); const path = process.argv[1].split("."); const candidates = [ `${process.env.HOME}/.openclaw/openclaw.json`, `${process.env.HOME}/.config/openclaw/openclaw.json`, ]; let config = null; for (const file of candidates) { try { config = JSON.parse(fs.readFileSync(file, "utf8")); break; } catch {} } if (!config) process.exit(1); let current = config; for (const part of path) { if (!current || typeof current !== "object" || !(part in current)) { process.exit(1); } current = current[part]; } if (current == null) process.exit(1); process.stdout.write(typeof current === "object" ? JSON.stringify(current) : String(current)); ' "$path" } is_missing_or_redacted() { local value="${1:-}" [[ -z "$value" || "$value" == "null" || "$value" == "undefined" || "$value" == *"REDACTED"* ]] } complete_pairing() { local gw_url="$1" gateway_token="$2" device_token="$3" gw_name="$4" plugin_ver="$5" net_warn="$6" local relay_url="${7:-}" server_id="${8:-}" plugin_url="${9:-}" local payload payload=$(node -e ' const [gwUrl, gatewayToken, deviceToken, gwName, pluginVer, netWarn, relayUrl, serverId, pluginUrl] = process.argv.slice(1); const body = { gatewayUrl: gwUrl, gatewayToken, gatewayName: gwName, pluginVersion: pluginVer, networkWarning: netWarn === "true", }; if (deviceToken) { body.deviceToken = deviceToken; body.botToken = deviceToken; } if (pluginUrl) body.pluginUrl = pluginUrl; if (relayUrl) body.relayUrl = relayUrl; if (serverId) body.serverId = serverId; process.stdout.write(JSON.stringify(body)); ' "$gw_url" "$gateway_token" "$device_token" "$gw_name" "$plugin_ver" "$net_warn" "$relay_url" "$server_id" "$plugin_url") local resp resp=$(curl -sf -X POST "${PAIRING_API}/pair/${PAIRING_TOKEN}/complete" -H "Content-Type: application/json" -d "$payload" 2>&1) || { warn "Pairing callback failed (non-fatal)" return 0 } if echo "$resp" | grep -q '"ok"'; then ok "Pairing completed — client will auto-connect" else warn "Pairing response: $resp" fi } # ── Detect private IP ─────────────────────────────────────── is_private_ip() { local ip="$1" # 127.x / 10.x / 172.16-31.x / 192.168.x / ::1 / fd00::/8 echo "$ip" | grep -qE '^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|::1|fd[0-9a-f]{2}:)' 2>/dev/null } # ── Detect Gateway URL ────────────────────────────────────── detect_gateway_url() { local bind_port bind_port=$(openclaw config get gateway.port 2>/dev/null || echo "3456") # 1. Try remote.url first (explicitly configured public/tailscale URL) local remote_url remote_url=$(openclaw config get gateway.remote.url 2>/dev/null || echo "") if [[ -n "$remote_url" && "$remote_url" != "null" && "$remote_url" != "undefined" ]]; then echo "$remote_url" return fi # 2. Try Tailscale IP (best for cross-network access) if command -v tailscale >/dev/null 2>&1; then local ts_ip ts_ip=$(tailscale ip -4 2>/dev/null || echo "") if [[ -n "$ts_ip" ]]; then echo "ws://${ts_ip}:${bind_port}" return fi fi # 3. Fall back to local bind config local bind_host bind_host=$(openclaw config get gateway.bind 2>/dev/null | grep -oE '[0-9.]+' | head -1 || echo "127.0.0.1") # "lan" / 0.0.0.0 → detect LAN IP if [[ "$bind_host" == "0.0.0.0" || "$bind_host" == "lan" ]]; then local machine_ip="" # Linux if command -v hostname >/dev/null 2>&1; then machine_ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "") fi # macOS fallback if [[ -z "$machine_ip" ]]; then machine_ip=$(ipconfig getifaddr en0 2>/dev/null || echo "") fi if [[ -n "$machine_ip" ]]; then bind_host="$machine_ip" else bind_host="127.0.0.1" fi fi echo "ws://${bind_host}:${bind_port}" } # ── Main ──────────────────────────────────────────────────── main() { printf "\n${BOLD} 🦁 KingClaw Plugin Installer${RESET}\n\n" # Parse args local token="" local update_only=false while [[ $# -gt 0 ]]; do case "$1" in kcpair-*) token="$1"; shift ;; --token) token="${2:-}"; shift 2 ;; --update) update_only=true; shift ;; -h|--help) echo "Usage: curl -fsSL https://get.kingclaw.app | bash -s -- [kcpair-TOKEN]" echo "" echo "Options:" echo " kcpair-TOKEN Pairing token from KingClaw client" echo " --token TOKEN Explicit pairing token" echo " --update Update plugin only, skip config" echo " -h, --help Show this help" exit 0 ;; *) shift ;; esac done PAIRING_TOKEN="$token" # ── Step 1: Check OpenClaw ────────────────────────────── log "Checking OpenClaw installation..." if ! command -v openclaw >/dev/null 2>&1; then die "OpenClaw is not installed. Please install first: https://docs.openclaw.ai" fi local oc_ver oc_ver=$(openclaw --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "0.0.0") ok "OpenClaw CLI found: v${oc_ver} ($(which openclaw))" # Minimum version check (KingClaw plugin requires describeMessageTool API, 3.22+) local min_ver="2026.3.22" local ver_ok ver_ok=$(node -e " const [a,b] = ['${oc_ver}','${min_ver}'].map(v => v.split('.').map(Number)); for (let i = 0; i < 3; i++) { if ((a[i]||0) < (b[i]||0)) { console.log('old'); process.exit(); } if ((a[i]||0) > (b[i]||0)) { console.log('ok'); process.exit(); } } console.log('ok'); " 2>/dev/null || echo "ok") if [[ "$ver_ok" == "old" ]]; then warn "OpenClaw ${oc_ver} is older than ${min_ver}" warn "KingClaw plugin requires OpenClaw 2026.3.22 or later" warn "Upgrade: openclaw update (or: npm install -g openclaw@latest)" die "Please upgrade OpenClaw first, then re-run this installer" fi # Config validation pre-flight — auto-fix schema errors before install if ! openclaw config validate >/dev/null 2>&1; then warn "OpenClaw config has validation errors — attempting auto-fix..." # Step 1: try openclaw doctor --fix if openclaw doctor --fix >/dev/null 2>&1; then if openclaw config validate >/dev/null 2>&1; then ok "Config auto-fixed by openclaw doctor" fi fi # Step 2: if still broken, fix known issues manually if ! openclaw config validate >/dev/null 2>&1; then local config_file="${HOME}/.openclaw/openclaw.json" if [[ -f "$config_file" ]]; then log "Removing deprecated config keys..." cp "$config_file" "${config_file}.bak.kingclaw-install" # Remove known deprecated keys that break schema validation node -e " const fs = require('fs'); const cfg = JSON.parse(fs.readFileSync('${config_file}', 'utf8')); let fixed = false; // channels.*.allowlist → removed in 3.22+ for (const [ch, chCfg] of Object.entries(cfg.channels || {})) { if (chCfg && typeof chCfg === 'object' && 'allowlist' in chCfg) { delete chCfg.allowlist; fixed = true; } } // Remove any top-level unrecognized legacy keys const legacyKeys = ['whitelist', 'blacklist']; for (const key of legacyKeys) { if (key in cfg) { delete cfg[key]; fixed = true; } } if (fixed) { fs.writeFileSync('${config_file}', JSON.stringify(cfg, null, 2) + '\n'); console.log('fixed'); } else { console.log('no-fix'); } " 2>/dev/null if openclaw config validate >/dev/null 2>&1; then ok "Config auto-fixed (removed deprecated keys)" else warn "Could not fully auto-fix config" warn "Backup saved: ${config_file}.bak.kingclaw-install" warn "Try: openclaw doctor --fix or manually edit ${config_file}" die "Config validation still failing — fix manually, then re-run" fi fi fi fi # ── Step 2: Check if plugin already installed ─────────── local existing_ver="" local plugin_dir="${HOME}/.openclaw/extensions/${PLUGIN_ID}" if [[ -f "${plugin_dir}/openclaw.plugin.json" ]]; then existing_ver=$(node -p "require('${plugin_dir}/package.json').version" 2>/dev/null || echo "") if [[ -n "$existing_ver" ]]; then log "Existing plugin v${existing_ver} found — upgrading" rm -rf "${plugin_dir}" fi fi # ── Step 3: Download & install plugin ─────────────────── log "Downloading KingClaw plugin..." local tmpdir tmpdir=$(mktemp -d) trap 'rm -rf "${tmpdir:-}"' EXIT local tarball="${tmpdir}/kingclaw-plugin.tar.gz" if ! curl -fsSL -o "$tarball" "$PLUGIN_CDN"; then die "Failed to download plugin. Check network." fi ok "Downloaded plugin tarball" log "Installing plugin..." if ! openclaw plugins install "$tarball" 2>/dev/null; then # Fallback: manual extraction if plugins install fails (e.g. scanner false positive) log "CLI install failed, trying manual extraction..." mkdir -p "${plugin_dir}" tar xzf "$tarball" -C "${plugin_dir}/.." 2>/dev/null \ || die "Plugin extraction failed" if [[ -f "${plugin_dir}/openclaw.plugin.json" ]]; then ok "Plugin extracted manually to ${plugin_dir}" else die "Plugin extraction succeeded but files are missing" fi fi ok "Plugin files installed" if [[ "$update_only" == "true" ]]; then ok "Plugin updated. Config unchanged." return fi # ── Step 4: Configure ────────────────────────────────── log "Configuring Gateway..." # plugins.allow — append if missing local current_allow current_allow=$(openclaw config get plugins.allow --json 2>/dev/null || echo "[]") local needs_allow needs_allow=$(node -e " try { const arr = JSON.parse(process.argv[1]); console.log(Array.isArray(arr) && arr.includes('${PLUGIN_ID}') ? 'no' : 'yes'); } catch { console.log('yes'); } " "$current_allow") if [[ "$needs_allow" == "yes" ]]; then local new_allow new_allow=$(node -e " let arr; try { arr = JSON.parse(process.argv[1]); } catch { arr = []; } if (!Array.isArray(arr)) arr = []; arr.push('${PLUGIN_ID}'); console.log(JSON.stringify(arr)); " "$current_allow") openclaw config set plugins.allow "$new_allow" 2>/dev/null \ || warn "Could not update plugins.allow" fi # plugins.entries.kingclaw.enabled openclaw config set "plugins.entries.${PLUGIN_ID}.enabled" true 2>/dev/null \ || warn "Could not enable plugin" # Generate/read Plugin WS token if needed local device_token device_token=$(read_openclaw_config_raw "channels.${PLUGIN_ID}.token" 2>/dev/null || openclaw config get "channels.${PLUGIN_ID}.token" 2>/dev/null || echo "") if is_missing_or_redacted "$device_token"; then device_token=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") openclaw config set "channels.${PLUGIN_ID}.token" "$device_token" 2>/dev/null \ || warn "Could not set token" log "Generated plugin device token" else log "Using existing plugin device token" fi if [[ -n "$PAIRING_TOKEN" ]]; then # Pairing is a cross-device flow: the desktop client must be able to reach # the plugin WS endpoint advertised in the pairing result. openclaw config set "channels.${PLUGIN_ID}.wsHost" "0.0.0.0" 2>/dev/null \ || warn "Could not set plugin WS host" openclaw config set "channels.${PLUGIN_ID}.wsPort" 18900 2>/dev/null \ || warn "Could not set plugin WS port" openclaw config set "gateway.controlUi.dangerouslyDisableDeviceAuth" true 2>/dev/null \ || warn "Could not enable KingClaw desktop gateway access" local current_control_origins current_control_origins=$(openclaw config get gateway.controlUi.allowedOrigins --json 2>/dev/null || echo "[]") local next_control_origins next_control_origins=$(node -e " let arr; try { arr = JSON.parse(process.argv[1]); } catch { arr = []; } if (!Array.isArray(arr)) arr = []; const next = arr .filter((origin) => typeof origin === 'string' && origin.trim() && origin.trim() !== '*') .map((origin) => origin.trim()); if (!next.includes('https://desktop.kingclaw.app')) next.push('https://desktop.kingclaw.app'); console.log(JSON.stringify(next)); " "$current_control_origins") openclaw config set "gateway.controlUi.allowedOrigins" "$next_control_origins" 2>/dev/null \ || warn "Could not allow KingClaw desktop origin" fi # Read the Gateway auth token matching the published URL. local gateway_token local published_remote_url published_remote_url=$(openclaw config get gateway.remote.url 2>/dev/null || echo "") gateway_token="" if [[ -n "$published_remote_url" && "$published_remote_url" != "null" && "$published_remote_url" != "undefined" ]]; then gateway_token=$(read_openclaw_config_raw "gateway.remote.token" 2>/dev/null || openclaw config get "gateway.remote.token" 2>/dev/null || echo "") fi if is_missing_or_redacted "$gateway_token"; then gateway_token=$(read_openclaw_config_raw "gateway.auth.token" 2>/dev/null || openclaw config get "gateway.auth.token" 2>/dev/null || echo "") fi if is_missing_or_redacted "$gateway_token"; then gateway_token=$(node -e "console.log(require('crypto').randomBytes(24).toString('hex'))") openclaw config set "gateway.auth.token" "$gateway_token" 2>/dev/null \ || warn "Could not set gateway auth token" log "Generated gateway auth token" fi ok "Gateway configured" # ── Step 5: Check Gateway & reload ───────────────────── # Check if Gateway process is running (don't use `gateway status` — it can fail on config validation) local gw_pid gw_pid=$(pgrep -f "openclaw-gateway\|openclaw.*gateway" 2>/dev/null | head -1 || echo "") if [[ -z "$gw_pid" ]]; then log "Gateway is not running, attempting to start..." if openclaw gateway start 2>/dev/null; then ok "Gateway started" sleep 3 gw_pid=$(pgrep -f "openclaw-gateway\|openclaw.*gateway" 2>/dev/null | head -1 || echo "") else warn "Could not start Gateway automatically" warn "Start it manually: openclaw gateway start" fi fi if [[ -n "$gw_pid" ]]; then log "Gateway running (pid ${gw_pid}), waiting for plugin reload..." # Send SIGHUP to trigger config reload (if supported), otherwise just wait kill -HUP "$gw_pid" 2>/dev/null || true local loaded=false for _i in $(seq 1 15); do if openclaw status 2>/dev/null | grep -qi "kingclaw"; then loaded=true break fi sleep 2 done if [[ "$loaded" == "true" ]]; then ok "KingClaw plugin loaded!" else warn "Gateway didn't pick up plugin within 30s" warn "Try restarting: kill ${gw_pid} (LaunchAgent will auto-restart)" fi fi # ── Step 6: Detect Gateway URL + pairing callback ────── local gw_url gw_url=$(detect_gateway_url) log "Gateway URL: ${gw_url}" local plugin_url plugin_url=$(node -e ' const input = process.argv[1]; try { const url = new URL(input); url.protocol = url.protocol === "wss:" ? "wss:" : "ws:"; url.port = "18900"; url.pathname = "/"; url.search = ""; url.hash = ""; console.log(url.toString()); } catch { process.exit(1); } ' "$gw_url" 2>/dev/null || echo "") local net_warn="false" local url_host url_host=$(echo "$gw_url" | sed 's|.*://\([^:/]*\).*|\1|') if is_private_ip "$url_host"; then net_warn="true" warn "Gateway URL is a private IP — client must be on the same network or use Tailscale" fi # Read installed version local plugin_ver plugin_ver=$(node -p "require('${plugin_dir}/package.json').version" 2>/dev/null || echo "0.0.0") # Gateway display name local gw_name gw_name=$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo "OpenClaw Gateway") # ── Step 6b: Relay credentials (NAT traversal) ─────────── local relay_creds_file="$HOME/.openclaw/kingclaw-relay.json" local server_id relay_token relay_url if [[ -f "$relay_creds_file" ]]; then server_id=$(jq -r '.serverId' "$relay_creds_file") relay_token=$(jq -r '.relayToken' "$relay_creds_file") log "Reusing existing relay credentials (serverId: ${server_id})" else server_id=$(uuidgen | tr '[:upper:]' '[:lower:]') relay_token=$(uuidgen | tr '[:upper:]' '[:lower:]') mkdir -p "$(dirname "$relay_creds_file")" jq -n --arg sid "$server_id" --arg tok "$relay_token" \ '{"serverId":$sid,"relayToken":$tok}' > "$relay_creds_file" chmod 600 "$relay_creds_file" log "Generated relay credentials (serverId: ${server_id})" fi relay_url="wss://relay.kingclaw.app/client/${server_id}" # Write relay config to OpenClaw (Plugin picks this up on next account start) if command -v openclaw &>/dev/null; then openclaw config set "channels.kingclaw.relay.url" "wss://relay.kingclaw.app/gateway/${server_id}" 2>/dev/null || true openclaw config set "channels.kingclaw.relay.token" "${relay_token}" 2>/dev/null || true openclaw config set "channels.kingclaw.relay.serverId" "${server_id}" 2>/dev/null || true ok "Relay config written" fi # ── Pairing callback ─────────────────────────────────── if [[ -n "$PAIRING_TOKEN" ]]; then log "Completing pairing..." complete_pairing "$gw_url" "$gateway_token" "$device_token" "$gw_name" "$plugin_ver" "$net_warn" "$relay_url" "$server_id" "$plugin_url" fi # ── Done ──────────────────────────────────────────────── printf "\n${GREEN}${BOLD} ✅ KingClaw plugin is ready!${RESET}\n\n" echo " Gateway URL : ${gw_url}" echo " Relay URL : ${relay_url}" echo " Server ID : ${server_id}" echo " Plugin ver : ${plugin_ver}" if [[ -n "$PAIRING_TOKEN" ]]; then echo " Pairing : ${PAIRING_TOKEN}" echo "" echo " Your KingClaw client should auto-connect momentarily." if [[ "$net_warn" == "true" ]]; then echo " ℹ️ Gateway is behind NAT — relay will be used for external access." fi else echo "" echo " To connect from KingClaw client, use 'Add Server' button." fi echo "" } main "$@"