Skip to content
Go back

One-Click OpenClaw

Published: Feb 15, 2026
Updated: Feb 17, 2026
Vancouver, Canada

Kimi’s one-click installer looks simple, but it does more than unpack files.

It links to an existing OpenClaw gateway or provisions a new setup with runtime checks.

alt text

AI agent providers can ship dedicated setup pages like https://www.kimi.com/bot so users can launch one-click installers for specific tools, repositories, or CLIs.

That opens a new distribution model: track fast-rising projects on https://github.com/trending, then publish same-day ‘try it now’ pages around those repos.

Disclaimer: I am building my own AI agent, AgentX, and I plan to apply these ideas there.

AI agents will change software adoption because installation no longer has to be manual. Agents can run scripts, detect failures, and repair setup issues automatically.

Run this on your OpenClaw device, then click Done.

bash <(curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh) --bot-token <BOT_TOKEN>

https://cdn.kimi.com/kimi-claw/install.sh

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="${BASE_URL:-https://cdn.kimi.com/kimi-claw}"
TGZ_URL="${TGZ_URL:-$BASE_URL/kimi-claw-latest.tgz}"
SEARCH_TGZ_URL="${SEARCH_TGZ_URL:-$BASE_URL/openclaw-kimi-search-0.1.2.tgz}"

OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}"
NPM_BIN="${NPM_BIN:-npm}"
TARGET_DIR="${TARGET_DIR:-$HOME/.openclaw/extensions/kimi-claw}"
SEARCH_TARGET_DIR="${SEARCH_TARGET_DIR:-$HOME/.openclaw/extensions/kimi-search}"
SEARCH_PLUGIN_ENABLED="${SEARCH_PLUGIN_ENABLED:-0}"
OPENCLAW_CONFIG_PATH="${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"
DEFAULT_LOCAL_CONFIG_PATH="${DEFAULT_LOCAL_CONFIG_PATH:-$HOME/.kimi/kimi-claw/kimi-claw-config.json}"
DEFAULT_BRIDGE_URL="${OPENCLAW_DEFAULT_BRIDGE_URL:-wss://www.kimi.com/api-claw/bots/agent-ws}"
DEFAULT_KIMIAPI_HOST="${OPENCLAW_DEFAULT_KIMIAPI_HOST:-https://www.kimi.com/api-claw}"

BRIDGE_MODE="${BRIDGE_MODE:-acp}"
WS_URL="${WS_URL:-${OPENCLAW_BRIDGE_URL:-}}"
BOT_TOKEN="${BOT_TOKEN:-${OPENCLAW_BRIDGE_TOKEN:-}}"
KIMIAPI_HOST="${OPENCLAW_KIMIAPI_HOST:-${KIMIAPI_HOST:-$DEFAULT_KIMIAPI_HOST}}"
BRIDGE_USER_ID="${BRIDGE_USER_ID:-}"
BRIDGE_INSTANCE_ID="${BRIDGE_INSTANCE_ID:-connector-$(hostname)}"
BRIDGE_DEVICE_ID="${BRIDGE_DEVICE_ID:-$(hostname)}"

GATEWAY_URL="${GATEWAY_URL:-ws://127.0.0.1:18789}"
GATEWAY_TOKEN="${GATEWAY_TOKEN:-}"
GATEWAY_AGENT_ID="${GATEWAY_AGENT_ID:-main}"

RETRY_BASE_MS="${RETRY_BASE_MS:-1000}"
RETRY_MAX_MS="${RETRY_MAX_MS:-600000}"
RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-0}"
HISTORY_PENDING_TIMEOUT_MS="${HISTORY_PENDING_TIMEOUT_MS:-15000}"
LOG_ENABLED="0"

BRIDGE_CHECK_ENABLED="${BRIDGE_CHECK_ENABLED:-1}"
BRIDGE_CHECK_TIMEOUT_MS="${BRIDGE_CHECK_TIMEOUT_MS:-6000}"
BRIDGE_CHECK_SETTLE_MS="${BRIDGE_CHECK_SETTLE_MS:-800}"
GATEWAY_CHECK_ENABLED="${GATEWAY_CHECK_ENABLED:-1}"
GATEWAY_CHECK_TIMEOUT_MS="${GATEWAY_CHECK_TIMEOUT_MS:-8000}"
GATEWAY_CHECK_SETTLE_MS="${GATEWAY_CHECK_SETTLE_MS:-600}"
GATEWAY_CHECK_RETRIES="${GATEWAY_CHECK_RETRIES:-10}"
GATEWAY_CHECK_INTERVAL_MS="${GATEWAY_CHECK_INTERVAL_MS:-5000}"
GATEWAY_CHECK_INITIAL_DELAY_MS="${GATEWAY_CHECK_INITIAL_DELAY_MS:-5000}"
ALLOW_MISSING_BRIDGE_CONFIG="${ALLOW_MISSING_BRIDGE_CONFIG:-1}"
SETUP_DEFAULT_MODEL="${SETUP_DEFAULT_MODEL:-0}"
MODEL_ID="${MODEL_ID:-}"
HAS_BRIDGE_CONFIG="0"

BRIDGE_CHECK_STATUS="PENDING"
BRIDGE_CHECK_MESSAGE="not executed"
GATEWAY_CHECK_STATUS="PENDING"
GATEWAY_CHECK_MESSAGE="not executed"
GATEWAY_TOKEN_SOURCE="cli/env"

log() {
  printf "%b[install-oss]%b %s\n" "$COLOR_CYAN" "$COLOR_RESET" "$*"
}

init_colors() {
  COLOR_RESET=""
  COLOR_RED=""
  COLOR_GREEN=""
  COLOR_YELLOW=""
  COLOR_BLUE=""
  COLOR_CYAN=""
  COLOR_BOLD=""

  if [ -n "${NO_COLOR:-}" ]; then
    return
  fi
  if [ ! -t 1 ]; then
    return
  fi
  if ! command -v tput >/dev/null 2>&1; then
    return
  fi
  local ncolors
  ncolors="$(tput colors 2>/dev/null || printf "0")"
  if [ "${ncolors:-0}" -lt 8 ]; then
    return
  fi

  COLOR_RESET=$'\033[0m'
  COLOR_RED=$'\033[31m'
  COLOR_GREEN=$'\033[32m'
  COLOR_YELLOW=$'\033[33m'
  COLOR_BLUE=$'\033[34m'
  COLOR_CYAN=$'\033[36m'
  COLOR_BOLD=$'\033[1m'
}

section() {
  printf "%b\n" "${COLOR_BOLD}${COLOR_BLUE}== $* ==${COLOR_RESET}"
}

log_ok() {
  printf "%b[ok]%b %s\n" "$COLOR_GREEN" "$COLOR_RESET" "$*"
}

log_warn() {
  printf "%b[warn]%b %s\n" "$COLOR_YELLOW" "$COLOR_RESET" "$*"
}

log_error() {
  printf "%b[error]%b %s\n" "$COLOR_RED" "$COLOR_RESET" "$*" >&2
}

print_kv() {
  local key="$1"
  local value="$2"
  printf "  %-24s %s\n" "$key" "$value"
}

mask_secret() {
  local raw="$1"
  local n
  n=${#raw}
  if [ "$n" -eq 0 ]; then
    printf "(empty)"
    return
  fi
  if [ "$n" -le 6 ]; then
    printf "*** (%s chars)" "$n"
    return
  fi
  printf "%s...%s (%s chars)" "${raw:0:3}" "${raw:$((n-2)):2}" "$n"
}

mask_url_for_log() {
  local raw="$1"
  if [ -z "$raw" ]; then
    printf "(empty)"
    return
  fi
  case "$raw" in
    *\?*)
      printf "%s?***" "${raw%%\?*}"
      ;;
    *)
      printf "%s" "$raw"
      ;;
  esac
}

single_line() {
  printf "%s" "$1" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
}

render_status() {
  local status="$1"
  case "$status" in
    OK)
      printf "%bOK%b" "$COLOR_GREEN" "$COLOR_RESET"
      ;;
    FAILED)
      printf "%bFAILED%b" "$COLOR_RED" "$COLOR_RESET"
      ;;
    SKIPPED)
      printf "%bSKIPPED%b" "$COLOR_YELLOW" "$COLOR_RESET"
      ;;
    *)
      printf "%s" "$status"
      ;;
  esac
}

print_runtime_overview() {
  section "Install Inputs"
  print_kv "openclaw.bin" "$OPENCLAW_BIN"
  print_kv "npm.bin" "$NPM_BIN"
  print_kv "target.dir" "$TARGET_DIR"
  print_kv "openclaw.config" "$OPENCLAW_CONFIG_PATH"
  print_kv "package.url" "$(mask_url_for_log "$TGZ_URL")"
  print_kv "search.enabled" "$SEARCH_PLUGIN_ENABLED"
  if [ "$SEARCH_PLUGIN_ENABLED" = "1" ]; then
    print_kv "search.package.url" "$(mask_url_for_log "$SEARCH_TGZ_URL")"
    print_kv "search.target.dir" "$SEARCH_TARGET_DIR"
  fi
  print_kv "bridge.mode" "$BRIDGE_MODE"
  print_kv "bridge.url" "$(mask_url_for_log "$WS_URL")"
  print_kv "bridge.token" "$(mask_secret "$BOT_TOKEN")"
  print_kv "bridge.kimiapiHost" "$(mask_url_for_log "$KIMIAPI_HOST")"
  print_kv "gateway.url" "$(mask_url_for_log "$GATEWAY_URL")"
  print_kv "gateway.token" "$(mask_secret "$GATEWAY_TOKEN")"
  print_kv "gateway.token_source" "$GATEWAY_TOKEN_SOURCE"
  print_kv "gateway.agentId" "$GATEWAY_AGENT_ID"
  print_kv "retry.baseMs" "$RETRY_BASE_MS"
  print_kv "retry.maxMs" "$RETRY_MAX_MS"
  print_kv "retry.maxAttempts" "$RETRY_MAX_ATTEMPTS"
  print_kv "bridge.historyPendingTimeoutMs" "$HISTORY_PENDING_TIMEOUT_MS"
  print_kv "log.enabled" "$LOG_ENABLED"
  print_kv "check.bridge" "$BRIDGE_CHECK_ENABLED"
  print_kv "check.gateway" "$GATEWAY_CHECK_ENABLED"
  print_kv "allow.missing.bridge" "$ALLOW_MISSING_BRIDGE_CONFIG"
  print_kv "setup.defaultModel" "$SETUP_DEFAULT_MODEL"
  if [ -n "$MODEL_ID" ]; then
    print_kv "defaultModel.modelId" "$MODEL_ID"
  fi
  print_kv "check.gateway.retries" "$GATEWAY_CHECK_RETRIES"
  print_kv "check.gateway.interval" "${GATEWAY_CHECK_INTERVAL_MS}ms"
  print_kv "check.gateway.initial_delay" "${GATEWAY_CHECK_INITIAL_DELAY_MS}ms"
}

print_connectivity_summary() {
  local overall="OK"
  if [ "$BRIDGE_CHECK_STATUS" = "FAILED" ] || [ "$GATEWAY_CHECK_STATUS" = "FAILED" ]; then
    overall="FAILED"
  elif [ "$BRIDGE_CHECK_STATUS" = "SKIPPED" ] || [ "$GATEWAY_CHECK_STATUS" = "SKIPPED" ]; then
    overall="SKIPPED"
  fi
  section "Connectivity Summary"
  printf "  %-24s %s\n" "bridge.check" "$(render_status "$BRIDGE_CHECK_STATUS")"
  print_kv "bridge.detail" "$(single_line "$BRIDGE_CHECK_MESSAGE")"
  printf "  %-24s %s\n" "gateway.check" "$(render_status "$GATEWAY_CHECK_STATUS")"
  print_kv "gateway.detail" "$(single_line "$GATEWAY_CHECK_MESSAGE")"
  printf "  %-24s %s\n" "overall.connectivity" "$(render_status "$overall")"
}

print_bridge_troubleshooting() {
  section "Bridge Troubleshooting"
  log_warn "bridge auth/connectivity probe failed."
  log_warn "next-step commands:"
  log_warn "  curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh | \\"
  log_warn "    bash -s -- --ws-url \"$(mask_url_for_log "$WS_URL")\" --bot-token '<BOT_TOKEN>' --skip-bridge-check"
  log_warn "  $OPENCLAW_BIN config get \"plugins.entries.kimi-claw.config.bridge.url\""
  log_warn "  $OPENCLAW_BIN config get \"plugins.entries.kimi-claw.config.bridge.token\""
  log_warn "  verify bridge token/endpoint or retry later if bridge is temporarily busy"
}

print_gateway_troubleshooting() {
  section "Gateway Troubleshooting"
  log "local gateway handshake still failing after retries."
  log "next-step commands:"
  log "  $OPENCLAW_BIN gateway status"
  log "  $OPENCLAW_BIN gateway restart"
  log "  $OPENCLAW_BIN config get gateway.auth.token"
  log "  curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh | \\"
  log "    bash -s -- --ws-url \"$(mask_url_for_log "$WS_URL")\" --bot-token '<BOT_TOKEN>' --gateway-url \"$(mask_url_for_log "$GATEWAY_URL")\" --skip-gateway-check"

  if ! command -v "$OPENCLAW_BIN" >/dev/null 2>&1; then
    return
  fi

  local status_out status_rc
  set +e
  status_out="$($OPENCLAW_BIN gateway status 2>&1)"
  status_rc=$?
  set -e
  if [ "$status_rc" -eq 0 ] && [ -n "$status_out" ]; then
    log "gateway status output:"
    while IFS= read -r line; do
      log "  $line"
    done <<<"$status_out"
  elif [ "$status_rc" -ne 0 ]; then
    log "failed to run '$OPENCLAW_BIN gateway status': $status_out"
  fi
}

usage() {
  cat <<'USAGE'
Usage:
  curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh | bash -s -- [options]

Optional:
  --ws-url <ws_url>              Bridge server WebSocket URL (alias: --bridge-url)
                                 default: wss://www.kimi.com/api-claw/bots/agent-ws
  --bot-token <token>            Kimi bot token (X-Kimi-Bot-Token)
  --tgz-url <url|s3://...>       Override package URL
  --target-dir <path>            Install directory for plugin
  --with-search-plugin           Install kimi-search plugin together (default: disabled)
  --search-tgz-url <url|s3://...> Override kimi-search package URL
  --search-target-dir <path>     Install directory for kimi-search plugin
  --skip-search-plugin           Explicitly disable kimi-search plugin install
  --bridge-mode <acp>            Default: acp
  --bridge-user-id <user_id>     Deprecated: ignored (ACP-only)
  --kimiapi-host <url>           Default: https://www.kimi.com/api-claw
  --gateway-url <ws_url>         Default: ws://127.0.0.1:18789
  --gateway-token <token>        If omitted, tries openclaw gateway.auth.token
  --agent-id <id>                Default: main
  --retry-base-ms <ms>           Default: 1000
  --retry-max-ms <ms>            Default: 600000
  --retry-max-attempts <n>       Default: 0
  --log-enabled <true|false>     Enable connector trace logs (default: false)
  --skip-bridge-check            Skip bridge ws auth/connectivity probe
  --bridge-check-timeout-ms <n>  Default: 6000
  --bridge-check-settle-ms <n>   Default: 800
  --skip-gateway-check           Skip local gateway handshake probe
  --skip-connectivity-checks     Skip bridge+gateway checks and allow missing bridge runtime config
  --allow-missing-bridge-config  Allow install without explicit --ws-url/--bot-token (default enabled)
  --setup-default-model          Enable auto-configure of default model (1P only)
  --model-id <id>                Override default model ID (default: k2p5)
  --gateway-check-timeout-ms <n> Default: 8000
  --gateway-check-settle-ms <n>  Default: 600
  --gateway-check-retries <n>    Default: 10
  --gateway-check-interval-ms <n> Default: 5000
  --gateway-check-initial-delay-ms <n> Default: 5000
  -h, --help

Examples:
  curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh | \
    bash -s --

  curl -fsSL https://cdn.kimi.com/kimi-claw/install.sh | \
    bash -s -- --bridge-url wss://bridge.example.com/acp --bot-token sk_live_xxx --with-search-plugin --skip-gateway-check
USAGE
}

parse_bool_flag() {
  local raw="$1"
  local normalized
  normalized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]')"
  case "$normalized" in
    1|true|yes|on)
      printf "1"
      return 0
      ;;
    0|false|no|off)
      printf "0"
      return 0
      ;;
    *)
      return 1
      ;;
  esac
}

require_cmd() {
  local cmd="$1"
  local hint="$2"
  if ! command -v "$cmd" >/dev/null 2>&1; then
    log_error "missing command: $cmd"
    log_error "$hint"
    exit 1
  fi
}

download_to_file() {
  local src="$1"
  local dest="$2"
  case "$src" in
    s3://*)
      require_cmd aws "install aws cli and configure credentials/profile first"
      AWS_REQUEST_CHECKSUM_CALCULATION=when_required \
      AWS_RESPONSE_CHECKSUM_VALIDATION=when_required \
      aws s3 cp "$src" "$dest" >/dev/null
      ;;
    *)
      require_cmd curl "install curl then retry"
      curl -fsSL "$src" -o "$dest"
      ;;
  esac
}

sleep_ms() {
  local ms="$1"
  local seconds
  seconds="$(awk "BEGIN { printf \"%.3f\", (${ms} / 1000) }")"
  sleep "$seconds"
}

check_bridge_ws_auth() {
  local output rc
  set +e
  output=$(
    {
      cd "$TARGET_DIR" && \
        WS_URL="$WS_URL" \
        BOT_TOKEN="$BOT_TOKEN" \
        BRIDGE_CHECK_TIMEOUT_MS="$BRIDGE_CHECK_TIMEOUT_MS" \
        BRIDGE_CHECK_SETTLE_MS="$BRIDGE_CHECK_SETTLE_MS" \
        node --input-type=module - <<'NODE'
import WebSocket from "ws";

const url = String(process.env.WS_URL ?? "").trim();
const token = String(process.env.BOT_TOKEN ?? "");
const timeoutMs = Number(process.env.BRIDGE_CHECK_TIMEOUT_MS ?? "6000");
const settleMs = Number(process.env.BRIDGE_CHECK_SETTLE_MS ?? "800");
const headerName = "X-Kimi-Bot-Token";

if (!url) {
  console.error("missing ws url");
  process.exit(2);
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  console.error("invalid BRIDGE_CHECK_TIMEOUT_MS");
  process.exit(2);
}
if (!Number.isFinite(settleMs) || settleMs < 0) {
  console.error("invalid BRIDGE_CHECK_SETTLE_MS");
  process.exit(2);
}

let done = false;
let opened = false;
let settled = false;
let settleTimer = null;

const finish = (ok, message) => {
  if (done) {
    return;
  }
  done = true;
  clearTimeout(overallTimer);
  if (settleTimer) {
    clearTimeout(settleTimer);
  }
  if (ok) {
    console.log(message);
    process.exit(0);
  }
  console.error(message);
  process.exit(1);
};

const headers = token ? { [headerName]: token } : {};
const ws = new WebSocket(url, {
  headers,
  handshakeTimeout: timeoutMs,
});

const overallTimer = setTimeout(() => {
  finish(false, `timeout after ${timeoutMs}ms`);
}, timeoutMs + settleMs + 500);

ws.on("unexpected-response", (_req, res) => {
  const status = Number(res?.statusCode ?? 0);
  if (status === 401 || status === 403) {
    finish(false, `auth rejected by bridge (http ${status})`);
    return;
  }
  finish(false, `unexpected http response ${status || "unknown"}`);
});

ws.on("open", () => {
  opened = true;
  settleTimer = setTimeout(() => {
    settled = true;
    try {
      ws.close(1000, "probe done");
    } catch {
      // ignore
    }
    finish(true, `ws connected and auth accepted (${url})`);
  }, settleMs);
});

ws.on("close", (code, reason) => {
  const reasonText = reason?.toString() || "";

  if (code === 1013) {
    finish(
      true,
      `bridge reachable and auth passed, but server is busy (close=${code}${reasonText ? ` ${reasonText}` : ""})`,
    );
    return;
  }

  if (!opened) {
    if (code === 4001 || code === 1008) {
      finish(false, `auth rejected by bridge (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
      return;
    }
    finish(false, `connection closed before websocket open (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
    return;
  }

  if (!settled) {
    if (code === 4001 || code === 1008) {
      finish(false, `auth rejected by bridge (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
      return;
    }
    finish(false, `connection dropped during settle window (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
  }
});

ws.on("error", (err) => {
  const msg = err instanceof Error ? err.message : String(err);
  if (/401|403|unauthor|forbidden/i.test(msg)) {
    finish(false, `auth rejected by bridge (${msg})`);
    return;
  }
  finish(false, `websocket connect error (${msg})`);
});
NODE
    } 2>&1
  )
  rc=$?
  set -e
  BRIDGE_CHECK_MESSAGE="$output"
  [ "$rc" -eq 0 ]
}

check_gateway_ws_handshake() {
  local output rc
  set +e
  output=$(
    {
      cd "$TARGET_DIR" && \
        GATEWAY_URL="$GATEWAY_URL" \
        GATEWAY_TOKEN="$GATEWAY_TOKEN" \
        GATEWAY_CHECK_TIMEOUT_MS="$GATEWAY_CHECK_TIMEOUT_MS" \
        GATEWAY_CHECK_SETTLE_MS="$GATEWAY_CHECK_SETTLE_MS" \
        node --input-type=module - <<'NODE'
import WebSocket from "ws";

const url = String(process.env.GATEWAY_URL ?? "").trim();
const token = String(process.env.GATEWAY_TOKEN ?? "");
const timeoutMs = Number(process.env.GATEWAY_CHECK_TIMEOUT_MS ?? "8000");
const settleMs = Number(process.env.GATEWAY_CHECK_SETTLE_MS ?? "600");

if (!url) {
  console.error("missing gateway url");
  process.exit(2);
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  console.error("invalid GATEWAY_CHECK_TIMEOUT_MS");
  process.exit(2);
}
if (!Number.isFinite(settleMs) || settleMs < 0) {
  console.error("invalid GATEWAY_CHECK_SETTLE_MS");
  process.exit(2);
}

let done = false;
let opened = false;
let connectSent = false;
let settleTimer = null;

const finish = (ok, message) => {
  if (done) {
    return;
  }
  done = true;
  clearTimeout(overallTimer);
  if (settleTimer) {
    clearTimeout(settleTimer);
  }
  if (ok) {
    console.log(message);
    process.exit(0);
  }
  console.error(message);
  process.exit(1);
};

const ws = new WebSocket(url, {
  handshakeTimeout: timeoutMs,
});

const sendConnect = () => {
  if (connectSent || ws.readyState !== WebSocket.OPEN) {
    return;
  }
  connectSent = true;
  const params = {
    minProtocol: 3,
    maxProtocol: 3,
    client: {
      id: "gateway-client",
      version: "0.0.0",
      platform: process.platform,
      mode: "backend",
      displayName: "kimi-claw-installer",
    },
    role: "operator",
    scopes: ["operator.admin"],
    caps: ["tool-events"],
  };
  if (token) {
    params.auth = { token };
  }
  const frame = {
    type: "req",
    id: "connect",
    method: "connect",
    params,
  };
  try {
    ws.send(JSON.stringify(frame));
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    finish(false, `failed to send gateway connect frame (${msg})`);
  }
};

const overallTimer = setTimeout(() => {
  finish(false, `timeout after ${timeoutMs}ms`);
}, timeoutMs + settleMs + 500);

ws.on("unexpected-response", (_req, res) => {
  const status = Number(res?.statusCode ?? 0);
  finish(false, `unexpected http response ${status || "unknown"}`);
});

ws.on("open", () => {
  opened = true;
  setTimeout(sendConnect, 250);
});

ws.on("message", (data) => {
  let parsed;
  try {
    parsed = JSON.parse(data.toString());
  } catch {
    return;
  }
  if (!parsed || typeof parsed !== "object") {
    return;
  }
  if (parsed.type === "event" && parsed.event === "connect.challenge") {
    sendConnect();
    return;
  }
  if (parsed.type === "res" && parsed.id === "connect") {
    if (!parsed.ok) {
      const errMsg =
        parsed.error && typeof parsed.error === "object" && typeof parsed.error.message === "string"
          ? parsed.error.message
          : "connect rejected";
      finish(false, `gateway handshake rejected (${errMsg})`);
      return;
    }
    settleTimer = setTimeout(() => {
      try {
        ws.close(1000, "probe done");
      } catch {
        // ignore close error
      }
      finish(true, `gateway handshake complete (${url})`);
    }, settleMs);
  }
});

ws.on("close", (code, reason) => {
  if (done) {
    return;
  }
  const reasonText = reason?.toString() || "";
  if (!opened) {
    finish(false, `gateway connection closed before open (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
    return;
  }
  finish(false, `gateway connection closed during handshake (close=${code}${reasonText ? ` ${reasonText}` : ""})`);
});

ws.on("error", (err) => {
  const msg = err instanceof Error ? err.message : String(err);
  finish(false, `gateway websocket error (${msg})`);
});
NODE
    } 2>&1
  )
  rc=$?
  set -e
  GATEWAY_CHECK_MESSAGE="$output"
  [ "$rc" -eq 0 ]
}

cleanup_legacy_plugin_config() {
  local config_path="$OPENCLAW_CONFIG_PATH"
  if [ "${config_path#\~}" != "$config_path" ]; then
    config_path="$HOME${config_path#\~}"
  fi
  if [ ! -f "$config_path" ]; then
    return
  fi

  OPENCLAW_CONFIG_PATH="$config_path" TARGET_DIR="$TARGET_DIR" node - <<'NODE'
const fs = require("fs");
const path = require("path");

const expandUserPath = (value) => {
  if (typeof value !== "string") {
    return "";
  }
  if (value === "~") {
    return process.env.HOME || value;
  }
  if (value.startsWith("~/")) {
    return path.join(process.env.HOME || "", value.slice(2));
  }
  return value;
};

const configPath = path.resolve(expandUserPath(process.env.OPENCLAW_CONFIG_PATH || ""));
const targetDir = path.resolve(expandUserPath(process.env.TARGET_DIR || ""));
const legacyDir = path.resolve(path.dirname(targetDir), "openclaw-kimi-bridge-connector");

let payload;
try {
  payload = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
  process.exit(0);
}

if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
  process.exit(0);
}

let changed = false;
const plugins =
  payload.plugins && typeof payload.plugins === "object" && !Array.isArray(payload.plugins)
    ? payload.plugins
    : {};
if (payload.plugins !== plugins) {
  payload.plugins = plugins;
  changed = true;
}

const entries = plugins.entries;
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
  if (Object.prototype.hasOwnProperty.call(entries, "openclaw-kimi-bridge-connector")) {
    delete entries["openclaw-kimi-bridge-connector"];
    changed = true;
  }
}

const installs = plugins.installs;
if (installs && typeof installs === "object" && !Array.isArray(installs)) {
  if (Object.prototype.hasOwnProperty.call(installs, "openclaw-kimi-bridge-connector")) {
    delete installs["openclaw-kimi-bridge-connector"];
    changed = true;
  }
}

const load =
  plugins.load && typeof plugins.load === "object" && !Array.isArray(plugins.load)
    ? plugins.load
    : {};
if (plugins.load !== load) {
  plugins.load = load;
  changed = true;
}

const rawPaths = Array.isArray(load.paths) ? load.paths : [];
if (!Array.isArray(load.paths)) {
  changed = true;
}

const normalized = [];
const seen = new Set();
for (const raw of rawPaths) {
  if (typeof raw !== "string") {
    changed = true;
    continue;
  }
  const resolved = path.resolve(expandUserPath(raw));
  if (resolved === legacyDir) {
    changed = true;
    continue;
  }
  if (seen.has(resolved)) {
    changed = true;
    continue;
  }
  seen.add(resolved);
  normalized.push(resolved);
}

if (!seen.has(targetDir)) {
  normalized.push(targetDir);
  changed = true;
}

if (!Array.isArray(load.paths) || load.paths.length !== normalized.length || load.paths.some((value, index) => value !== normalized[index])) {
  load.paths = normalized;
  changed = true;
}

  if (changed) {
    fs.writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`);
  }
NODE
}

sync_search_plugin_api_key_from_local_config() {
  local config_path="$DEFAULT_LOCAL_CONFIG_PATH"
  if [ "${config_path#\~}" != "$config_path" ]; then
    config_path="$HOME${config_path#\~}"
  fi

  if [ ! -f "$config_path" ]; then
    log_warn "local config not found, skip kimiPluginAPIKey fallback: $config_path"
    return
  fi

  local sync_result
  sync_result="$(
    LOCAL_CFG="$config_path" node - <<'NODE'
const fs = require("fs");

const configPath = process.env.LOCAL_CFG || "";
if (!configPath) {
  process.stdout.write("invalid_path");
  process.exit(0);
}

try {
  const raw = fs.readFileSync(configPath, "utf8");
  const payload = JSON.parse(raw);
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    process.stdout.write("invalid_json_shape");
    process.exit(0);
  }

  const bridge =
    payload.bridge && typeof payload.bridge === "object" && !Array.isArray(payload.bridge)
      ? payload.bridge
      : {};
  if (payload.bridge !== bridge) {
    payload.bridge = bridge;
  }

  const pluginApiKey =
    typeof bridge.kimiPluginAPIKey === "string" ? bridge.kimiPluginAPIKey.trim() : "";
  if (pluginApiKey) {
    process.stdout.write("already_set");
    process.exit(0);
  }

  const codeApiKey =
    typeof bridge.kimiCodeAPIKey === "string" ? bridge.kimiCodeAPIKey.trim() : "";
  if (!codeApiKey) {
    process.stdout.write("missing_kimi_code_api_key");
    process.exit(0);
  }

  bridge.kimiPluginAPIKey = codeApiKey;
  fs.writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`);
  process.stdout.write("updated");
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  process.stdout.write(`error:${message}`);
}
NODE
  )"

  case "$sync_result" in
    updated)
      log "synced bridge.kimiPluginAPIKey from bridge.kimiCodeAPIKey in $config_path"
      ;;
    already_set)
      log "bridge.kimiPluginAPIKey already set in $config_path"
      ;;
    missing_kimi_code_api_key)
      log_warn "bridge.kimiPluginAPIKey is empty and bridge.kimiCodeAPIKey is missing in $config_path"
      ;;
    invalid_json_shape)
      log_warn "local config is not a JSON object; skip kimiPluginAPIKey fallback: $config_path"
      ;;
    invalid_path)
      log_warn "invalid local config path; skip kimiPluginAPIKey fallback"
      ;;
    error:*)
      log_warn "failed to sync kimiPluginAPIKey in $config_path: $(single_line "${sync_result#error:}")"
      ;;
    *)
      log_warn "unexpected kimiPluginAPIKey sync result ($sync_result); continuing"
      ;;
  esac
}

init_colors

while [ "$#" -gt 0 ]; do
  case "$1" in
    --ws-url|--bridge-url)
      WS_URL="${2:-}"
      shift 2
      ;;
    --bot-token)
      BOT_TOKEN="${2:-}"
      shift 2
      ;;
    --tgz-url)
      TGZ_URL="${2:-}"
      shift 2
      ;;
    --target-dir)
      TARGET_DIR="${2:-}"
      shift 2
      ;;
    --with-search-plugin)
      SEARCH_PLUGIN_ENABLED="1"
      shift
      ;;
    --search-tgz-url)
      SEARCH_TGZ_URL="${2:-}"
      shift 2
      ;;
    --search-target-dir)
      SEARCH_TARGET_DIR="${2:-}"
      shift 2
      ;;
    --skip-search-plugin)
      SEARCH_PLUGIN_ENABLED="0"
      shift
      ;;
    --bridge-mode)
      BRIDGE_MODE="${2:-}"
      shift 2
      ;;
    --bridge-user-id)
      BRIDGE_USER_ID="${2:-}"
      log_warn "--bridge-user-id is deprecated and ignored (ACP-only mode)"
      shift 2
      ;;
    --kimiapi-host)
      KIMIAPI_HOST="${2:-}"
      shift 2
      ;;
    --gateway-url)
      GATEWAY_URL="${2:-}"
      shift 2
      ;;
    --gateway-token)
      GATEWAY_TOKEN="${2:-}"
      shift 2
      ;;
    --agent-id)
      GATEWAY_AGENT_ID="${2:-}"
      shift 2
      ;;
    --retry-base-ms)
      RETRY_BASE_MS="${2:-}"
      shift 2
      ;;
    --retry-max-ms)
      RETRY_MAX_MS="${2:-}"
      shift 2
      ;;
    --retry-max-attempts)
      RETRY_MAX_ATTEMPTS="${2:-}"
      shift 2
      ;;
    --log-enabled)
      LOG_ENABLED="${2:-}"
      shift 2
      ;;
    --skip-bridge-check)
      BRIDGE_CHECK_ENABLED="0"
      shift
      ;;
    --bridge-check-timeout-ms)
      BRIDGE_CHECK_TIMEOUT_MS="${2:-}"
      shift 2
      ;;
    --bridge-check-settle-ms)
      BRIDGE_CHECK_SETTLE_MS="${2:-}"
      shift 2
      ;;
    --skip-gateway-check)
      GATEWAY_CHECK_ENABLED="0"
      shift
      ;;
    --skip-connectivity-checks)
      BRIDGE_CHECK_ENABLED="0"
      GATEWAY_CHECK_ENABLED="0"
      ALLOW_MISSING_BRIDGE_CONFIG="1"
      shift
      ;;
    --allow-missing-bridge-config)
      ALLOW_MISSING_BRIDGE_CONFIG="1"
      shift
      ;;
    --setup-default-model)
      SETUP_DEFAULT_MODEL="1"
      shift
      ;;
    --model-id)
      MODEL_ID="${2:-}"
      shift 2
      ;;
    --gateway-check-timeout-ms)
      GATEWAY_CHECK_TIMEOUT_MS="${2:-}"
      shift 2
      ;;
    --gateway-check-settle-ms)
      GATEWAY_CHECK_SETTLE_MS="${2:-}"
      shift 2
      ;;
    --gateway-check-retries)
      GATEWAY_CHECK_RETRIES="${2:-}"
      shift 2
      ;;
    --gateway-check-interval-ms)
      GATEWAY_CHECK_INTERVAL_MS="${2:-}"
      shift 2
      ;;
    --gateway-check-initial-delay-ms)
      GATEWAY_CHECK_INITIAL_DELAY_MS="${2:-}"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      log_error "unknown argument: $1"
      usage
      exit 1
      ;;
  esac
done

LOG_ENABLED_RAW="$LOG_ENABLED"
if ! LOG_ENABLED="$(parse_bool_flag "$LOG_ENABLED_RAW")"; then
  log_error "invalid --log-enabled value: '$LOG_ENABLED_RAW' (expected true/false)"
  usage
  exit 1
fi

if [ -z "$WS_URL" ] || [ -z "$BOT_TOKEN" ]; then
  if [ -f "$DEFAULT_LOCAL_CONFIG_PATH" ]; then
    log "loading missing parameters from local config: $DEFAULT_LOCAL_CONFIG_PATH"
    if [ -z "$WS_URL" ]; then
      WS_URL="$(LOCAL_CFG="$DEFAULT_LOCAL_CONFIG_PATH" node - <<'NODE'
const fs = require("fs");
try {
  const c = JSON.parse(fs.readFileSync(process.env.LOCAL_CFG, "utf-8"));
  const v = c && c.bridge && typeof c.bridge.url === "string" ? c.bridge.url.trim() : "";
  process.stdout.write(v);
} catch { /* ignore */ }
NODE
      )" || true
      if [ -n "$WS_URL" ]; then
        log "bridge.url loaded from local config: $(mask_url_for_log "$WS_URL")"
      fi
    fi
    if [ -z "$BOT_TOKEN" ]; then
      BOT_TOKEN="$(LOCAL_CFG="$DEFAULT_LOCAL_CONFIG_PATH" node - <<'NODE'
const fs = require("fs");
try {
  const c = JSON.parse(fs.readFileSync(process.env.LOCAL_CFG, "utf-8"));
  const v = c && c.bridge && typeof c.bridge.token === "string" ? c.bridge.token.trim() : "";
  process.stdout.write(v);
} catch { /* ignore */ }
NODE
      )" || true
      if [ -n "$BOT_TOKEN" ]; then
        log "bridge.token loaded from local config: $(mask_secret "$BOT_TOKEN")"
      fi
    fi
  fi
fi

if [ -z "$BOT_TOKEN" ]; then
  _cfg_path="$OPENCLAW_CONFIG_PATH"
  if [ "${_cfg_path#\~}" != "$_cfg_path" ]; then
    _cfg_path="$HOME${_cfg_path#\~}"
  fi
  if [ -f "$_cfg_path" ]; then
    log "loading missing bridge.token from existing openclaw plugin config: $_cfg_path"
    if [ -z "$BOT_TOKEN" ]; then
      BOT_TOKEN="$(OPENCLAW_CFG="$_cfg_path" node - <<'NODE'
const fs = require("fs");
try {
  const c = JSON.parse(fs.readFileSync(process.env.OPENCLAW_CFG, "utf-8"));
  const entries = c && c.plugins && c.plugins.entries;
  if (!entries) { process.exit(0); }
  const cfg = (entries["kimi-claw"] && entries["kimi-claw"].config)
    || (entries["openclaw-kimi-bridge-connector"] && entries["openclaw-kimi-bridge-connector"].config);
  if (!cfg || !cfg.bridge) { process.exit(0); }
  const v = typeof cfg.bridge.token === "string" ? cfg.bridge.token.trim() : "";
  process.stdout.write(v);
} catch { /* ignore */ }
NODE
      )" || true
      if [ -n "$BOT_TOKEN" ]; then
        log "bridge.token loaded from existing openclaw config: $(mask_secret "$BOT_TOKEN")"
      fi
    fi
  fi
fi

if [ -z "$WS_URL" ]; then
  WS_URL="$DEFAULT_BRIDGE_URL"
  log "bridge.url not provided; using default: $(mask_url_for_log "$WS_URL")"
fi

if [ -n "$WS_URL" ]; then
  HAS_BRIDGE_CONFIG="1"
fi

if [ "$HAS_BRIDGE_CONFIG" = "1" ] && [ -z "$BOT_TOKEN" ] && [ "$BRIDGE_CHECK_ENABLED" = "1" ]; then
  log_warn "bridge.token not set; skip bridge auth/connectivity probe"
  BRIDGE_CHECK_ENABLED="0"
  BRIDGE_CHECK_MESSAGE="skipped because bridge.token is empty"
fi

if [ "$HAS_BRIDGE_CONFIG" != "1" ]; then
  if [ "$ALLOW_MISSING_BRIDGE_CONFIG" = "1" ]; then
    log_warn "bridge.url missing; continuing install without bridge runtime config"
    BRIDGE_CHECK_ENABLED="0"
    BRIDGE_CHECK_MESSAGE="skipped because bridge.url is empty"
  else
    log_error "missing required argument: --ws-url (not found in local config either)"
    usage
    exit 1
  fi
fi

if [ "$BRIDGE_MODE" != "acp" ]; then
  log_error "invalid --bridge-mode: $BRIDGE_MODE (ACP-only; expected acp)"
  exit 1
fi

if [ "${TARGET_DIR#\~}" != "$TARGET_DIR" ]; then
  TARGET_DIR="$HOME${TARGET_DIR#\~}"
fi
if [ "${SEARCH_TARGET_DIR#\~}" != "$SEARCH_TARGET_DIR" ]; then
  SEARCH_TARGET_DIR="$HOME${SEARCH_TARGET_DIR#\~}"
fi
if [ "${OPENCLAW_CONFIG_PATH#\~}" != "$OPENCLAW_CONFIG_PATH" ]; then
  OPENCLAW_CONFIG_PATH="$HOME${OPENCLAW_CONFIG_PATH#\~}"
fi

require_cmd tar "install tar then retry"
require_cmd "$NPM_BIN" "install Node.js + npm then retry"
require_cmd "$OPENCLAW_BIN" "install OpenClaw CLI then retry"
require_cmd node "install Node.js then retry"

# Previous versions of this installer moved old plugin dirs to `${TARGET_DIR}.bak.<timestamp>`.
# Those backups live under `~/.openclaw/extensions` and get discovered as duplicate plugins,
# which makes *every* `openclaw` command print duplicate-plugin warnings. Migrate them out of
# the extensions scan path before running any OpenClaw CLI commands.
BACKUP_STASH_ROOT="${BACKUP_STASH_ROOT:-$HOME/.openclaw/extensions-backups}"
BACKUP_STASH_DIR="${BACKUP_STASH_DIR:-$BACKUP_STASH_ROOT/kimi-claw}"
RUN_TS="$(date +%Y%m%d%H%M%S)"
mkdir -p "$BACKUP_STASH_DIR"
shopt -s nullglob
for legacy in "${TARGET_DIR}".bak.*; do
  if [ -e "$legacy" ]; then
    dest="$BACKUP_STASH_DIR/$(basename "$legacy").migrated.${RUN_TS}.$$"
    if mv "$legacy" "$dest"; then
      log_warn "migrated legacy backup out of extensions: $legacy -> $dest"
    else
      log_warn "failed to migrate legacy backup (permission/lock?): $legacy"
    fi
  fi
done
shopt -u nullglob

# If we're reinstalling over an existing install, stop it first to avoid bridge session conflicts.
if [ -e "$TARGET_DIR" ]; then
  log_warn "existing install detected; disabling connector before reinstall: $TARGET_DIR"
  "$OPENCLAW_BIN" plugins disable "kimi-claw" >/dev/null 2>&1 || true
  "$OPENCLAW_BIN" plugins disable "openclaw-kimi-bridge-connector" >/dev/null 2>&1 || true
  if "$OPENCLAW_BIN" gateway restart >/dev/null 2>&1; then
    log "gateway restarted to release existing bridge session"
    sleep 1
  fi
fi

if [ "$SEARCH_PLUGIN_ENABLED" = "1" ] && [ -e "$SEARCH_TARGET_DIR" ]; then
  log_warn "existing kimi-search install detected; disabling before reinstall: $SEARCH_TARGET_DIR"
  "$OPENCLAW_BIN" plugins disable "kimi-search" >/dev/null 2>&1 || true
fi

TMP_DIR="$(mktemp -d)"
cleanup() {
  rm -rf "$TMP_DIR"
}
trap cleanup EXIT

section "Package Fetch"
log "downloading package archive"
download_to_file "$TGZ_URL" "$TMP_DIR/plugin.tgz"

section "Package Extract"
log "extracting package archive"
tar -xzf "$TMP_DIR/plugin.tgz" -C "$TMP_DIR"
SRC_DIR="$TMP_DIR/package"
if [ ! -f "$SRC_DIR/package.json" ]; then
  log_error "invalid tgz content (missing package/package.json)"
  exit 1
fi

mkdir -p "$(dirname "$TARGET_DIR")"
if [ -e "$TARGET_DIR" ]; then
  BACKUP_DIR="$BACKUP_STASH_DIR/$(basename "$TARGET_DIR").bak.${RUN_TS}.$$"
  mv "$TARGET_DIR" "$BACKUP_DIR"
  log "existing target moved to backup (outside extensions scan path): $BACKUP_DIR"
fi
mkdir -p "$TARGET_DIR"
cp -R "$SRC_DIR"/. "$TARGET_DIR"/

section "Dependency Setup"
log "installing npm dependencies"
(cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev)

PLUGIN_NAME="kimi-claw"
if [ -f "$TARGET_DIR/openclaw.plugin.json" ]; then
  PLUGIN_ID="$(node - "$TARGET_DIR/openclaw.plugin.json" <<'NODE'
const fs = require("fs");

const filePath = process.argv[2];
if (!filePath) {
  process.stdout.write("");
  process.exit(0);
}

try {
  const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
  const value = payload && typeof payload.id === "string" ? payload.id : "";
  process.stdout.write(value);
} catch {
  process.stdout.write("");
}
NODE
)"
  if [ -n "$PLUGIN_ID" ]; then
    PLUGIN_NAME="$PLUGIN_ID"
  fi
fi

if [ -z "$GATEWAY_TOKEN" ]; then
  # `openclaw config get` may print config warnings/UI to stdout when duplicate plugins exist.
  # Keep only the last line as the actual token value.
  GATEWAY_TOKEN="$($OPENCLAW_BIN config get gateway.auth.token 2>/dev/null | tail -n 1 | tr -d '\r\n' || true)"
  if [ -n "$GATEWAY_TOKEN" ]; then
    GATEWAY_TOKEN_SOURCE="openclaw.gateway.auth.token"
  else
    GATEWAY_TOKEN_SOURCE="empty"
  fi
fi

print_runtime_overview

section "Bridge Check"
if [ "$BRIDGE_CHECK_ENABLED" = "1" ]; then
  log "checking bridge websocket auth/connectivity"
  if check_bridge_ws_auth; then
    BRIDGE_CHECK_STATUS="OK"
    log_ok "$BRIDGE_CHECK_MESSAGE"
  else
    BRIDGE_CHECK_STATUS="FAILED"
    log_error "$BRIDGE_CHECK_MESSAGE"
    print_bridge_troubleshooting
    GATEWAY_CHECK_STATUS="SKIPPED"
    GATEWAY_CHECK_MESSAGE="not executed because bridge check failed"
    print_connectivity_summary
    exit 1
  fi
else
  BRIDGE_CHECK_STATUS="SKIPPED"
  if [ "$BRIDGE_CHECK_MESSAGE" = "not executed" ]; then
    BRIDGE_CHECK_MESSAGE="skipped by --skip-bridge-check"
  fi
  log_warn "bridge websocket auth/connectivity check skipped"
fi

CONFIG_JSON=""
if [ "$HAS_BRIDGE_CONFIG" = "1" ]; then
CONFIG_JSON="$(
BRIDGE_MODE="$BRIDGE_MODE" \
WS_URL="$WS_URL" \
BOT_TOKEN="$BOT_TOKEN" \
KIMIAPI_HOST="$KIMIAPI_HOST" \
BRIDGE_INSTANCE_ID="$BRIDGE_INSTANCE_ID" \
BRIDGE_DEVICE_ID="$BRIDGE_DEVICE_ID" \
GATEWAY_URL="$GATEWAY_URL" \
GATEWAY_TOKEN="$GATEWAY_TOKEN" \
GATEWAY_AGENT_ID="$GATEWAY_AGENT_ID" \
RETRY_BASE_MS="$RETRY_BASE_MS" \
RETRY_MAX_MS="$RETRY_MAX_MS" \
RETRY_MAX_ATTEMPTS="$RETRY_MAX_ATTEMPTS" \
HISTORY_PENDING_TIMEOUT_MS="$HISTORY_PENDING_TIMEOUT_MS" \
LOG_ENABLED="$LOG_ENABLED" \
SETUP_DEFAULT_MODEL="$SETUP_DEFAULT_MODEL" \
MODEL_ID="$MODEL_ID" \
node - <<'NODE'
const parseIntStrict = (name) => {
  const raw = String(process.env[name] ?? "").trim();
  if (!/^-?\d+$/.test(raw)) {
    console.error(`invalid integer value for ${name}: ${raw}`);
    process.exit(1);
  }
  return Number(raw);
};
const parseBool = (name, defaultValue) => {
  const raw = String(process.env[name] ?? "").trim().toLowerCase();
  if (["1", "true", "yes", "on"].includes(raw)) {
    return true;
  }
  if (["0", "false", "no", "off"].includes(raw)) {
    return false;
  }
  return defaultValue;
};

const cfg = {
  bridge: {
    mode: process.env.BRIDGE_MODE,
    url: process.env.WS_URL,
    kimiapiHost: process.env.KIMIAPI_HOST,
    instanceId: process.env.BRIDGE_INSTANCE_ID,
    deviceId: process.env.BRIDGE_DEVICE_ID,
    historyPendingTimeoutMs: parseIntStrict("HISTORY_PENDING_TIMEOUT_MS"),
  },
  gateway: {
    url: process.env.GATEWAY_URL,
    agentId: process.env.GATEWAY_AGENT_ID,
  },
  retry: {
    baseMs: parseIntStrict("RETRY_BASE_MS"),
    maxMs: parseIntStrict("RETRY_MAX_MS"),
    maxAttempts: parseIntStrict("RETRY_MAX_ATTEMPTS"),
  },
  log: {
    enabled: parseBool("LOG_ENABLED", false),
  },
};

if (process.env.BOT_TOKEN) {
  cfg.bridge.token = process.env.BOT_TOKEN;
}

if (process.env.GATEWAY_TOKEN) {
  cfg.gateway.token = process.env.GATEWAY_TOKEN;
}

if (process.env.SETUP_DEFAULT_MODEL === "1") {
  cfg.defaultModel = { enabled: true };
  if (process.env.MODEL_ID) {
    cfg.defaultModel.modelId = process.env.MODEL_ID;
  }
}

process.stdout.write(JSON.stringify(cfg));
NODE
)"
fi

cleanup_legacy_plugin_config

section "Plugin Configure"
log "linking and enabling plugin"
$OPENCLAW_BIN plugins install -l "$TARGET_DIR" || true
$OPENCLAW_BIN plugins enable "$PLUGIN_NAME" || true

if [ "$HAS_BRIDGE_CONFIG" = "1" ]; then
  log "writing plugin config"
  $OPENCLAW_BIN config set "plugins.entries.$PLUGIN_NAME.config" --json "$CONFIG_JSON"
else
  log_warn "bridge.url not set; skip updating plugins.entries.$PLUGIN_NAME.config"
  log_warn "add ~/.kimi/kimi-claw/kimi-claw-config.json later, then restart gateway/plugin"
fi

if [ "$SEARCH_PLUGIN_ENABLED" = "1" ]; then
  section "Search Plugin"
  log "downloading kimi-search package archive"
  download_to_file "$SEARCH_TGZ_URL" "$TMP_DIR/search-plugin.tgz"

  log "extracting kimi-search package archive"
  SEARCH_TMP_DIR="$TMP_DIR/search-package"
  rm -rf "$SEARCH_TMP_DIR"
  mkdir -p "$SEARCH_TMP_DIR"
  tar -xzf "$TMP_DIR/search-plugin.tgz" -C "$SEARCH_TMP_DIR"
  SEARCH_SRC_DIR="$SEARCH_TMP_DIR/package"
  if [ ! -f "$SEARCH_SRC_DIR/package.json" ]; then
    log_error "invalid kimi-search tgz content (missing package/package.json)"
    exit 1
  fi

  mkdir -p "$(dirname "$SEARCH_TARGET_DIR")"
  if [ -e "$SEARCH_TARGET_DIR" ]; then
    SEARCH_BACKUP_DIR="$BACKUP_STASH_DIR/$(basename "$SEARCH_TARGET_DIR").bak.${RUN_TS}.$$"
    mv "$SEARCH_TARGET_DIR" "$SEARCH_BACKUP_DIR"
    log "existing kimi-search target moved to backup: $SEARCH_BACKUP_DIR"
  fi
  mkdir -p "$SEARCH_TARGET_DIR"
  cp -R "$SEARCH_SRC_DIR"/. "$SEARCH_TARGET_DIR"/

  log "installing kimi-search npm dependencies"
  (cd "$SEARCH_TARGET_DIR" && "$NPM_BIN" install --omit=dev)

  SEARCH_PLUGIN_NAME="kimi-search"
  if [ -f "$SEARCH_TARGET_DIR/openclaw.plugin.json" ]; then
    SEARCH_PLUGIN_ID="$(node - "$SEARCH_TARGET_DIR/openclaw.plugin.json" <<'NODE'
const fs = require("fs");

const filePath = process.argv[2];
if (!filePath) {
  process.stdout.write("");
  process.exit(0);
}

try {
  const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
  const value = payload && typeof payload.id === "string" ? payload.id : "";
  process.stdout.write(value);
} catch {
  process.stdout.write("");
}
NODE
)"
    if [ -n "$SEARCH_PLUGIN_ID" ]; then
      SEARCH_PLUGIN_NAME="$SEARCH_PLUGIN_ID"
    fi
  fi

  if [ "$SETUP_DEFAULT_MODEL" = "1" ]; then
    sync_search_plugin_api_key_from_local_config
  else
    log "skip kimiPluginAPIKey sync because --setup-default-model is disabled"
  fi

  log "linking and enabling kimi-search plugin"
  $OPENCLAW_BIN plugins install -l "$SEARCH_TARGET_DIR" || true
  $OPENCLAW_BIN plugins enable "$SEARCH_PLUGIN_NAME" || true
else
  section "Search Plugin"
  log_warn "skip installing kimi-search plugin (--skip-search-plugin)"
fi

section "Gateway Check"
log "restarting OpenClaw gateway"
if $OPENCLAW_BIN gateway restart; then
  log_ok "gateway restart succeeded"
else
  log "gateway restart failed (please restart manually); continuing with handshake probe"
fi

if [ "$GATEWAY_CHECK_ENABLED" = "1" ]; then
  # Enforce friendly polling defaults: >=5s interval, <=10 attempts.
  retries="$GATEWAY_CHECK_RETRIES"
  case "$retries" in
    ''|*[!0-9]*)
      retries=10
      ;;
  esac
  if [ "${retries:-0}" -lt 1 ] 2>/dev/null; then
    retries=1
  fi
  if [ "${retries:-0}" -gt 10 ] 2>/dev/null; then
    retries=10
  fi
  GATEWAY_CHECK_RETRIES="$retries"

  interval_ms="$GATEWAY_CHECK_INTERVAL_MS"
  case "$interval_ms" in
    ''|*[!0-9]*)
      interval_ms=5000
      ;;
  esac
  if [ "${interval_ms:-0}" -lt 5000 ] 2>/dev/null; then
    interval_ms=5000
  fi
  GATEWAY_CHECK_INTERVAL_MS="$interval_ms"
  interval_s="$(awk "BEGIN { printf \"%.0f\", (${interval_ms} / 1000) }")"

  log "checking local gateway websocket handshake (poll every ${interval_s}s, up to ${retries} attempts)"
  if [ "${GATEWAY_CHECK_INITIAL_DELAY_MS:-0}" -gt 0 ] 2>/dev/null; then
    delay_s="$(awk "BEGIN { printf \"%.0f\", (${GATEWAY_CHECK_INITIAL_DELAY_MS} / 1000) }")"
    log "waiting ${delay_s}s before first gateway check"
    sleep_ms "$GATEWAY_CHECK_INITIAL_DELAY_MS"
  fi
  attempt=1
  while [ "$attempt" -le "$retries" ]; do
    if check_gateway_ws_handshake; then
      GATEWAY_CHECK_STATUS="OK"
      log_ok "$GATEWAY_CHECK_MESSAGE"
      break
    fi
    if [ "$attempt" -lt "$retries" ]; then
      log "gateway not ready yet (${attempt}/${retries}); waiting ${interval_s}s before retry"
      sleep_ms "$GATEWAY_CHECK_INTERVAL_MS"
    fi
    attempt=$((attempt + 1))
  done
  if [ "$GATEWAY_CHECK_STATUS" != "OK" ]; then
    GATEWAY_CHECK_STATUS="FAILED"
    log_error "$GATEWAY_CHECK_MESSAGE"
    print_gateway_troubleshooting
    print_connectivity_summary
    exit 1
  fi
else
  GATEWAY_CHECK_STATUS="SKIPPED"
  GATEWAY_CHECK_MESSAGE="skipped by --skip-gateway-check"
  log "local gateway handshake check skipped (--skip-gateway-check)"
fi

print_connectivity_summary
section "Done"
if [ "$BRIDGE_CHECK_STATUS" = "OK" ] && [ "$GATEWAY_CHECK_STATUS" = "OK" ]; then
  log_ok "install completed with bridge+gateway checks passed"
else
  log "install completed (one or more checks skipped)"
fi
if [ "$SEARCH_PLUGIN_ENABLED" = "1" ]; then
  log "plugins=$PLUGIN_NAME,$SEARCH_PLUGIN_NAME ws_url=$(mask_url_for_log "$WS_URL") target_dir=$TARGET_DIR search_target_dir=$SEARCH_TARGET_DIR"
else
  log "plugins=$PLUGIN_NAME ws_url=$(mask_url_for_log "$WS_URL") target_dir=$TARGET_DIR"
fi
(This example is complete, it can be run "as is")

How It Works

This is a production-grade Bash installer script for installing and configuring the kimi-claw OpenClaw plugin, plus an optional kimi-search plugin, including:

  • Downloading packages
  • Installing dependencies
  • Writing configuration
  • Verifying remote bridge connectivity
  • Verifying local gateway handshake
  • Handling upgrades and legacy cleanup

It’s much more than a simple installer - it performs full environment validation and runtime health checks.


1. Most Surprising Part: It Installs and Verifies Runtime Health

The installer does not stop at ‘files copied successfully.’

It actively validates both network paths before declaring success:

  1. Remote bridge auth check (check_bridge_ws_auth) to confirm bot token and WebSocket auth really work.
  2. Local gateway handshake check (check_gateway_ws_handshake) with retries to confirm the gateway is actually ready.

If either fails, the script exits non-zero and prints troubleshooting output. That is unusual for a one-click installer and closer to deployment health gating.


2. Safety Mode Is Only the Start

set -euo pipefail is expected in careful Bash scripts, but here it is paired with strict command checks (tar, npm, node, openclaw) and explicit input parsing.

The surprising part is not strict mode by itself. It is that strict mode is combined with operational checks, so bad state is rejected early instead of silently tolerated.


3. Config Hydration and Migration Are Built In

If flags like --ws-url or --bot-token are missing, the script loads prior values from local config files. It also removes legacy plugin references and migrates stale state.

That means re-installs behave like controlled upgrades, not fresh blind installs.


4. Install Step Includes State Management

Package download and extraction are straightforward, but the script also handles existing plugin directories carefully and keeps backups out of extension scan paths to avoid duplicate-plugin noise.

This is a practical reliability detail most installer scripts skip.


5. What This Script Really Is

This is an install-plus-validate workflow:

  1. Parse args and hydrate config
  2. Validate required runtime and clean legacy state
  3. Download/install plugin package
  4. Write OpenClaw config and enable plugin
  5. Restart gateway and run connectivity probes
  6. Fail fast on runtime errors, or print healthy summary

In practice, this behaves more like a lightweight deployment pipeline than a typical install.sh.

Content Attribution: 90% by Alpha, 10% by Claude
  • 90% by Alpha: Original draft and core concepts
  • 10% by Claude: Content editing and refinement
  • Note: Estimated 10% AI contribution based on 95% lexical similarity and 0% content condensation.