Skill: thedotmack/claude-mem (version 1.0.1)

# Claude-Mem OpenClaw Plugin — Setup Guide This guide walks through setting up the claude-mem plugin on an OpenClaw gateway. By the end, your agents will have persistent memory across sessions, a live-updating MEMORY.md in their workspace, and optionally a real-time observation feed streaming to a messaging channel. ## Quick Install (Recommended) Run this one-liner to install everything automatically: ```bash curl -fsSL https://install.cmem.ai/openclaw.sh | bash ``` The installer handles dependency checks (Bun, uv), plugin installation, memory slot configuration, AI provider setup, worker startup, and optional observation feed configuration — all interactively. ### Install with options Pre-select your AI provider and API key to skip interactive prompts: ```bash curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --[REDACTED_SECRET] ``` For fully unattended installation (defaults to Claude Max Plan, skips observation feed): ```bash curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --non-interactive ``` To upgrade an existing installation (preserves settings, updates plugin): ```bash curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --upgrade ``` After installation, skip to [Step 4: Restart the Gateway and Verify](#step-4-restart-the-gateway-and-verify) to confirm everything is working. --- ## Manual Setup The steps below are for manual installation if you prefer not to use the automated installer, or need to troubleshoot individual steps. ### Step 1: Clone the Claude-Mem Repo First, clone the claude-mem repository to a location accessible by your OpenClaw gateway. This gives you the worker service source and the plugin code. ```bash cd /opt # or wherever you want to keep it git clone https://github.com/thedotmack/claude-mem.git cd claude-mem npm install npm run build ``` You'll need **bun** installed for the worker service. If you don't have it: ```bash curl -fsSL https://bun.sh/install | bash ``` ### Step 2: Get the Worker Running The claude-mem worker is an HTTP service on port 37777. It stores observations, generates summaries, and serves the context timeline. The plugin talks to it over HTTP — it doesn't matter where the worker is running, just that it's reachable on localhost:37777. #### Check if it's already running If this machine also runs Claude Code with claude-mem installed, the worker may already be running: ```bash curl http://localhost:37777/api/health ``` **Got `{"status":"ok"}`?** The worker is already running. Skip to Step 3. **Got connection refused or no response?** The worker isn't running. Continue below. #### If Claude Code has claude-mem installed If claude-mem is installed as a Claude Code plugin (at `~/.claude/plugins/marketplaces/thedotmack/`), start the worker from that installation: ```bash cd ~/.claude/plugins/marketplaces/thedotmack npm run worker:restart ``` Verify: ```bash curl http://localhost:37777/api/health ``` **Got `{"status":"ok"}`?** You're set. Skip to Step 3. **Still not working?** Check `npm run worker:status` for error details, or check that bun is installed and on your PATH. #### If there's no Claude Code installation Run the worker from the cloned repo: ```bash cd /opt/claude-mem # wherever you cloned it npm run worker:start ``` Verify: ```bash curl http://localhost:37777/api/health ``` **Got `{"status":"ok"}`?** You're set. Move to Step 3. **Still not working?** Debug steps: - Check that bun is installed: `bun --version` - Check the worker status: `npm run worker:status` - Check if something else is using port 37777: `lsof -i :37777` - Check logs: `npm run worker:logs` (if available) - Try running it directly to see errors: `bun plugin/scripts/worker-service.cjs start` ### Step 3: Add the Plugin to Your Gateway Add the `claude-mem` plugin to your OpenClaw gateway configuration: ```json { "plugins": { "claude-mem": { "enabled": true, "config": { "project": "my-project", "syncMemoryFile": true, "workerPort": 37777 } } } } ``` #### Config fields explained - **`project`** (string, default: `"openclaw"`) — The project name that scopes all observations in the memory database. Use a unique name per gateway/use-case so observations don't mix. For example, if this gateway runs a coding bot, use `"coding-bot"`. - **`syncMemoryFile`** (boolean, default: `true`) — When enabled, the plugin writes a `MEMORY.md` file to each agent's workspace directory. This file contains the full timeline of observations and summaries from previous sessions, and it updates on every tool use so agents always have fresh context. Set to `false` only if you don't want the plugin writing files to agent workspaces. - **`workerPort`** (number, default: `37777`) — The port where the claude-mem worker service is listening. Only change this if you configured the worker to use a different port. --- ## Step 4: Restart the Gateway and Verify Restart your OpenClaw gateway so it picks up the new plugin configuration. After restart, check the gateway logs for: ``` [claude-mem] OpenClaw plugin loaded — v1.0.0 (worker: 127.0.0.1:37777) ``` If you see this, the plugin is loaded. You can also verify by running `/claude_mem_status` in any OpenClaw chat: ``` Claude-Mem Worker Status Status: ok Port: 37777 Active sessions: 0 Observation feed: disconnected ``` The observation feed shows `disconnected` because we haven't configured it yet. That's next. ## Step 5: Verify Observations Are Being Recorded Have an agent do some work. The plugin automatically records observations through these OpenClaw events: 1. **`before_agent_start`** — Initializes a claude-mem session when the agent starts, syncs MEMORY.md to the workspace 2. **`tool_result_persist`** — Records each tool use (Read, Write, Bash, etc.) as an observation, re-syncs MEMORY.md 3. **`agent_end`** — Summarizes the session and marks it complete All of this happens automatically. No additional configuration needed. To verify it's working, check the agent's workspace directory for a `MEMORY.md` file after the agent runs. It should contain a formatted timeline of observations. You can also check the worker's viewer UI at http://localhost:37777 to see observations appearing in real time. ## Step 6: Set Up the Observation Feed (Streaming to a Channel) The observation feed connects to the claude-mem worker's SSE (Server-Sent Events) stream and forwards every new observation to a messaging channel in real time. Your agents learn things, and you see them learning in your Telegram/Discord/Slack/etc. ### What you'll see Every time claude-mem creates a new observation from your agent's tool usage, a message like this appears in your channel: ``` 🧠 Claude-Mem Observation **Implemented retry logic for API client** Added exponential backoff with configurable max retries to handle transient failures ``` ### Pick your channel You need two things: - **Channel type** — Must match a channel plugin already running on your OpenClaw gateway - **Target ID** — The chat/channel/user ID where messages go #### Telegram Channel type: `telegram` To find your chat ID: 1. Message @userinfobot on Telegram — https://t.me/userinfobot 2. It replies with your numeric chat ID (e.g., `123456789`) 3. For group chats, the ID is negative (e.g., `-1001234567890`) ```json "observationFeed": { "enabled": true, "channel": "telegram", "to": "123456789" } ``` #### Discord Channel type: `discord` To find your channel ID: 1. Enable Developer Mode in Discord: Settings → Advanced → Developer Mode 2. Right-click the target channel → Copy Channel ID ```json "observationFeed": { "enabled": true, "channel": "discord", "to": "1234567890123456789" } ``` #### Slack Channel type: `slack` To find your channel ID (not the channel name): 1. Open the channel in Slack 2. Click the channel name at the top 3. Scroll to the bottom of the channel details — the ID looks like `C01ABC2DEFG` ```json "observationFeed": { "enabled": true, "channel": "slack", "to": "C01ABC2DEFG" } ``` #### Signal Channel type: `signal` Use the phone number or group ID configured in your OpenClaw gateway's Signal plugin. ```json "observationFeed": { "enabled": true, "channel": "signal", "to": "+1234567890" } ``` #### WhatsApp Channel type: `whatsapp` Use the phone number or group JID configured in your OpenClaw gateway's WhatsApp plugin. ```json "observationFeed": { "enabled": true, "channel": "whatsapp", "to": "+1234567890" } ``` #### LINE Channel type: `line` Use the user ID or group ID from the LINE Developer Console. ```json "observationFeed": { "enabled": true, "channel": "line", "to": "U1234567890abcdef" } ``` ### Add it to your config Your complete plugin config should now look like this (using Telegram as an example): ```json { "plugins": { "claude-mem": { "enabled": true, "config": { "project": "my-project", "syncMemoryFile": true, "workerPort": 37777, "observationFeed": { "enabled": true, "channel": "telegram", "to": "123456789" } } } } } ``` ### Restart and verify Restart the gateway. Check the logs for these three lines in order: ``` [claude-mem] Observation feed starting — channel: telegram, target: 123456789 [claude-mem] Connecting to SSE stream at http://localhost:37777/stream [claude-mem] Connected to SSE stream ``` Then run `/claude_mem_feed` in any OpenClaw chat: ``` Claude-Mem Observation Feed Enabled: yes Channel: telegram Target: 123456789 Connection: connected ``` If `Connection` shows `connected`, you're done. Have an agent do some work and watch observations stream to your channel. ## Commands Reference The plugin registers two commands: ### /claude_mem_status Reports worker health and current session state. ``` /claude_mem_status ``` Output: ``` Claude-Mem Worker Status Status: ok Port: 37777 Active sessions: 2 Observation feed: connected ``` ### /claude_mem_feed Shows observation feed status. Accepts optional `on`/`off` argument. ``` /claude_mem_feed — show status /claude_mem_feed on — request enable (update config to persist) /claude_mem_feed off — request disable (update config to persist) ``` ## How It All Works ``` OpenClaw Gateway │ ├── before_agent_start ──→ Sync MEMORY.md + Init session ├── tool_result_persist ──→ Record observation + Re-sync MEMORY.md ├── agent_end ────────────→ Summarize + Complete session └── gateway_start ────────→ Reset session tracking │ ▼ Claude-Mem Worker (localhost:37777) ├── POST /api/sessions/init ├── POST /api/sessions/observations ├── POST /api/sessions/summarize ├── POST /api/sessions/complete ├── GET /api/context/inject ──→ MEMORY.md content └── GET /stream ─────────────→ SSE → Messaging channels ``` ### MEMORY.md live sync The plugin writes `MEMORY.md` to each agent's workspace with the full observation timeline. It updates: - On every `before_agent_start` — agent gets fresh context before starting - On every `tool_result_persist` — context stays current as the agent works Updates are fire-and-forget (non-blocking). The agent is never held up waiting for MEMORY.md to write. ### Observation recording Every tool use (Read, Write, Bash, etc.) is sent to the claude-mem worker as an observation. The worker's AI agent processes it into a structured observation with title, subtitle, facts, concepts, and narrative. Tools prefixed with `memory_` are skipped to avoid recursive recording. ### Session lifecycle - **`before_agent_start`** — Creates a session in the worker, syncs MEMORY.md. Short prompts (under 10 chars) skip session init but still sync. - **`tool_result_persist`** — Records observation (fire-and-forget), re-syncs MEMORY.md (fire-and-forget). Tool responses are truncated to 1000 characters. - **`agent_end`** — Sends the last assistant message for summarization, then completes the session. Both fire-and-forget. - **`gateway_start`** — Clears all session tracking (session IDs, workspace mappings) so agents start fresh. ### Observation feed A background service connects to the worker's SSE stream and forwards `new_observation` events to a configured messaging channel. The connection auto-reconnects with exponential backoff (1s → 30s max). ## Troubleshooting | Problem | What to check | |---------|---------------| | Worker health check fails | Is bun installed? (`bun --version`). Is something else on port 37777? (`lsof -i :37777`). Try running directly: `bun plugin/scripts/worker-service.cjs start` | | Worker started from Claude Code install but not responding | Check `cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:status`. May need `npm run worker:restart`. | | Worker started from cloned repo but not responding | Check `cd /path/to/claude-mem && npm run worker:status`. Make sure you ran `npm install && npm run build` first. | | No MEMORY.md appearing | Check that `syncMemoryFile` is not set to `false`. Verify the agent's event context includes `workspaceDir`. | | Observations not being recorded | Check gateway logs for `[claude-mem]` messages. The worker must be running and reachable on localhost:37777. | | Feed shows `disconnected` | Worker's `/stream` endpoint not reachable. Check `workerPort` matches the actual worker port. | | Feed shows `reconnecting` | Connection dropped. The plugin auto-reconnects — wait up to 30 seconds. | | `Unknown channel type` in logs | The channel plugin (e.g., telegram) isn't loaded on your gateway. Make sure the channel is configured and running. | | `Observation feed disabled` in logs | Set `observationFeed.enabled` to `true` in your config. | | `Observation feed misconfigured` in logs | Both `observationFeed.channel` and `observationFeed.to` are required. | | No messages in channel despite `connected` | The feed only sends processed observations, not raw tool usage. There's a 1-2 second delay. Make sure the worker is actually processing observations (check http://localhost:37777). | ## Full Config Reference ```json { "plugins": { "claude-mem": { "enabled": true, "config": { "project": "openclaw", "syncMemoryFile": true, "workerPort": 37777, "observationFeed": { "enabled": false, "channel": "telegram", "to": "123456789" } } } } } ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `project` | string | `"openclaw"` | Project name scoping observations in the database | | `syncMemoryFile` | boolean | `true` | Write MEMORY.md to agent workspaces | | `workerPort` | number | `37777` | Claude-mem worker service port | | `observationFeed.enabled` | boolean | `false` | Stream observations to a messaging channel | | `observationFeed.channel` | string | — | Channel type: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `line` | | `observationFeed.to` | string | — | Target chat/channel/user ID |

path: install.sh
content: #!/usr/bin/env bash
set -euo pipefail

# claude-mem OpenClaw Plugin Installer
# Installs the claude-mem persistent memory plugin for OpenClaw gateways.
#
# Usage:
#   curl -fsSL https://install.cmem.ai/openclaw.sh | bash
#   # Or with options:
#   curl -fsSL https://install.cmem.ai/openclaw.sh | bash -s -- --provider=gemini --[REDACTED_SECRET]
#   # Direct execution:
#   bash install.sh [--non-interactive] [--upgrade] [--provider=claude|gemini|openrouter] [--api-key=KEY]

###############################################################################
# Constants
###############################################################################

readonly MIN_BUN_VERSION="1.1.14"
readonly INSTALLER_VERSION="1.0.0"

###############################################################################
# Argument parsing
###############################################################################

NON_INTERACTIVE=""
CLI_PROVIDER=""
CLI_API_KEY=""
UPGRADE_MODE=""
CLI_BRANCH=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --non-interactive)
      NON_INTERACTIVE="true"
      shift
      ;;
    --upgrade)
      UPGRADE_MODE="true"
      shift
      ;;
    --branch=*)
      CLI_BRANCH="${1#--branch=}"
      shift
      ;;
    --branch)
      CLI_BRANCH="${2:-}"
      shift 2
      ;;
    --provider=*)
      CLI_PROVIDER="${1#--provider=}"
      shift
      ;;
    --provider)
      CLI_PROVIDER="${2:-}"
      shift 2
      ;;
    --api-key=*)
      CLI_API_KEY="${1#--api-key=}"
      shift
      ;;
    --api-key)
      CLI_API_KEY="${2:-}"
      shift 2
      ;;
    *)
      shift
      ;;
  esac
done

###############################################################################
# TTY detection — ensure interactive prompts work under curl | bash
# When piped, stdin reads from curl's output, not the terminal.
# We open /dev/tty on fd 3 and read interactive input from there.
###############################################################################

TTY_FD=0

setup_tty() {
  if [[ -t 0 ]]; then
    # stdin IS a terminal — use it directly
    TTY_FD=0
  elif [[ -e /dev/tty ]]; then
    # stdin is piped (curl | bash) but /dev/tty is available
    exec 3</dev/tty
    TTY_FD=3
  else
    # No terminal available at all
    if [[ "$NON_INTERACTIVE" != "true" ]]; then
      echo "Error: No terminal available for interactive prompts." >&2
      echo "Use --non-interactive or run directly: bash install.sh" >&2
      exit 1
    fi
  fi
}

###############################################################################
# Color utilities — auto-detect terminal color support
###############################################################################

if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
  readonly COLOR_RED='\033[0;31m'
  readonly COLOR_GREEN='\033[0;32m'
  readonly COLOR_YELLOW='\033[0;33m'
  readonly COLOR_BLUE='\033[0;34m'
  readonly COLOR_MAGENTA='\033[0;35m'
  readonly COLOR_CYAN='\033[0;36m'
  readonly COLOR_BOLD='\033[1m'
  readonly COLOR_RESET='\033[0m'
else
  readonly COLOR_RED=''
  readonly COLOR_GREEN=''
  readonly COLOR_YELLOW=''
  readonly COLOR_BLUE=''
  readonly COLOR_MAGENTA=''
  readonly COLOR_CYAN=''
  readonly COLOR_BOLD=''
  readonly COLOR_RESET=''
fi

info()    { echo -e "${COLOR_BLUE}ℹ${COLOR_RESET}  $*"; }
success() { echo -e "${COLOR_GREEN}✓${COLOR_RESET}  $*"; }
warn()    { echo -e "${COLOR_YELLOW}⚠${COLOR_RESET}  $*"; }
error()   { echo -e "${COLOR_RED}✗${COLOR_RESET}  $*" >&2; }

prompt_user() {
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    error "Cannot prompt in non-interactive mode: $*"
    return 1
  fi
  echo -en "${COLOR_CYAN}?${COLOR_RESET}  $* "
}

# Read a line from the terminal (works even when stdin is piped from curl)
# Callers always pass -r via $@; shellcheck can't see through the delegation
read_tty() {
  # shellcheck disable=SC2162
  read "$@" <&"$TTY_FD"
}

###############################################################################
# Global cleanup trap — removes temp directories on unexpected exit
###############################################################################

CLEANUP_DIRS=()

register_cleanup_dir() {
  CLEANUP_DIRS+=("$1")
}

cleanup_on_exit() {
  local exit_code=$?
  for dir in "${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}"; do
    if [[ -d "$dir" ]]; then
      rm -rf "$dir"
    fi
  done
  if [[ $exit_code -ne 0 ]]; then
    echo "" >&2
    error "Installation failed (exit code: ${exit_code})"
    error "Any temporary files have been cleaned up."
    error "Fix the issue above and re-run the installer."
  fi
}

trap cleanup_on_exit EXIT

###############################################################################
# Prerequisite checks
###############################################################################

check_git() {
  if command -v git &>/dev/null; then
    return 0
  fi

  error "git is not installed"
  echo "" >&2
  case "${PLATFORM:-}" in
    macos)
      error "Install git on macOS with:"
      error "  xcode-select --install"
      error "  # or: brew install git"
      ;;
    linux)
      error "Install git on Linux with:"
      error "  sudo apt install git        # Debian/Ubuntu"
      error "  sudo dnf install git        # Fedora/RHEL"
      error "  sudo pacman -S git          # Arch"
      ;;
    *)
      error "Please install git and re-run this installer."
      ;;
  esac
  exit 1
}

###############################################################################
# Port conflict detection — check if port 37777 is already in use
###############################################################################

check_port_37777() {
  local port_in_use=""

  # Try lsof first (macOS/Linux)
  if command -v lsof &>/dev/null; then
    if lsof -i :37777 -sTCP:LISTEN &>/dev/null; then
      port_in_use="true"
    fi
  # Fallback to ss (Linux)
  elif command -v ss &>/dev/null; then
    if ss -tlnp 2>/dev/null | grep -q ':37777 '; then
      port_in_use="true"
    fi
  # Fallback to curl probe
  elif command -v curl &>/dev/null; then
    local response
    response="$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:37777/api/health" 2>/dev/null)" || true
    if [[ "$response" == "200" ]]; then
      port_in_use="true"
    fi
  fi

  if [[ "$port_in_use" == "true" ]]; then
    return 0  # port IS in use
  fi
  return 1  # port is free
}

###############################################################################
# Upgrade detection — check if claude-mem is already installed
###############################################################################

is_claude_mem_installed() {
  # Check if the plugin directory exists with the worker script
  if find_claude_mem_install_dir 2>/dev/null; then
    return 0
  fi
  return 1
}

###############################################################################
# JSON manipulation helper — jq with python3/node fallback
# Usage: ensure_jq_or_fallback <json_file> <jq_filter> [jq_args...]
# For simple read operations, returns the result on stdout.
# For write operations, updates the file in-place.
###############################################################################

ensure_jq_or_fallback() {
  local json_file="$1"
  shift
  local jq_filter="$1"
  shift
  # remaining args are passed as jq --arg pairs

  if command -v jq &>/dev/null; then
    local tmp_file
    tmp_file="$(mktemp)"
    jq "$@" "$jq_filter" "$json_file" > "$tmp_file" && mv "$tmp_file" "$json_file"
    return $?
  fi

  if command -v python3 &>/dev/null; then
    # For complex jq filters, fall back to node instead
    # Python is used only for simple operations
    :
  fi

  # Fallback to node (always available — it's a dependency)
  # This is a passthrough; callers that need node-specific logic
  # should use node -e directly. This function is for jq compatibility.
  warn "jq not found — using node for JSON manipulation"
  return 1
}

###############################################################################
# Parse /api/health JSON response — extract worker metadata into globals
# Uses jq → python3 → node fallback chain (matching installer conventions)
# Sets: WORKER_VERSION, WORKER_AI_PROVIDER, WORKER_AI_AUTH_METHOD,
#        WORKER_INITIALIZED, WORKER_REPORTED_PID, WORKER_UPTIME
###############################################################################

parse_health_json() {
  local raw_json="$1"

  # Reset all health globals before parsing
  WORKER_VERSION=""
  WORKER_AI_PROVIDER=""
  WORKER_AI_AUTH_METHOD=""
  WORKER_INITIALIZED=""
  WORKER_REPORTED_PID=""
  WORKER_UPTIME=""

  if [[ -z "$raw_json" ]]; then
    return 0
  fi

  # Try jq first (fastest, most reliable)
  if command -v jq &>/dev/null; then
    WORKER_VERSION="$(echo "$raw_json" | jq -r '.version // empty' 2>/dev/null)" || true
    WORKER_AI_PROVIDER="$(echo "$raw_json" | jq -r '.ai.provider // empty' 2>/dev/null)" || true
    WORKER_AI_AUTH_METHOD="$(echo "$raw_json" | jq -r '.ai.authMethod // empty' 2>/dev/null)" || true
    WORKER_INITIALIZED="$(echo "$raw_json" | jq -r '.initialized // empty' 2>/dev/null)" || true
    WORKER_REPORTED_PID="$(echo "$raw_json" | jq -r '.pid // empty' 2>/dev/null)" || true
    WORKER_UPTIME="$(echo "$raw_json" | jq -r '.uptime // empty' 2>/dev/null)" || true
    return 0
  fi

  # Try python3 fallback
  if command -v python3 &>/dev/null; then
    local parsed
    parsed="$(INSTALLER_HEALTH_JSON="$raw_json" python3 -c "
import json, os, sys
try:
    data = json.loads(os.environ['INSTALLER_HEALTH_JSON'])
    ai = data.get('ai') or {}
    fields = [
        str(data.get('version', '')),
        str(ai.get('provider', '')),
        str(ai.get('authMethod', '')),
        str(data.get('initialized', '')),
        str(data.get('pid', '')),
        str(data.get('uptime', '')),
    ]
    sys.stdout.write('\n'.join(fields))
except Exception:
    sys.stdout.write('\n\n\n\n\n')
" 2>/dev/null)" || true

    if [[ -n "$parsed" ]]; then
      local -a health_fields
      IFS=$'\n' read -r -d '' -a health_fields <<< "$parsed" || true
      WORKER_VERSION="${health_fields[0]:-}"
      WORKER_AI_PROVIDER="${health_fields[1]:-}"
      WORKER_AI_AUTH_METHOD="${health_fields[2]:-}"
      WORKER_INITIALIZED="${health_fields[3]:-}"
      WORKER_REPORTED_PID="${health_fields[4]:-}"
      WORKER_UPTIME="${health_fields[5]:-}"
      # Normalize python's None/empty representations
      [[ "$WORKER_VERSION" == "None" ]] && WORKER_VERSION=""
      [[ "$WORKER_AI_PROVIDER" == "None" ]] && WORKER_AI_PROVIDER=""
      [[ "$WORKER_AI_AUTH_METHOD" == "None" ]] && WORKER_AI_AUTH_METHOD=""
      [[ "$WORKER_INITIALIZED" == "None" ]] && WORKER_INITIALIZED=""
      [[ "$WORKER_REPORTED_PID" == "None" ]] && WORKER_REPORTED_PID=""
      [[ "$WORKER_UPTIME" == "None" ]] && WORKER_UPTIME=""
    fi
    return 0
  fi

  # Fallback to node (always available — it's a dependency)
  local parsed
  parsed="$(INSTALLER_HEALTH_JSON="$raw_json" node -e "
    try {
      const data = JSON.parse(process.env.INSTALLER_HEALTH_JSON);
      const ai = data.ai || {};
      const fields = [
        data.version ?? '',
        ai.provider ?? '',
        ai.authMethod ?? '',
        data.initialized != null ? String(data.initialized) : '',
        data.pid != null ? String(data.pid) : '',
        data.uptime != null ? String(data.uptime) : '',
      ];
      process.stdout.write(fields.join('\n'));
    } catch (e) {
      process.stdout.write('\n\n\n\n\n');
    }
  " 2>/dev/null)" || true

  if [[ -n "$parsed" ]]; then
    local -a health_fields
    IFS=$'\n' read -r -d '' -a health_fields <<< "$parsed" || true
    WORKER_VERSION="${health_fields[0]:-}"
    WORKER_AI_PROVIDER="${health_fields[1]:-}"
    WORKER_AI_AUTH_METHOD="${health_fields[2]:-}"
    WORKER_INITIALIZED="${health_fields[3]:-}"
    WORKER_REPORTED_PID="${health_fields[4]:-}"
    WORKER_UPTIME="${health_fields[5]:-}"
  fi
}

###############################################################################
# Format uptime from milliseconds to human-readable (e.g., "2m 15s", "1h 23m")
###############################################################################

format_uptime_ms() {
  local ms="$1"
  local secs=$((ms / 1000))
  if (( secs >= 3600 )); then
    echo "$((secs / 3600))h $((secs % 3600 / 60))m"
  elif (( secs >= 60 )); then
    echo "$((secs / 60))m $((secs % 60))s"
  else
    echo "${secs}s"
  fi
}

###############################################################################
# Banner
###############################################################################

print_banner() {
  echo -e "${COLOR_MAGENTA}${COLOR_BOLD}"
  cat << 'BANNER'
   ┌─────────────────────────────────────────┐
   │    claude-mem  ×  OpenClaw              │
   │    Persistent Memory Plugin Installer   │
   └─────────────────────────────────────────┘
BANNER
  echo -e "${COLOR_RESET}"
  info "Installer v${INSTALLER_VERSION}"
  echo ""
}

###############################################################################
# Platform detection
###############################################################################

PLATFORM=""
IS_WSL=""

detect_platform() {
  local uname_out
  uname_out="$(uname -s)"

  case "${uname_out}" in
    Darwin*)
      PLATFORM="macos"
      ;;
    Linux*)
      if grep -qi microsoft /proc/version 2>/dev/null; then
        PLATFORM="linux"
        IS_WSL="true"
      else
        PLATFORM="linux"
      fi
      ;;
    MINGW*|MSYS*|CYGWIN*)
      PLATFORM="windows"
      ;;
    *)
      error "Unsupported platform: ${uname_out}"
      exit 1
      ;;
  esac

  info "Detected platform: ${PLATFORM}${IS_WSL:+ (WSL)}"
}

###############################################################################
# Version comparison — returns 0 if $1 >= $2
###############################################################################

version_gte() {
  local v1="$1" v2="$2"
  local -a parts1 parts2
  IFS='.' read -ra parts1 <<< "$v1"
  IFS='.' read -ra parts2 <<< "$v2"

  for i in 0 1 2; do
    local p1="${parts1[$i]:-0}"
    local p2="${parts2[$i]:-0}"
    if (( p1 > p2 )); then return 0; fi
    if (( p1 < p2 )); then return 1; fi
  done
  return 0
}

###############################################################################
# Bun detection and installation
# Translated from plugin/scripts/smart-install.js patterns
###############################################################################

BUN_PATH=""

find_bun_path() {
  # Try PATH first
  if command -v bun &>/dev/null; then
    BUN_PATH="$(command -v bun)"
    return 0
  fi

  # Check common installation paths (handles fresh installs before PATH reload)
  local -a bun_paths=(
    "${HOME}/.bun/bin/bun"
    "/usr/local/bin/bun"
    "/opt/homebrew/bin/bun"
  )

  for candidate in "${bun_paths[@]}"; do
    if [[ -x "$candidate" ]]; then
      BUN_PATH="$candidate"
      return 0
    fi
  done

  BUN_PATH=""
  return 1
}

check_bun() {
  if ! find_bun_path; then
    return 1
  fi

  # Verify minimum version
  local bun_version
  bun_version="$("$BUN_PATH" --version 2>/dev/null)" || return 1

  if version_gte "$bun_version" "$MIN_BUN_VERSION"; then
    success "Bun ${bun_version} found at ${BUN_PATH}"
    return 0
  else
    warn "Bun ${bun_version} is below minimum required version ${MIN_BUN_VERSION}"
    return 1
  fi
}

install_bun() {
  info "Installing Bun runtime..."

  if ! curl -fsSL https://bun.sh/install | bash; then
    error "Failed to install Bun automatically"
    error "Please install manually:"
    error "  curl -fsSL https://bun.sh/install | bash"
    error "  Or: brew install oven-sh/bun/bun (macOS)"
    error "Then restart your terminal and re-run this installer."
    exit 1
  fi

  # Re-detect after install (installer may have placed it in ~/.bun/bin)
  if ! find_bun_path; then
    error "Bun installation completed but binary not found in expected locations"
    error "Please restart your terminal and re-run this installer."
    exit 1
  fi

  local bun_version
  bun_version="$("$BUN_PATH" --version 2>/dev/null)" || true
  success "Bun ${bun_version} installed at ${BUN_PATH}"
}

###############################################################################
# OpenClaw gateway detection
###############################################################################

OPENCLAW_PATH=""

find_openclaw() {
  # Try PATH first — check both "openclaw" and "openclaw.mjs" binary names
  for bin_name in openclaw openclaw.mjs; do
    if command -v "$bin_name" &>/dev/null; then
      OPENCLAW_PATH="$(command -v "$bin_name")"
      return 0
    fi
  done

  # Check common installation paths
  local -a openclaw_paths=(
    "${HOME}/.openclaw/openclaw.mjs"
    "/usr/local/bin/openclaw.mjs"
    "/usr/local/bin/openclaw"
    "/usr/local/lib/node_modules/openclaw/openclaw.mjs"
    "${HOME}/.npm-global/lib/node_modules/openclaw/openclaw.mjs"
    "${HOME}/.npm-global/bin/openclaw"
  )

  # Also check for node_modules in common project locations
  if [[ -n "${NODE_PATH:-}" ]]; then
    openclaw_paths+=("${NODE_PATH}/openclaw/openclaw.mjs")
  fi

  for candidate in "${openclaw_paths[@]}"; do
    if [[ -f "$candidate" ]]; then
      OPENCLAW_PATH="$candidate"
      return 0
    fi
  done

  OPENCLAW_PATH=""
  return 1
}

check_openclaw() {
  if ! find_openclaw; then
    error "OpenClaw gateway not found"
    error ""
    error "The claude-mem plugin requires an OpenClaw gateway to be installed."
    error "Please install OpenClaw first:"
    error ""
    error "  npm install -g openclaw"
    error "  # or visit: https://openclaw.dev/docs/installation"
    error ""
    error "Then re-run this installer."
    exit 1
  fi

  success "OpenClaw gateway found at ${OPENCLAW_PATH}"
}

# Run openclaw command — uses node for .mjs files, direct execution otherwise
run_openclaw() {
  if [[ "$OPENCLAW_PATH" == *.mjs ]]; then
    node "$OPENCLAW_PATH" "$@"
  else
    "$OPENCLAW_PATH" "$@"
  fi
}

###############################################################################
# Plugin installation — clone, build, install, enable
# Flow based on openclaw/Dockerfile.e2e
###############################################################################

CLAUDE_MEM_REPO="https://github.com/thedotmack/claude-mem.git"
CLAUDE_MEM_BRANCH="${CLI_BRANCH:-main}"
PLUGIN_FRESHLY_INSTALLED=""

# Resolve the target extension directory.
# Priority: existing installPath from config > plugins.load.paths > default.
resolve_extension_dir() {
  local oc_config="${HOME}/.openclaw/openclaw.json"
  if [[ -f "$oc_config" ]] && command -v node &>/dev/null; then
    local existing_path
    existing_path="$(node -e "
      try {
        const c = require('$oc_config');
        const p = c?.plugins?.installs?.['claude-mem']?.installPath;
        if (p) console.log(p);
      } catch {}
    " 2>/dev/null)" || true
    if [[ -n "$existing_path" ]]; then
      echo "$existing_path"
      return
    fi
    local load_path
    load_path="$(node -e "
      try {
        const c = require('$oc_config');
        const paths = c?.plugins?.load?.paths || [];
        const p = paths.find(p => p.endsWith('/claude-mem'));
        if (p) console.log(p);
      } catch {}
    " 2>/dev/null)" || true
    if [[ -n "$load_path" ]]; then
      echo "$load_path"
      return
    fi
  fi
  echo "${HOME}/.openclaw/extensions/claude-mem"
}

CLAUDE_MEM_EXTENSION_DIR=""

install_plugin() {
  # Check for git before attempting clone
  check_git

  CLAUDE_MEM_EXTENSION_DIR="$(resolve_extension_dir)"

  # Remove existing plugin installation to allow clean re-install
  local existing_plugin_dir="$CLAUDE_MEM_EXTENSION_DIR"
  if [[ -d "$existing_plugin_dir" ]]; then
    info "Removing existing claude-mem plugin at ${existing_plugin_dir}..."
    rm -rf "$existing_plugin_dir"
  fi

  local build_dir
  build_dir="$(mktemp -d)"
  register_cleanup_dir "$build_dir"

  info "Cloning claude-mem repository (branch: ${CLAUDE_MEM_BRANCH})..."
  if ! git clone --depth 1 --branch "$CLAUDE_MEM_BRANCH" "$CLAUDE_MEM_REPO" "$build_dir/claude-mem" 2>&1; then
    error "Failed to clone claude-mem repository"
    error "Check your internet connection and try again."
    exit 1
  fi

  local plugin_src="${build_dir}/claude-mem/openclaw"

  # Build the TypeScript plugin
  info "Building TypeScript plugin..."
  if ! (cd "$plugin_src" && NODE_ENV=development npm install --ignore-scripts 2>&1 && npx tsc 2>&1); then
    error "Failed to build the claude-mem OpenClaw plugin"
    error "Make sure Node.js and npm are installed."
    exit 1
  fi

  # Create minimal installable package (matches Dockerfile.e2e pattern)
  local installable_dir="${build_dir}/claude-mem-installable"
  mkdir -p "${installable_dir}/dist"

  cp "${plugin_src}/dist/index.js" "${installable_dir}/dist/"
  cp "${plugin_src}/dist/index.d.ts" "${installable_dir}/dist/" 2>/dev/null || true
  cp "${plugin_src}/openclaw.plugin.json" "${installable_dir}/"

  # Generate the installable package.json with openclaw.extensions field
  INSTALLER_PACKAGE_DIR="$installable_dir" node -e "
    const pkg = {
      name: 'claude-mem',
      version: '1.0.0',
      type: 'module',
      main: 'dist/index.js',
      openclaw: { extensions: ['./dist/index.js'] }
    };
    require('fs').writeFileSync(process.env.INSTALLER_PACKAGE_DIR + '/package.json', JSON.stringify(pkg, null, 2));
  "

  # Clean up stale claude-mem plugin entry before installing.
  # If the config references claude-mem but the plugin isn't installed,
  # OpenClaw's config validator blocks ALL CLI commands (including plugins install).
  # We temporarily remove the entry and save the config so `plugins install` can run,
  # then `plugins install` + `plugins enable` will re-create it properly.
  local oc_config="${HOME}/.openclaw/openclaw.json"
  local saved_plugin_config=""
  if [[ -f "$oc_config" ]]; then
    saved_plugin_config=$(INSTALLER_CONFIG_FILE="$oc_config" node -e "
      const fs = require('fs');
      const configPath = process.env.INSTALLER_CONFIG_FILE;
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
      const entry = config?.plugins?.entries?.['claude-mem'];
      if (entry || config?.plugins?.slots?.memory === 'claude-mem') {
        // Save the config block so we can restore it after install
        process.stdout.write(JSON.stringify(entry?.config || {}));
        // Remove the stale entry so OpenClaw CLI can run
        if (entry) delete config.plugins.entries['claude-mem'];
        // Also remove the slot reference — if the slot points to a plugin
        // that isn't in entries, OpenClaw's config validator rejects ALL commands
        if (config?.plugins?.slots?.memory === 'claude-mem') {
          delete config.plugins.slots.memory;
        }
        fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
      }
    " 2>/dev/null) || true
  fi

  # Install the plugin using OpenClaw's CLI
  info "Installing claude-mem plugin into OpenClaw..."
  if ! run_openclaw plugins install "$installable_dir" 2>&1; then
    error "Failed to install claude-mem plugin"
    error "Try manually: ${OPENCLAW_PATH} plugins install <path>"
    exit 1
  fi

  # Enable the plugin
  info "Enabling claude-mem plugin..."
  if ! run_openclaw plugins enable claude-mem 2>&1; then
    error "Failed to enable claude-mem plugin"
    error "Try manually: ${OPENCLAW_PATH} plugins enable claude-mem"
    exit 1
  fi

  # Restore saved plugin config (workerPort, syncMemoryFile, observationFeed, etc.)
  # from any pre-existing installation that was temporarily removed above.
  if [[ -n "$saved_plugin_config" && "$saved_plugin_config" != "{}" ]]; then
    info "Restoring previous plugin configuration..."
    INSTALLER_CONFIG_FILE="$oc_config" INSTALLER_SAVED_CONFIG="$saved_plugin_config" node -e "
      const fs = require('fs');
      const configPath = process.env.INSTALLER_CONFIG_FILE;
      const savedConfig = JSON.parse(process.env.INSTALLER_SAVED_CONFIG);
      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
      if (config?.plugins?.entries?.['claude-mem']) {
        config.plugins.entries['claude-mem'].config = savedConfig;
        fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
      }
    " 2>/dev/null || warn "Could not restore previous plugin config — configure manually"
  fi

  success "claude-mem plugin installed and enabled"

  # ── Copy core plugin files (worker, hooks, scripts) to extension directory ──
  # The OpenClaw extension only contains the gateway hook (dist/index.js).
  # The actual worker service and Claude Code hooks live in the plugin/ directory
  # of the main repo. We copy them so find_claude_mem_install_dir() can locate
  # the worker-service.cjs and the worker runs the updated version.
  local extension_dir="$CLAUDE_MEM_EXTENSION_DIR"
  local repo_root="${build_dir}/claude-mem"

  if [[ -d "$extension_dir" && -d "${repo_root}/plugin" ]]; then
    info "Copying core plugin files to ${extension_dir}..."

    # Copy plugin/ directory (worker service, hooks, scripts, skills, UI)
    cp -R "${repo_root}/plugin" "${extension_dir}/"

    # Merge the canonical version from root package.json into the existing
    # extension package.json, preserving the openclaw.extensions field that
    # plugin discovery requires.
    local root_version
    root_version="$(node -e "console.log(require('${repo_root}/package.json').version)")"
    node -e "
      const fs = require('fs');
      const pkgPath = '${extension_dir}/package.json';
      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
      pkg.version = '${root_version}';
      fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
    "

    success "Core plugin files updated at ${extension_dir}"
  else
    warn "Could not copy core plugin files — worker may need manual update"
  fi

  PLUGIN_FRESHLY_INSTALLED="true"
}

###############################################################################
# Memory slot configuration
# Sets plugins.slots.memory = "claude-mem" in ~/.openclaw/openclaw.json
###############################################################################

configure_memory_slot() {
  local config_dir="${HOME}/.openclaw"
  local config_file="${config_dir}/openclaw.json"

  mkdir -p "$config_dir"

  if [[ ! -f "$config_file" ]]; then
    # No config file exists — create one with the memory slot
    info "Creating OpenClaw configuration with claude-mem memory slot..."
    INSTALLER_CONFIG_FILE="$config_file" node -e "
      const config = {
        plugins: {
          slots: { memory: 'claude-mem' },
          entries: {
            'claude-mem': {
              enabled: true,
              config: {
                workerPort: 37777,
                syncMemoryFile: true
              }
            }
          }
        }
      };
      require('fs').writeFileSync(process.env.INSTALLER_CONFIG_FILE, JSON.stringify(config, null, 2));
    "
    success "Created ${config_file} with memory slot set to claude-mem"
    return 0
  fi

  # Config file exists — update it to set the memory slot
  info "Updating OpenClaw configuration to use claude-mem memory slot..."

  # Use node for reliable JSON manipulation
  INSTALLER_CONFIG_FILE="$config_file" node -e "
    const fs = require('fs');
    const configPath = process.env.INSTALLER_CONFIG_FILE;
    const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

    // Ensure plugins structure exists
    if (!config.plugins) config.plugins = {};
    if (!config.plugins.slots) config.plugins.slots = {};
    if (!config.plugins.entries) config.plugins.entries = {};

    // Set memory slot to claude-mem
    config.plugins.slots.memory = 'claude-mem';

    // Ensure claude-mem entry exists and is enabled
    if (!config.plugins.entries['claude-mem']) {
      config.plugins.entries['claude-mem'] = {
        enabled: true,
        config: {
          workerPort: 37777,
          syncMemoryFile: true
        }
      };
    } else {
      config.plugins.entries['claude-mem'].enabled = true;
      // Remove unrecognized keys that cause OpenClaw config validation errors
      const allowedKeys = new Set(['enabled', 'config']);
      for (const key of Object.keys(config.plugins.entries['claude-mem'])) {
        if (!allowedKeys.has(key)) {
          delete config.plugins.entries['claude-mem'][key];
        }
      }
    }

    fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
  "

  success "Memory slot set to claude-mem in ${config_file}"
}

###############################################################################
# AI Provider setup — interactive provider selection
# Reads defaults from SettingsDefaultsManager.ts (single source of truth)
###############################################################################

AI_PROVIDER=""
AI_PROVIDER_API_KEY=""

mask_api_key() {
  local key="$1"
  local len=${#key}
  if (( len <= 4 )); then
    echo "****"
  else
    local masked_len=$((len - 4))
    local mask=""
    for (( i=0; i<masked_len; i++ )); do
      mask+="*"
    done
    echo "${mask}${key: -4}"
  fi
}

setup_ai_provider() {
  echo ""
  info "AI Provider Configuration"
  echo ""

  # Handle --provider flag (pre-selected via CLI)
  if [[ -n "$CLI_PROVIDER" ]]; then
    case "$CLI_PROVIDER" in
      claude)
        AI_PROVIDER="claude"
        success "Selected via --provider: Claude Max Plan (CLI authentication)"
        ;;
      gemini)
        AI_PROVIDER="gemini"
        AI_PROVIDER_API_KEY="${CLI_API_KEY}"
        if [[ -n "$AI_PROVIDER_API_KEY" ]]; then
          success "Selected via --provider: Gemini (API key set via --api-key)"
        else
          warn "Selected via --provider: Gemini (no API key — add later in ~/.claude-mem/settings.json)"
        fi
        ;;
      openrouter)
        AI_PROVIDER="openrouter"
        AI_PROVIDER_API_KEY="${CLI_API_KEY}"
        if [[ -n "$AI_PROVIDER_API_KEY" ]]; then
          success "Selected via --provider: OpenRouter (API key set via --api-key)"
        else
          warn "Selected via --provider: OpenRouter (no API key — add later in ~/.claude-mem/settings.json)"
        fi
        ;;
      *)
        error "Unknown provider: ${CLI_PROVIDER}"
        error "Valid providers: claude, gemini, openrouter"
        exit 1
        ;;
    esac
    return 0
  fi

  # Handle non-interactive mode (no --provider flag)
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    info "Non-interactive mode: defaulting to Claude Max Plan (no API key needed)"
    AI_PROVIDER="claude"
    return 0
  fi

  echo -e "  Choose your AI provider for claude-mem:"
  echo ""
  echo -e "  ${COLOR_BOLD}1)${COLOR_RESET} Claude Max Plan ${COLOR_GREEN}(recommended)${COLOR_RESET}"
  echo -e "     Uses your existing subscription, no API key needed"
  echo ""
  echo -e "  ${COLOR_BOLD}2)${COLOR_RESET} Gemini"
  echo -e "     Free tier available — requires API key from ai.google.dev"
  echo ""
  echo -e "  ${COLOR_BOLD}3)${COLOR_RESET} OpenRouter"
  echo -e "     Pay-per-use — requires API key from openrouter.ai"
  echo ""

  local choice
  while true; do
    prompt_user "Enter choice [1/2/3] (default: 1):"
    read_tty -r choice
    choice="${choice:-1}"

    case "$choice" in
      1)
        AI_PROVIDER="claude"
        success "Selected: Claude Max Plan (CLI authentication)"
        break
        ;;
      2)
        AI_PROVIDER="gemini"
        echo ""
        prompt_user "Enter your Gemini API key (from https://ai.google.dev):"
        read_tty -rs AI_PROVIDER_API_KEY
        echo ""
        if [[ -z "$AI_PROVIDER_API_KEY" ]]; then
          warn "No API key provided — you can add it later in ~/.claude-mem/settings.json"
        else
          success "Gemini API key set ($(mask_api_key "$AI_PROVIDER_API_KEY"))"
        fi
        break
        ;;
      3)
        AI_PROVIDER="openrouter"
        echo ""
        prompt_user "Enter your OpenRouter API key (from https://openrouter.ai):"
        read_tty -rs AI_PROVIDER_API_KEY
        echo ""
        if [[ -z "$AI_PROVIDER_API_KEY" ]]; then
          warn "No API key provided — you can add it later in ~/.claude-mem/settings.json"
        else
          success "OpenRouter API key set ($(mask_api_key "$AI_PROVIDER_API_KEY"))"
        fi
        break
        ;;
      *)
        warn "Invalid choice. Please enter 1, 2, or 3."
        ;;
    esac
  done
}

###############################################################################
# Write settings.json — creates ~/.claude-mem/settings.json with all defaults
# Schema: flat key-value (not nested { env: {...} })
# Defaults sourced from SettingsDefaultsManager.ts
###############################################################################

write_settings() {
  local settings_dir="${HOME}/.claude-mem"
  local settings_file="${settings_dir}/settings.json"

  mkdir -p "$settings_dir"

  # Pass provider and API key via environment variables to avoid shell-to-JS injection
  INSTALLER_AI_PROVIDER="$AI_PROVIDER" \
  INSTALLER_AI_API_KEY="$AI_PROVIDER_API_KEY" \
  INSTALLER_SETTINGS_FILE="$settings_file" \
  node -e "
    const fs = require('fs');
    const path = require('path');
    const homedir = require('os').homedir();
    const provider = process.env.INSTALLER_AI_PROVIDER;
    const [REDACTED_SECRET] || '';
    const settingsPath = process.env.INSTALLER_SETTINGS_FILE;

    // All defaults from SettingsDefaultsManager.ts
    const defaults = {
      CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
      CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
      CLAUDE_MEM_WORKER_PORT: '37777',
      CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
      CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
      CLAUDE_MEM_PROVIDER: 'claude',
      CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli',
      CLAUDE_MEM_GEMINI_API_KEY: '',
      CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
      CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true',
      CLAUDE_MEM_OPENROUTER_API_KEY: '',
      CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free',
      CLAUDE_MEM_OPENROUTER_SITE_URL: '',
      CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',
      CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20',
      CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000',
      CLAUDE_MEM_DATA_DIR: path.join(homedir, '.claude-mem'),
      CLAUDE_MEM_LOG_LEVEL: 'INFO',
      CLAUDE_MEM_PYTHON_VERSION: '3.13',
      CLAUDE_CODE_PATH: '',
      CLAUDE_MEM_MODE: 'code',
      CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
      CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
      CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
      CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
      CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: 'bugfix,feature,refactor,discovery,decision,change',
      CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: 'how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off',
      CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
      CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
      CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
      CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
      CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
      CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
      CLAUDE_MEM_EXCLUDED_PROJECTS: '',
      CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]'
    };

    // Build provider-specific overrides safely from environment variables
    const overrides = { CLAUDE_MEM_PROVIDER: provider };
    if (provider === 'claude') {
      overrides.CLAUDE_MEM_CLAUDE_AUTH_METHOD = 'cli';
    } else if (provider === 'gemini') {
      overrides.CLAUDE_MEM_GEMINI_API_KEY = apiKey;
      overrides.CLAUDE_MEM_GEMINI_MODEL = 'gemini-2.5-flash-lite';
    } else if (provider === 'openrouter') {
      overrides.CLAUDE_MEM_OPENROUTER_API_KEY = apiKey;
      overrides.CLAUDE_MEM_OPENROUTER_MODEL = 'xiaomi/mimo-v2-flash:free';
    }

    const settings = Object.assign(defaults, overrides);

    // If settings file already exists, merge (preserve user customizations)
    if (fs.existsSync(settingsPath)) {
      try {
        let existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
        // Handle old nested schema
        if (existing.env && typeof existing.env === 'object') {
          existing = existing.env;
        }
        // Existing settings take priority, except for provider settings we just set
        for (const key of Object.keys(existing)) {
          if (!(key in overrides) && key in defaults) {
            settings[key] = existing[key];
          }
        }
      } catch (e) {
        // Corrupted file — overwrite with fresh defaults
      }
    }

    fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
  "

  success "Settings written to ${settings_file}"
}

###############################################################################
# Locate the installed claude-mem plugin directory
# Checks common OpenClaw and Claude Code plugin install paths
###############################################################################

CLAUDE_MEM_INSTALL_DIR=""

find_claude_mem_install_dir() {
  local resolved_dir
  resolved_dir="$(resolve_extension_dir)"
  local -a search_paths=(
    "$resolved_dir"
    "${HOME}/.openclaw/extensions/claude-mem"
    "${HOME}/.claude/plugins/marketplaces/thedotmack"
    "${HOME}/.openclaw/plugins/claude-mem"
  )

  for candidate in "${search_paths[@]}"; do
    if [[ -f "${candidate}/plugin/scripts/worker-service.cjs" ]]; then
      CLAUDE_MEM_INSTALL_DIR="$candidate"
      return 0
    fi
  done

  # Fallback: search for the worker script under common plugin roots
  local -a roots=(
    "${HOME}/.openclaw"
    "${HOME}/.claude/plugins"
  )
  for root in "${roots[@]}"; do
    if [[ -d "$root" ]]; then
      local found
      found="$(find "$root" -name "worker-service.cjs" -path "*/plugin/scripts/*" 2>/dev/null | head -n 1)" || true
      if [[ -n "$found" ]]; then
        # Strip /plugin/scripts/worker-service.cjs to get the install dir
        CLAUDE_MEM_INSTALL_DIR="${found%/plugin/scripts/worker-service.cjs}"
        return 0
      fi
    fi
  done

  CLAUDE_MEM_INSTALL_DIR=""
  return 1
}

###############################################################################
# Worker service startup
# Starts the claude-mem worker using bun in the background
###############################################################################

WORKER_PID=""
WORKER_VERSION=""
WORKER_AI_PROVIDER=""
WORKER_AI_AUTH_METHOD=""
WORKER_INITIALIZED=""
WORKER_REPORTED_PID=""
WORKER_UPTIME=""

start_worker() {
  info "Starting claude-mem worker service..."

  if ! find_claude_mem_install_dir; then
    error "Cannot find claude-mem plugin installation directory"
    error "Expected worker-service.cjs in one of:"
    error "  ~/.openclaw/extensions/claude-mem/plugin/scripts/"
    error "  ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/"
    error ""
    error "Try reinstalling the plugin and re-running this installer."
    return 1
  fi

  local worker_script="${CLAUDE_MEM_INSTALL_DIR}/plugin/scripts/worker-service.cjs"
  local log_dir="${HOME}/.claude-mem/logs"
  local log_date
  log_date="$(date +%Y-%m-%d)"
  local log_file="${log_dir}/worker-${log_date}.log"

  mkdir -p "$log_dir"

  # Ensure bun path is available
  if [[ -z "$BUN_PATH" ]]; then
    if ! find_bun_path; then
      error "Bun not found — cannot start worker service"
      return 1
    fi
  fi

  # Start worker in background with nohup
  CLAUDE_MEM_WORKER_PORT=37777 nohup "$BUN_PATH" "$worker_script" \
    >> "$log_file" 2>&1 &
  WORKER_PID=$!

  # Write PID file for future management
  local pid_file="${HOME}/.claude-mem/worker.pid"
  mkdir -p "${HOME}/.claude-mem"
  INSTALLER_PID_FILE="$pid_file" INSTALLER_WORKER_PID="$WORKER_PID" node -e "
    const info = {
      pid: parseInt(process.env.INSTALLER_WORKER_PID, 10),
      port: 37777,
      startedAt: new Date().toISOString(),
      version: 'installer'
    };
    require('fs').writeFileSync(process.env.INSTALLER_PID_FILE, JSON.stringify(info, null, 2));
  "

  success "Worker process started (PID: ${WORKER_PID})"
  info "Logs: ${log_file}"
}

###############################################################################
# Health verification — two-stage: health (alive) then readiness (initialized)
# Stage 1: Poll /api/health for HTTP 200 (worker process is running)
# Stage 2: Poll /api/readiness for HTTP 200 (worker is fully initialized)
# Total budget: 30 attempts (30 seconds) shared across both stages
###############################################################################

verify_health() {
  local max_attempts=30
  local attempt=1
  local health_url="http://127.0.0.1:37777/api/health"
  local readiness_url="http://127.0.0.1:37777/api/readiness"
  local health_alive=false

  info "Verifying worker health..."

  # ── Stage 1: Wait for /api/health to return HTTP 200 (worker is alive) ──
  while (( attempt <= max_attempts )); do
    local http_status
    http_status="$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null)" || true

    if [[ "$http_status" == "200" ]]; then
      health_alive=true

      # Fetch the full health response body and parse metadata
      local body
      body="$(curl -s "$health_url" 2>/dev/null)" || true
      parse_health_json "$body"

      success "Worker is alive, waiting for initialization..."

      break
    fi

    info "Waiting for worker to start... (attempt ${attempt}/${max_attempts})"
    sleep 1
    attempt=$((attempt + 1))
  done

  # If health never responded, the worker is not running at all
  if [[ "$health_alive" != "true" ]]; then
    warn "Worker health check timed out after ${max_attempts} attempts"
    warn "The worker may still be starting up. Check status with:"
    warn "  curl http://127.0.0.1:37777/api/health"
    warn "  Or check logs: ~/.claude-mem/logs/"
    return 1
  fi

  # ── Stage 2: Wait for /api/readiness to return HTTP 200 (fully initialized) ──
  attempt=$((attempt + 1))
  while (( attempt <= max_attempts )); do
    local readiness_status
    readiness_status="$(curl -s -o /dev/null -w "%{http_code}" "$readiness_url" 2>/dev/null)" || true

    if [[ "$readiness_status" == "200" ]]; then
      success "Worker is ready!"
      return 0
    fi

    info "Waiting for worker to initialize... (attempt ${attempt}/${max_attempts})"
    sleep 1
    attempt=$((attempt + 1))
  done

  # Readiness timed out but health is OK — worker is running, just not fully initialized yet
  warn "Worker is running but initialization is still in progress"
  warn "This is normal on first run — the worker will finish initializing in the background."
  warn "Check readiness with: curl http://127.0.0.1:37777/api/readiness"
  return 0
}

###############################################################################
# Observation feed setup — optional interactive channel configuration
###############################################################################

FEED_CHANNEL=""
FEED_TARGET_ID=""
FEED_CONFIGURED=false

setup_observation_feed() {
  echo ""
  echo -e "  ${COLOR_BOLD}Real-Time Observation Feed${COLOR_RESET}"
  echo ""
  echo "  claude-mem can stream AI-compressed observations to a messaging"
  echo "  channel in real time. Every time an agent learns something,"
  echo "  you'll see it in your chat."
  echo ""

  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    info "Non-interactive mode: skipping observation feed setup"
    info "Configure later in ~/.openclaw/openclaw.json under"
    info "  plugins.entries.claude-mem.config.observationFeed"
    return 0
  fi

  prompt_user "Would you like to set up real-time observation streaming to a messaging channel? (y/n)"
  local answer
  read_tty -r answer
  answer="${answer:-n}"

  if [[ "$answer" != [yY] && "$answer" != [yY][eE][sS] ]]; then
    echo ""
    info "Skipped observation feed setup."
    info "You can configure it later by re-running this installer or"
    info "editing ~/.openclaw/openclaw.json under"
    info "  plugins.entries.claude-mem.config.observationFeed"
    return 0
  fi

  echo ""
  echo -e "  ${COLOR_BOLD}Select your messaging channel:${COLOR_RESET}"
  echo ""
  echo -e "  ${COLOR_BOLD}1)${COLOR_RESET} Telegram"
  echo -e "  ${COLOR_BOLD}2)${COLOR_RESET} Discord"
  echo -e "  ${COLOR_BOLD}3)${COLOR_RESET} Slack"
  echo -e "  ${COLOR_BOLD}4)${COLOR_RESET} Signal"
  echo -e "  ${COLOR_BOLD}5)${COLOR_RESET} WhatsApp"
  echo -e "  ${COLOR_BOLD}6)${COLOR_RESET} LINE"
  echo ""

  local channel_choice
  while true; do
    prompt_user "Enter choice [1-6]:"
    read_tty -r channel_choice

    case "$channel_choice" in
      1)
        FEED_CHANNEL="telegram"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your Telegram chat ID:${COLOR_RESET}"
        echo "  Message @userinfobot on Telegram (https://t.me/userinfobot)"
        echo "  — it replies with your numeric chat ID."
        echo "  For groups, the ID is negative (e.g., -1001234567890)."
        break
        ;;
      2)
        FEED_CHANNEL="discord"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your Discord channel ID:${COLOR_RESET}"
        echo "  Enable Developer Mode (Settings → Advanced → Developer Mode),"
        echo "  right-click the target channel → Copy Channel ID"
        break
        ;;
      3)
        FEED_CHANNEL="slack"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your Slack channel ID:${COLOR_RESET}"
        echo "  Open the channel, click the channel name at top,"
        echo "  scroll to bottom — ID looks like C01ABC2DEFG"
        break
        ;;
      4)
        FEED_CHANNEL="signal"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your Signal target ID:${COLOR_RESET}"
        echo "  Use the phone number or group ID from your"
        echo "  OpenClaw Signal plugin config"
        break
        ;;
      5)
        FEED_CHANNEL="whatsapp"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your WhatsApp target ID:${COLOR_RESET}"
        echo "  Use the phone number or group JID from your"
        echo "  OpenClaw WhatsApp plugin config"
        break
        ;;
      6)
        FEED_CHANNEL="line"
        echo ""
        echo -e "  ${COLOR_CYAN}How to find your LINE target ID:${COLOR_RESET}"
        echo "  Use the user ID or group ID from the"
        echo "  LINE Developer Console"
        break
        ;;
      *)
        warn "Invalid choice. Please enter a number between 1 and 6."
        ;;
    esac
  done

  echo ""
  prompt_user "Enter your ${FEED_CHANNEL} target ID:"
  read_tty -r FEED_TARGET_ID

  if [[ -z "$FEED_TARGET_ID" ]]; then
    warn "No target ID provided — skipping observation feed setup."
    warn "You can configure it later in ~/.openclaw/openclaw.json"
    FEED_CHANNEL=""
    return 0
  fi

  success "Observation feed: ${FEED_CHANNEL} → ${FEED_TARGET_ID}"
  FEED_CONFIGURED=true
}

###############################################################################
# Write observation feed config into ~/.openclaw/openclaw.json
###############################################################################

write_observation_feed_config() {
  if [[ "$FEED_CONFIGURED" != "true" ]]; then
    return 0
  fi

  local config_file="${HOME}/.openclaw/openclaw.json"

  if [[ ! -f "$config_file" ]]; then
    warn "OpenClaw config file not found at ${config_file}"
    warn "Cannot write observation feed config."
    return 1
  fi

  info "Writing observation feed configuration..."

  # Use jq if available, fall back to python3, then node for JSON manipulation
  if command -v jq &>/dev/null; then
    local tmp_file
    tmp_file="$(mktemp)"
    jq --arg channel "$FEED_CHANNEL" --arg target "$FEED_TARGET_ID" '
      .plugins //= {} |
      .plugins.entries //= {} |
      .plugins.entries["claude-mem"] //= {"enabled": true, "config": {}} |
      .plugins.entries["claude-mem"].config //= {} |
      .plugins.entries["claude-mem"].config.observationFeed = {
        "enabled": true,
        "channel": $channel,
        "to": $target
      }
    ' "$config_file" > "$tmp_file" && mv "$tmp_file" "$config_file"
  elif command -v python3 &>/dev/null; then
    INSTALLER_FEED_CHANNEL="$FEED_CHANNEL" \
    INSTALLER_FEED_TARGET_ID="$FEED_TARGET_ID" \
    INSTALLER_CONFIG_FILE="$config_file" \
    python3 -c "
import json, os
config_path = os.environ['INSTALLER_CONFIG_FILE']
channel = os.environ['INSTALLER_FEED_CHANNEL']
target_id = os.environ['INSTALLER_FEED_TARGET_ID']

with open(config_path) as f:
    config = json.load(f)

config.setdefault('plugins', {})
config['plugins'].setdefault('entries', {})
config['plugins']['entries'].setdefault('claude-mem', {'enabled': True, 'config': {}})
config['plugins']['entries']['claude-mem'].setdefault('config', {})
config['plugins']['entries']['claude-mem']['config']['observationFeed'] = {
    'enabled': True,
    'channel': channel,
    'to': target_id
}

with open(config_path, 'w') as f:
    json.dump(config, f, indent=2)
"
  else
    # Fallback to node (always available since it's a dependency)
    INSTALLER_FEED_CHANNEL="$FEED_CHANNEL" \
    INSTALLER_FEED_TARGET_ID="$FEED_TARGET_ID" \
    INSTALLER_CONFIG_FILE="$config_file" \
    node -e "
      const fs = require('fs');
      const configPath = process.env.INSTALLER_CONFIG_FILE;
      const channel = process.env.INSTALLER_FEED_CHANNEL;
      const targetId = process.env.INSTALLER_FEED_TARGET_ID;

      const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

      if (!config.plugins) config.plugins = {};
      if (!config.plugins.entries) config.plugins.entries = {};
      if (!config.plugins.entries['claude-mem']) {
        config.plugins.entries['claude-mem'] = { enabled: true, config: {} };
      }
      if (!config.plugins.entries['claude-mem'].config) {
        config.plugins.entries['claude-mem'].config = {};
      }

      config.plugins.entries['claude-mem'].config.observationFeed = {
        enabled: true,
        channel: channel,
        to: targetId
      };

      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
    "
  fi

  success "Observation feed config written to ${config_file}"
  echo ""
  echo -e "  ${COLOR_BOLD}Observation feed summary:${COLOR_RESET}"
  echo -e "  Channel: ${COLOR_CYAN}${FEED_CHANNEL}${COLOR_RESET}"
  echo -e "  Target:  ${COLOR_CYAN}${FEED_TARGET_ID}${COLOR_RESET}"
  echo -e "  Enabled: ${COLOR_GREEN}yes${COLOR_RESET}"
  echo ""
  info "Restart your OpenClaw gateway to activate the observation feed."
  info "You should see these log lines:"
  echo "  [claude-mem] Observation feed starting — channel: ${FEED_CHANNEL}, target: ${FEED_TARGET_ID}"
  echo ""
  info "After restarting, run /claude-mem-feed in any OpenClaw chat to verify"
  info "the feed is connected."
}

###############################################################################
# Completion summary
###############################################################################

print_completion_summary() {
  local provider_display=""
  case "$AI_PROVIDER" in
    claude)    provider_display="Claude Max Plan (CLI authentication)" ;;
    gemini)    provider_display="Gemini (gemini-2.5-flash-lite)" ;;
    openrouter) provider_display="OpenRouter (xiaomi/mimo-v2-flash:free)" ;;
    *)         provider_display="$AI_PROVIDER" ;;
  esac

  echo ""
  echo -e "${COLOR_MAGENTA}${COLOR_BOLD}"
  echo "  ┌──────────────────────────────────────────┐"
  echo "  │       Installation Complete!              │"
  echo "  └──────────────────────────────────────────┘"
  echo -e "${COLOR_RESET}"

  echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Dependencies installed (Bun)"
  echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  OpenClaw gateway detected"

  # Show installed version from health data if available
  if [[ -n "$WORKER_VERSION" ]]; then
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  claude-mem v${COLOR_BOLD}${WORKER_VERSION}${COLOR_RESET} installed and running"
  else
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  claude-mem plugin installed and enabled"
  fi

  echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Memory slot configured"

  # Show AI provider with auth method from health data if available
  if [[ -n "$WORKER_AI_AUTH_METHOD" ]]; then
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  AI provider: ${COLOR_BOLD}${WORKER_AI_PROVIDER} (${WORKER_AI_AUTH_METHOD})${COLOR_RESET}"
  else
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  AI provider: ${COLOR_BOLD}${provider_display}${COLOR_RESET}"
  fi

  echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Settings written to ~/.claude-mem/settings.json"

  if [[ -n "$WORKER_PID" ]] && kill -0 "$WORKER_PID" 2>/dev/null; then
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_PID})"
  elif [[ -n "$WORKER_UPTIME" && "$WORKER_UPTIME" =~ ^[0-9]+$ ]] && (( WORKER_UPTIME > 0 )); then
    local uptime_formatted
    uptime_formatted="$(format_uptime_ms "$WORKER_UPTIME")"
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Worker running on port ${COLOR_BOLD}37777${COLOR_RESET} (PID: ${WORKER_REPORTED_PID}, uptime: ${uptime_formatted})"
  else
    echo -e "  ${COLOR_YELLOW}⚠${COLOR_RESET}  Worker may not be running — check logs at ~/.claude-mem/logs/"
  fi

  # Show initialization warning if worker is alive but not yet initialized
  if [[ "$WORKER_INITIALIZED" != "true" ]] && { [[ -n "$WORKER_REPORTED_PID" ]] || { [[ -n "$WORKER_PID" ]] && kill -0 "$WORKER_PID" 2>/dev/null; }; }; then
    echo -e "  ${COLOR_YELLOW}⚠${COLOR_RESET}  Worker is starting but still initializing (this is normal on first run)"
  fi

  if [[ "$FEED_CONFIGURED" == "true" ]]; then
    echo -e "  ${COLOR_GREEN}✓${COLOR_RESET}  Observation feed: ${COLOR_BOLD}${FEED_CHANNEL}${COLOR_RESET} → ${FEED_TARGET_ID}"
  else
    echo -e "  ${COLOR_YELLOW}─${COLOR_RESET}  Observation feed: not configured (optional)"
    echo -e "     Configure later in ~/.openclaw/openclaw.json under"
    echo -e "     plugins.entries.claude-mem.config.observationFeed"
  fi

  echo ""
  echo -e "  ${COLOR_BOLD}What's next?${COLOR_RESET}"
  echo ""
  echo -e "  ${COLOR_CYAN}1.${COLOR_RESET} Restart your OpenClaw gateway to load the plugin"
  echo -e "  ${COLOR_CYAN}2.${COLOR_RESET} Verify with ${COLOR_BOLD}/claude-mem-status${COLOR_RESET} in any OpenClaw chat"
  echo -e "  ${COLOR_CYAN}3.${COLOR_RESET} Check the viewer UI at ${COLOR_BOLD}http://localhost:37777${COLOR_RESET}"
  if [[ "$FEED_CONFIGURED" == "true" ]]; then
    echo -e "  ${COLOR_CYAN}4.${COLOR_RESET} Run ${COLOR_BOLD}/claude-mem-feed${COLOR_RESET} to check feed status"
  fi
  echo ""
  echo -e "  ${COLOR_BOLD}To re-run this installer:${COLOR_RESET}"
  echo "  bash <(curl -fsSL https://install.cmem.ai/openclaw.sh)"
  echo ""
}

###############################################################################
# Main
###############################################################################

main() {
  setup_tty
  print_banner
  detect_platform

  # --- Step 1: Dependencies ---
  echo ""
  info "${COLOR_BOLD}[1/8]${COLOR_RESET} Checking dependencies..."
  echo ""

  if ! check_bun; then
    install_bun
  fi

  echo ""
  success "All dependencies satisfied"

  # --- Step 2: OpenClaw gateway ---
  echo ""
  info "${COLOR_BOLD}[2/8]${COLOR_RESET} Locating OpenClaw gateway..."
  check_openclaw

  # --- Step 3: Plugin installation (skip if upgrading and already installed) ---
  echo ""
  info "${COLOR_BOLD}[3/8]${COLOR_RESET} Installing claude-mem plugin..."

  if [[ "$UPGRADE_MODE" == "true" ]] && is_claude_mem_installed; then
    success "claude-mem already installed at ${CLAUDE_MEM_INSTALL_DIR}"
    info "Upgrade mode: skipping clone/build/register, updating settings only"
  else
    install_plugin
  fi

  # --- Step 4: Memory slot configuration ---
  echo ""
  info "${COLOR_BOLD}[4/8]${COLOR_RESET} Configuring memory slot..."
  configure_memory_slot

  # --- Step 5: AI provider setup ---
  echo ""
  info "${COLOR_BOLD}[5/8]${COLOR_RESET} AI provider setup..."
  setup_ai_provider

  # --- Step 6: Write settings ---
  echo ""
  info "${COLOR_BOLD}[6/8]${COLOR_RESET} Writing settings..."
  write_settings

  # --- Step 7: Start worker and verify ---
  echo ""
  info "${COLOR_BOLD}[7/8]${COLOR_RESET} Starting worker service..."

  if check_port_37777; then
    warn "Port 37777 is already in use (worker may already be running)"
    info "Checking if the existing service is healthy..."
    if verify_health; then
      # verify_health already called parse_health_json — WORKER_* globals are set.
      # Determine the expected version from the installed plugin's package.json.
      local expected_version=""
      if [[ -n "$CLAUDE_MEM_INSTALL_DIR" ]] || find_claude_mem_install_dir; then
        expected_version="$(INSTALLER_PKG="${CLAUDE_MEM_INSTALL_DIR}/package.json" node -e "
          try { process.stdout.write(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PKG, 'utf8')).version || ''); }
          catch(e) {}
        " 2>/dev/null)" || true
      fi

      local needs_restart=""

      # If we just installed fresh plugin files, always restart the worker
      # to pick up the new version — even if the old worker was healthy.
      if [[ "$PLUGIN_FRESHLY_INSTALLED" == "true" ]]; then
        if [[ -n "$WORKER_VERSION" && -n "$expected_version" && "$WORKER_VERSION" != "$expected_version" ]]; then
          info "Upgrading worker from v${WORKER_VERSION} to v${expected_version}..."
        else
          info "Plugin files updated — restarting worker to load new code..."
        fi
        needs_restart="true"
      fi

      # Check if worker version is outdated compared to installed version
      if [[ "$needs_restart" != "true" && -n "$WORKER_VERSION" && -n "$expected_version" && "$WORKER_VERSION" != "$expected_version" ]]; then
        info "Upgrading worker from v${WORKER_VERSION} to v${expected_version}..."
        needs_restart="true"
      fi

      # Check if AI provider doesn't match current configuration
      if [[ "$needs_restart" != "true" && -n "$WORKER_AI_PROVIDER" && -n "$AI_PROVIDER" && "$WORKER_AI_PROVIDER" != "$AI_PROVIDER" ]]; then
        warn "Worker is using ${WORKER_AI_PROVIDER} but you configured ${AI_PROVIDER} — restarting to apply"
        needs_restart="true"
      fi

      # Restart worker if needed: kill old process, start fresh
      if [[ "$needs_restart" == "true" ]]; then
        info "Stopping existing worker..."
        # Try graceful shutdown via API first, fall back to SIGTERM
        curl -s -X POST "http://127.0.0.1:37777/api/admin/shutdown" >/dev/null 2>&1 || true
        sleep 2

        # If still running, send SIGTERM to known PID
        if check_port_37777; then
          if [[ -n "$WORKER_REPORTED_PID" ]]; then
            kill "$WORKER_REPORTED_PID" 2>/dev/null || true
            sleep 1
          fi
          # Check PID file as fallback
          local pid_file="${HOME}/.claude-mem/worker.pid"
          if [[ -f "$pid_file" ]]; then
            local file_pid
            file_pid="$(INSTALLER_PID_FILE="$pid_file" node -e "
              try { process.stdout.write(String(JSON.parse(require('fs').readFileSync(process.env.INSTALLER_PID_FILE, 'utf8')).pid || '')); }
              catch(e) {}
            " 2>/dev/null)" || true
            if [[ -n "$file_pid" ]]; then
              kill "$file_pid" 2>/dev/null || true
              sleep 1
            fi
          fi
        fi

        # Start fresh worker
        if start_worker; then
          verify_health || true
        else
          warn "Worker restart failed — you can start it manually later"
        fi
      else
        # No restart needed — show healthy status
        local uptime_display=""
        if [[ -n "$WORKER_UPTIME" && "$WORKER_UPTIME" =~ ^[0-9]+$ && "$WORKER_UPTIME" != "0" ]]; then
          uptime_display="$(format_uptime_ms "$WORKER_UPTIME")"
        fi

        local status_parts=""
        if [[ -n "$WORKER_VERSION" ]]; then
          status_parts="v${WORKER_VERSION}"
        fi
        if [[ -n "$WORKER_AI_PROVIDER" ]]; then
          status_parts="${status_parts:+${status_parts}, }${WORKER_AI_PROVIDER}"
        fi
        if [[ -n "$uptime_display" ]]; then
          status_parts="${status_parts:+${status_parts}, }uptime: ${uptime_display}"
        fi

        if [[ -n "$status_parts" ]]; then
          success "Existing worker is healthy (${status_parts}) — skipping startup"
        else
          success "Existing worker is healthy — skipping startup"
        fi
      fi
    else
      warn "Port 37777 is occupied but not responding to health checks"
      warn "Another process may be using this port. Stop it and re-run the installer,"
      warn "or change CLAUDE_MEM_WORKER_PORT in ~/.claude-mem/settings.json"
    fi
  else
    if start_worker; then
      verify_health || true
    else
      warn "Worker startup failed — you can start it manually later"
      warn "  cd ~/.openclaw/extensions/claude-mem && bun plugin/scripts/worker-service.cjs"
    fi
  fi

  # --- Step 8: Observation feed setup (optional) ---
  echo ""
  info "${COLOR_BOLD}[8/8]${COLOR_RESET} Observation feed setup..."
  setup_observation_feed
  write_observation_feed_config

  # --- Completion ---
  print_completion_summary
}

main "$@"


path: test-sse-consumer.js
content: /**
 * Smoke test for OpenClaw claude-mem plugin registration.
 * Validates the plugin structure works independently of the full OpenClaw runtime.
 *
 * Run: node test-sse-consumer.js
 */

import claudeMemPlugin from "./dist/index.js";

let registeredService = null;
const registeredCommands = new Map();
const eventHandlers = new Map();
const logs = [];

const mockApi = {
  id: "claude-mem",
  name: "Claude-Mem (Persistent Memory)",
  version: "1.0.0",
  source: "/test/extensions/claude-mem/dist/index.js",
  config: {},
  pluginConfig: {},
  logger: {
    info: (message) => { logs.push(message); },
    warn: (message) => { logs.push(message); },
    error: (message) => { logs.push(message); },
    debug: (message) => { logs.push(message); },
  },
  registerService: (service) => {
    registeredService = service;
  },
  registerCommand: (command) => {
    registeredCommands.set(command.name, command);
  },
  on: (event, callback) => {
    if (!eventHandlers.has(event)) {
      eventHandlers.set(event, []);
    }
    eventHandlers.get(event).push(callback);
  },
  runtime: {
    channel: {
      telegram: { sendMessageTelegram: async () => {} },
      discord: { sendMessageDiscord: async () => {} },
      signal: { sendMessageSignal: async () => {} },
      slack: { sendMessageSlack: async () => {} },
      whatsapp: { sendMessageWhatsApp: async () => {} },
      line: { sendMessageLine: async () => {} },
    },
  },
};

// Call the default export with mock API
claudeMemPlugin(mockApi);

// Verify registration
let failures = 0;

if (!registeredService) {
  console.error("FAIL: No service was registered");
  failures++;
} else if (registeredService.id !== "claude-mem-observation-feed") {
  console.error(
    `FAIL: Service ID is "${registeredService.id}", expected "claude-mem-observation-feed"`
  );
  failures++;
} else {
  console.log("OK: Service registered with id 'claude-mem-observation-feed'");
}

if (!registeredCommands.has("claude-mem-feed")) {
  console.error("FAIL: No 'claude-mem-feed' command registered");
  failures++;
} else {
  console.log("OK: Command registered with name 'claude-mem-feed'");
}

if (!registeredCommands.has("claude-mem-status")) {
  console.error("FAIL: No 'claude-mem-status' command registered");
  failures++;
} else {
  console.log("OK: Command registered with name 'claude-mem-status'");
}

const expectedEvents = ["before_agent_start", "tool_result_persist", "agent_end", "gateway_start"];
for (const event of expectedEvents) {
  if (!eventHandlers.has(event) || eventHandlers.get(event).length === 0) {
    console.error(`FAIL: No handler registered for '${event}'`);
    failures++;
  } else {
    console.log(`OK: Event handler registered for '${event}'`);
  }
}

if (!logs.some((l) => l.includes("plugin loaded"))) {
  console.error("FAIL: Plugin did not log a load message");
  failures++;
} else {
  console.log("OK: Plugin logged load message");
}

if (failures > 0) {
  console.error(`\nFAIL: ${failures} check(s) failed`);
  process.exit(1);
} else {
  console.log("\nPASS: Plugin registers service, commands, and event handlers correctly");
}


path: openclaw.plugin.json
content: {
  "id": "claude-mem",
  "name": "Claude-Mem (Persistent Memory)",
  "description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
  "kind": "memory",
  "version": "1.0.0",
  "author": "thedotmack",
  "homepage": "https://claude-mem.com",
  "skills": ["skills/make-plan", "skills/do-plan"],
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "syncMemoryFile": {
        "type": "boolean",
        "default": true,
        "description": "Automatically sync MEMORY.md on session start"
      },
      "workerPort": {
        "type": "number",
        "default": 37777,
        "description": "Port for Claude-Mem worker service"
      },
      "project": {
        "type": "string",
        "default": "openclaw",
        "description": "Project name for scoping observations in the memory database"
      },
      "observationFeed": {
        "type": "object",
        "description": "Live observation feed — streams observations to any OpenClaw channel in real-time",
        "properties": {
          "enabled": {
            "type": "boolean",
            "default": false,
            "description": "Enable live observation feed to messaging channels"
          },
          "channel": {
            "type": "string",
            "description": "Channel type: telegram, discord, signal, slack, whatsapp, line"
          },
          "to": {
            "type": "string",
            "description": "Target chat/user ID to send observations to"
          },
          "botToken": {
            "type": "string",
            "description": "Optional dedicated Telegram bot token for the feed (bypasses gateway channel)"
          },
          "emojis": {
            "type": "object",
            "description": "Emoji personalization for the observation feed. Each agent gets a unique emoji automatically — customize here to override.",
            "properties": {
              "primary": {
                "type": "string",
                "default": "🦞",
                "description": "Emoji for the main OpenClaw gateway (project='openclaw')"
              },
              "claudeCode": {
                "type": "string",
                "default": "⌨️",
                "description": "Emoji for Claude Code sessions (non-OpenClaw)"
              },
              "claudeCodeLabel": {
                "type": "string",
                "default": "Claude Code Session",
                "description": "Display label prefix for Claude Code sessions in the feed (project identifier is appended automatically)"
              },
              "default": {
                "type": "string",
                "default": "🦀",
                "description": "Fallback emoji when no match is found"
              },
              "agents": {
                "type": "object",
                "default": {},
                "description": "Pin specific emojis to agent IDs (e.g. {\"devops\": \"🔧\"}). Agents not listed here get auto-assigned emojis.",
                "additionalProperties": { "type": "string" }
              }
            }
          }
        }
      }
    }
  }
}


path: e2e-verify.sh
content: #!/usr/bin/env bash
# e2e-verify.sh — Automated E2E verification for claude-mem plugin on OpenClaw
#
# This script verifies the complete plugin installation and operation flow:
# 1. Plugin is installed and visible in OpenClaw
# 2. Plugin loads correctly when gateway starts
# 3. Mock worker SSE stream is consumed by the plugin
# 4. Observations are received and formatted
#
# Exit 0 = all checks passed, Exit 1 = failure

set -euo pipefail

PASS=0
FAIL=0
TOTAL=0

pass() {
  PASS=$((PASS + 1))
  TOTAL=$((TOTAL + 1))
  echo "  PASS: $1"
}

fail() {
  FAIL=$((FAIL + 1))
  TOTAL=$((TOTAL + 1))
  echo "  FAIL: $1"
}

section() {
  echo ""
  echo "=== $1 ==="
}

# ─── Phase 1: Plugin Discovery ───

section "Phase 1: Plugin Discovery"

# Check plugin is listed
PLUGIN_LIST=$(node /app/openclaw.mjs plugins list 2>&1)
if echo "$PLUGIN_LIST" | grep -q "claude-mem"; then
  pass "Plugin appears in 'plugins list'"
else
  fail "Plugin NOT found in 'plugins list'"
  echo "$PLUGIN_LIST"
fi

# Check plugin info
PLUGIN_INFO=$(node /app/openclaw.mjs plugins info claude-mem 2>&1 || true)
if echo "$PLUGIN_INFO" | grep -qi "claude-mem"; then
  pass "Plugin info shows claude-mem details"
else
  fail "Plugin info failed"
  echo "$PLUGIN_INFO"
fi

# Check plugin is enabled
if echo "$PLUGIN_LIST" | grep -A1 "claude-mem" | grep -qi "enabled\|loaded"; then
  pass "Plugin is enabled"
else
  # Try to check via info
  if echo "$PLUGIN_INFO" | grep -qi "enabled\|loaded"; then
    pass "Plugin is enabled (via info)"
  else
    fail "Plugin does not appear enabled"
    echo "$PLUGIN_INFO"
  fi
fi

# Check plugin doctor reports no issues
DOCTOR_OUT=$(node /app/openclaw.mjs plugins doctor 2>&1 || true)
if echo "$DOCTOR_OUT" | grep -qi "no.*issue\|0 issue"; then
  pass "Plugin doctor reports no issues"
else
  fail "Plugin doctor reports issues"
  echo "$DOCTOR_OUT"
fi

# ─── Phase 2: Plugin Files ───

section "Phase 2: Plugin Files"

# Check extension directory exists
EXTENSIONS_DIR="/home/node/.openclaw/extensions/openclaw-plugin"
if [ ! -d "$EXTENSIONS_DIR" ]; then
  # Try alternative naming
  EXTENSIONS_DIR="/home/node/.openclaw/extensions/claude-mem"
  if [ ! -d "$EXTENSIONS_DIR" ]; then
    # Search for it
    FOUND_DIR=$(find /home/node/.openclaw/extensions/ -name "openclaw.plugin.json" -exec dirname {} \; 2>/dev/null | head -1 || true)
    if [ -n "$FOUND_DIR" ]; then
      EXTENSIONS_DIR="$FOUND_DIR"
    fi
  fi
fi

if [ -d "$EXTENSIONS_DIR" ]; then
  pass "Plugin directory exists: $EXTENSIONS_DIR"
else
  fail "Plugin directory not found under /home/node/.openclaw/extensions/"
  ls -la /home/node/.openclaw/extensions/ 2>/dev/null || echo "  (extensions dir not found)"
fi

# Check key files exist
for FILE in "openclaw.plugin.json" "dist/index.js" "package.json"; do
  if [ -f "$EXTENSIONS_DIR/$FILE" ]; then
    pass "File exists: $FILE"
  else
    fail "File missing: $FILE"
  fi
done

# ─── Phase 3: Mock Worker + Plugin Integration ───

section "Phase 3: Mock Worker + Plugin Integration"

# Start mock worker in background
echo "  Starting mock claude-mem worker..."
node /app/mock-worker.js &
MOCK_PID=$!

# Wait for mock worker to be ready
for i in $(seq 1 10); do
  if curl -sf http://localhost:37777/health > /dev/null 2>&1; then
    break
  fi
  sleep 0.5
done

if curl -sf http://localhost:37777/health > /dev/null 2>&1; then
  pass "Mock worker health check passed"
else
  fail "Mock worker health check failed"
  kill $MOCK_PID 2>/dev/null || true
fi

# Test SSE stream connectivity (curl with max-time to capture initial SSE frame)
SSE_TEST=$(curl -s --max-time 2 http://localhost:37777/stream 2>/dev/null || true)
if echo "$SSE_TEST" | grep -q "connected"; then
  pass "SSE stream returns connected event"
else
  fail "SSE stream did not return connected event"
  echo "  Got: $(echo "$SSE_TEST" | head -5)"
fi

# ─── Phase 4: Gateway + Plugin Load ───

section "Phase 4: Gateway Startup with Plugin"

# Create a minimal config that enables the plugin with the mock worker.
# The memory slot must be set to "claude-mem" to match what `plugins install` configured.
# Gateway auth is disabled via token for headless testing.
mkdir -p /home/node/.openclaw
cat > /home/node/.openclaw/openclaw.json << 'EOFCONFIG'
{
  "gateway": {
    "mode": "local",
    "auth": {
      "mode": "token",
      "token": "e2e-test-token"
    }
  },
  "plugins": {
    "slots": {
      "memory": "claude-mem"
    },
    "entries": {
      "claude-mem": {
        "enabled": true,
        "config": {
          "workerPort": 37777,
          "observationFeed": {
            "enabled": true,
            "channel": "telegram",
            "to": "test-chat-id-12345"
          }
        }
      }
    }
  }
}
EOFCONFIG

pass "OpenClaw config written with plugin enabled"

# Start gateway in background and capture output
GATEWAY_LOG="/tmp/gateway.log"
echo "  Starting OpenClaw gateway (timeout 15s)..."
OPENCLAW_GATEWAY_TOKEN=e2e-test-token timeout 15 node /app/openclaw.mjs gateway --allow-unconfigured --verbose --token e2e-test-token > "$GATEWAY_LOG" 2>&1 &
GATEWAY_PID=$!

# Give the gateway time to start and load plugins
sleep 5

# Check if gateway started
if kill -0 $GATEWAY_PID 2>/dev/null; then
  pass "Gateway process is running"
else
  fail "Gateway process exited early"
  echo "  Gateway log:"
  cat "$GATEWAY_LOG" 2>/dev/null | tail -30
fi

# Check gateway log for plugin load messages
if grep -qi "claude-mem" "$GATEWAY_LOG" 2>/dev/null; then
  pass "Gateway log mentions claude-mem plugin"
else
  fail "Gateway log does not mention claude-mem"
  echo "  Gateway log (last 20 lines):"
  tail -20 "$GATEWAY_LOG" 2>/dev/null
fi

# Check for plugin loaded message
if grep -q "plugin loaded" "$GATEWAY_LOG" 2>/dev/null || grep -q "v1.0.0" "$GATEWAY_LOG" 2>/dev/null; then
  pass "Plugin load message found in gateway log"
else
  fail "Plugin load message not found"
fi

# Check for observation feed messages
if grep -qi "observation feed" "$GATEWAY_LOG" 2>/dev/null; then
  pass "Observation feed activity in gateway log"
else
  fail "No observation feed activity detected"
fi

# Check for SSE connection to mock worker
if grep -qi "connected.*SSE\|SSE.*stream\|connecting.*SSE" "$GATEWAY_LOG" 2>/dev/null; then
  pass "SSE connection activity detected"
else
  fail "No SSE connection activity in log"
fi

# ─── Cleanup ───

section "Cleanup"
kill $GATEWAY_PID 2>/dev/null || true
kill $MOCK_PID 2>/dev/null || true
wait $GATEWAY_PID 2>/dev/null || true
wait $MOCK_PID 2>/dev/null || true
echo "  Processes stopped."

# ─── Summary ───

echo ""
echo "==============================="
echo "  E2E Test Results"
echo "==============================="
echo "  Total:  $TOTAL"
echo "  Passed: $PASS"
echo "  Failed: $FAIL"
echo "==============================="

if [ "$FAIL" -gt 0 ]; then
  echo ""
  echo "  SOME TESTS FAILED"
  echo ""
  echo "  Full gateway log:"
  cat "$GATEWAY_LOG" 2>/dev/null
  exit 1
fi

echo ""
echo "  ALL TESTS PASSED"
exit 0


path: TESTING.md
content: # OpenClaw Claude-Mem Plugin — Testing Guide

## Quick Start (Docker)

The fastest way to test the plugin is using the pre-built Docker E2E environment:

```bash
cd openclaw

# Automated test (builds, installs plugin on real OpenClaw, verifies everything)
./test-e2e.sh

# Interactive shell (for manual exploration)
./test-e2e.sh --interactive

# Just build the image
./test-e2e.sh --build-only
```

---

## Test Layers

### 1. Unit Tests (fastest)

```bash
cd openclaw
npm test    # compiles TypeScript, runs 17 tests
```

Tests plugin registration, service lifecycle, command handling, SSE integration, and all 6 channel types.

### 2. Smoke Test

```bash
node test-sse-consumer.js
```

Quick check that the plugin loads and registers its service + command correctly.

### 3. Container Unit Tests (fresh install)

```bash
./test-container.sh          # Unit tests in clean Docker
./test-container.sh --full   # Integration tests with mock worker
```

### 4. E2E on Real OpenClaw (Docker)

```bash
./test-e2e.sh
```

This is the most comprehensive test. It:
1. Uses the official `ghcr.io/openclaw/openclaw:main` Docker image
2. Installs the plugin via `openclaw plugins install` (same as a real user)
3. Enables the plugin via `openclaw plugins enable`
4. Starts a mock claude-mem worker on port 37777
5. Starts the OpenClaw gateway with plugin config
6. Verifies the plugin loads, connects to SSE, and processes events

**All 16 checks must pass.**

---

## Human E2E Testing (Interactive Docker)

For manual walkthrough testing, use the interactive Docker mode:

```bash
./test-e2e.sh --interactive
```

This drops you into a fully-configured OpenClaw container with the plugin pre-installed.

### Step-by-step inside the container

#### 1. Verify plugin is installed

```bash
node openclaw.mjs plugins list
node openclaw.mjs plugins info claude-mem
node openclaw.mjs plugins doctor
```

**Expected:**
- `claude-mem` appears in the plugins list as "enabled" or "loaded"
- Info shows version 1.0.0, source at `/home/node/.openclaw/extensions/claude-mem/`
- Doctor reports no issues

#### 2. Inspect plugin files

```bash
ls -la /home/node/.openclaw/extensions/claude-mem/
cat /home/node/.openclaw/extensions/claude-mem/openclaw.plugin.json
cat /home/node/.openclaw/extensions/claude-mem/package.json
```

**Expected:**
- `dist/index.js` exists (compiled plugin)
- `openclaw.plugin.json` has `"id": "claude-mem"` and `"kind": "memory"`
- `package.json` has `openclaw.extensions` field pointing to `./dist/index.js`

#### 3. Start mock worker

```bash
node /app/mock-worker.js &
```

Verify it's running:

```bash
curl -s http://localhost:37777/health
# → {"status":"ok"}

curl -s --max-time 3 http://localhost:37777/stream
# → data: {"type":"connected","message":"Mock worker SSE stream"}
# → data: {"type":"new_observation","observation":{...}}
```

#### 4. Configure and start gateway

```bash
cat > /home/node/.openclaw/openclaw.json << 'EOF'
{
  "gateway": {
    "mode": "local",
    "auth": {
      "mode": "token",
      "token": "e2e-test-token"
    }
  },
  "plugins": {
    "slots": {
      "memory": "claude-mem"
    },
    "entries": {
      "claude-mem": {
        "enabled": true,
        "config": {
          "workerPort": 37777,
          "observationFeed": {
            "enabled": true,
            "channel": "telegram",
            "to": "test-chat-id-12345"
          }
        }
      }
    }
  }
}
EOF

node openclaw.mjs gateway --allow-unconfigured --verbose --token e2e-test-token
```

**Expected in gateway logs:**
- `[claude-mem] OpenClaw plugin loaded — v1.0.0`
- `[claude-mem] Observation feed starting — channel: telegram, target: test-chat-id-12345`
- `[claude-mem] Connecting to SSE stream at http://localhost:37777/stream`
- `[claude-mem] Connected to SSE stream`

#### 5. Run automated verification (optional)

From a second shell in the container (or after stopping the gateway):

```bash
/bin/bash /app/e2e-verify.sh
```

---

## Manual E2E (Real OpenClaw + Real Worker)

For testing with a real claude-mem worker and real messaging channel:

### Prerequisites

- OpenClaw gateway installed and configured
- Claude-Mem worker running on port 37777
- Plugin built: `cd openclaw && npm run build`

### 1. Install the plugin

```bash
# Build the plugin
cd openclaw && npm run build

# Install on OpenClaw (from the openclaw/ directory)
openclaw plugins install .

# Enable it
openclaw plugins enable claude-mem
```

### 2. Configure

Edit `~/.openclaw/openclaw.json` to add plugin config:

```json
{
  "plugins": {
    "entries": {
      "claude-mem": {
        "enabled": true,
        "config": {
          "workerPort": 37777,
          "observationFeed": {
            "enabled": true,
            "channel": "telegram",
            "to": "YOUR_CHAT_ID"
          }
        }
      }
    }
  }
}
```

**Supported channels:** `telegram`, `discord`, `signal`, `slack`, `whatsapp`, `line`

### 3. Restart gateway

```bash
openclaw restart
```

**Look for in logs:**
- `[claude-mem] OpenClaw plugin loaded — v1.0.0`
- `[claude-mem] Connected to SSE stream`

### 4. Trigger an observation

Start a Claude Code session with claude-mem enabled and perform any action. The worker will emit a `new_observation` SSE event.

### 5. Verify delivery

Check the target messaging channel for:

```
🧠 Claude-Mem Observation
**Observation Title**
Optional subtitle
```

---

## Troubleshooting

### `api.log is not a function`
The plugin was built against the wrong API. Ensure `src/index.ts` uses `api.logger.info()` not `api.log()`. Rebuild with `npm run build`.

### Worker not running
- **Symptom:** `SSE stream error: fetch failed. Reconnecting in 1s`
- **Fix:** Start the worker: `cd /path/to/claude-mem && npm run build-and-sync`

### Port mismatch
- **Fix:** Ensure `workerPort` in config matches the worker's actual port (default: 37777)

### Channel not configured
- **Symptom:** `Observation feed misconfigured — channel or target missing`
- **Fix:** Add both `channel` and `to` to `observationFeed` in config

### Unknown channel type
- **Fix:** Use: `telegram`, `discord`, `signal`, `slack`, `whatsapp`, or `line`

### Feed disabled
- **Symptom:** `Observation feed disabled`
- **Fix:** Set `observationFeed.enabled: true`

### Messages not arriving
1. Verify the bot/integration is configured in the target channel
2. Check the target ID (`to`) is correct
3. Look for `Failed to send to <channel>` in logs
4. Test the channel via OpenClaw's built-in tools

### Memory slot conflict
- **Symptom:** `plugin disabled (memory slot set to "memory-core")`
- **Fix:** Add `"slots": { "memory": "claude-mem" }` to plugins config


path: test-install.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Test suite for openclaw/install.sh functions
# Tests the OpenClaw gateway detection, plugin install, and memory slot config.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_SCRIPT="${SCRIPT_DIR}/install.sh"

TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0

###############################################################################
# Test helpers
###############################################################################

test_pass() {
  TESTS_RUN=$((TESTS_RUN + 1))
  TESTS_PASSED=$((TESTS_PASSED + 1))
  echo -e "\033[0;32m✓\033[0m  $1"
}

test_fail() {
  TESTS_RUN=$((TESTS_RUN + 1))
  TESTS_FAILED=$((TESTS_FAILED + 1))
  echo -e "\033[0;31m✗\033[0m  $1"
  if [[ -n "${2:-}" ]]; then
    echo "     Detail: $2"
  fi
}

assert_eq() {
  local expected="$1" actual="$2" msg="$3"
  if [[ "$expected" == "$actual" ]]; then
    test_pass "$msg"
  else
    test_fail "$msg" "expected='${expected}' actual='${actual}'"
  fi
}

assert_contains() {
  local haystack="$1" needle="$2" msg="$3"
  if [[ "$haystack" == *"$needle"* ]]; then
    test_pass "$msg"
  else
    test_fail "$msg" "expected string to contain '${needle}'"
  fi
}

assert_file_exists() {
  local filepath="$1" msg="$2"
  if [[ -f "$filepath" ]]; then
    test_pass "$msg"
  else
    test_fail "$msg" "file not found: ${filepath}"
  fi
}

###############################################################################
# Source the install script without running main()
# We override main to be a no-op, then source the file.
###############################################################################

source_install_functions() {
  # Create a temp file that overrides main and sources the install script
  local tmp_source
  tmp_source="$(mktemp)"
  # Extract everything except the final `main "$@"` invocation
  sed '$ d' "$INSTALL_SCRIPT" > "$tmp_source"
  # Override main to prevent execution
  echo 'main() { :; }' >> "$tmp_source"
  # Source it (suppress color output for cleaner tests)
  TERM=dumb source "$tmp_source"
  rm -f "$tmp_source"
}

source_install_functions

###############################################################################
# Test: detect_platform() — returns a valid platform string
###############################################################################

echo ""
echo "=== detect_platform() ==="

test_detect_platform_returns_valid_string() {
  PLATFORM=""
  IS_WSL=""
  detect_platform >/dev/null 2>&1

  case "$PLATFORM" in
    macos|linux|windows)
      test_pass "detect_platform sets PLATFORM='${PLATFORM}'"
      ;;
    *)
      test_fail "detect_platform returned unexpected PLATFORM='${PLATFORM}'" "expected macos, linux, or windows"
      ;;
  esac
}

test_detect_platform_returns_valid_string

test_detect_platform_is_idempotent() {
  PLATFORM=""
  IS_WSL=""
  detect_platform >/dev/null 2>&1
  local first_platform="$PLATFORM"

  PLATFORM=""
  IS_WSL=""
  detect_platform >/dev/null 2>&1
  local second_platform="$PLATFORM"

  assert_eq "$first_platform" "$second_platform" "detect_platform returns consistent results"
}

test_detect_platform_is_idempotent

test_detect_platform_sets_iswsl_empty_on_non_wsl() {
  # Unless actually running on WSL, IS_WSL should be empty
  PLATFORM=""
  IS_WSL=""
  detect_platform >/dev/null 2>&1

  if [[ "$PLATFORM" == "linux" ]] && grep -qi microsoft /proc/version 2>/dev/null; then
    assert_eq "true" "$IS_WSL" "IS_WSL is 'true' on WSL"
  else
    assert_eq "" "${IS_WSL:-}" "IS_WSL is empty on non-WSL platform"
  fi
}

test_detect_platform_sets_iswsl_empty_on_non_wsl

###############################################################################
# Test: check_bun() — correctly detects bun presence/absence
###############################################################################

echo ""
echo "=== check_bun() ==="

test_check_bun_detects_installed_bun() {
  # If bun is installed on this system, check_bun should succeed
  if command -v bun &>/dev/null; then
    BUN_PATH=""
    if check_bun >/dev/null 2>&1; then
      test_pass "check_bun succeeds when bun is installed"
    else
      test_fail "check_bun should succeed when bun is installed"
    fi

    if [[ -n "$BUN_PATH" ]]; then
      test_pass "check_bun sets BUN_PATH='${BUN_PATH}'"
    else
      test_fail "check_bun should set BUN_PATH when bun is found"
    fi
  else
    test_pass "check_bun test (installed): skipped (bun not installed)"
    test_pass "check_bun BUN_PATH test: skipped (bun not installed)"
  fi
}

test_check_bun_detects_installed_bun

test_check_bun_fails_when_not_found() {
  local fake_home
  fake_home="$(mktemp -d)"
  local exit_code=0
  bash -c '
    set -euo pipefail
    TERM=dumb
    export HOME="'"$fake_home"'"
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    PATH="/nonexistent"
    BUN_PATH=""
    check_bun
  ' >/dev/null 2>&1 || exit_code=$?
  rm -rf "$fake_home"

  if [[ "$exit_code" -ne 0 ]]; then
    test_pass "check_bun returns failure when bun is not in PATH"
  else
    test_fail "check_bun should return failure when bun is not in PATH"
  fi
}

test_check_bun_fails_when_not_found

test_find_bun_path_checks_home_bun_bin() {
  local fake_home
  fake_home="$(mktemp -d)"
  local saved_home="$HOME"
  HOME="$fake_home"
  BUN_PATH=""

  # Create a fake bun binary in ~/.bun/bin/
  mkdir -p "${fake_home}/.bun/bin"
  cat > "${fake_home}/.bun/bin/bun" <<'FAKEBUN'
#!/bin/bash
echo "1.2.0"
FAKEBUN
  chmod +x "${fake_home}/.bun/bin/bun"

  # Hide bun from PATH
  local saved_path="$PATH"
  PATH="/nonexistent"

  if find_bun_path 2>/dev/null; then
    assert_eq "${fake_home}/.bun/bin/bun" "$BUN_PATH" "find_bun_path finds bun in ~/.bun/bin/"
  else
    test_fail "find_bun_path should find bun in ~/.bun/bin/"
  fi

  HOME="$saved_home"
  PATH="$saved_path"
  rm -rf "$fake_home"
}

test_find_bun_path_checks_home_bun_bin

###############################################################################
# Test: find_openclaw() — not found scenario
###############################################################################

echo ""
echo "=== find_openclaw() ==="

# Save original PATH and test with empty locations
ORIGINAL_PATH="$PATH"
ORIGINAL_HOME="$HOME"

test_find_openclaw_not_found() {
  # Use a fake HOME where nothing exists
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  PATH="/nonexistent"
  OPENCLAW_PATH=""

  if find_openclaw 2>/dev/null; then
    test_fail "find_openclaw should return 1 when openclaw.mjs is not found"
  else
    test_pass "find_openclaw returns 1 when not found"
  fi

  assert_eq "" "$OPENCLAW_PATH" "OPENCLAW_PATH is empty when not found"

  HOME="$ORIGINAL_HOME"
  PATH="$ORIGINAL_PATH"
  rm -rf "$fake_home"
}

test_find_openclaw_not_found

# Test: find_openclaw() — found in HOME/.openclaw/
test_find_openclaw_in_home() {
  local fake_home
  fake_home="$(mktemp -d)"
  mkdir -p "${fake_home}/.openclaw"
  touch "${fake_home}/.openclaw/openclaw.mjs"

  HOME="$fake_home"
  PATH="/nonexistent"
  OPENCLAW_PATH=""

  if find_openclaw 2>/dev/null; then
    test_pass "find_openclaw finds openclaw.mjs in ~/.openclaw/"
    assert_eq "${fake_home}/.openclaw/openclaw.mjs" "$OPENCLAW_PATH" "OPENCLAW_PATH set correctly"
  else
    test_fail "find_openclaw should find openclaw.mjs in ~/.openclaw/"
  fi

  HOME="$ORIGINAL_HOME"
  PATH="$ORIGINAL_PATH"
  rm -rf "$fake_home"
}

test_find_openclaw_in_home

###############################################################################
# Test: configure_memory_slot() — creates new config
###############################################################################

echo ""
echo "=== configure_memory_slot() ==="

test_configure_new_config() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  configure_memory_slot >/dev/null 2>&1

  local config_file="${fake_home}/.openclaw/openclaw.json"
  assert_file_exists "$config_file" "Config file created at ~/.openclaw/openclaw.json"

  # Verify JSON structure
  local memory_slot
  memory_slot="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.slots.memory);")"
  assert_eq "claude-mem" "$memory_slot" "Memory slot set to claude-mem in new config"

  local enabled
  enabled="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);")"
  assert_eq "true" "$enabled" "claude-mem entry is enabled in new config"

  local worker_port
  worker_port="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);")"
  assert_eq "37777" "$worker_port" "Worker port is 37777 in new config"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_configure_new_config

# Test: configure_memory_slot() — updates existing config
test_configure_existing_config() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  # Create an existing config with other settings
  mkdir -p "${fake_home}/.openclaw"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  node -e "
    const config = {
      gateway: { mode: 'local' },
      plugins: {
        slots: { memory: 'memory-core' },
        entries: {
          'some-other-plugin': { enabled: true }
        }
      }
    };
    require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));
  "

  configure_memory_slot >/dev/null 2>&1

  # Verify memory slot was updated
  local memory_slot
  memory_slot="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.slots.memory);")"
  assert_eq "claude-mem" "$memory_slot" "Memory slot updated from memory-core to claude-mem"

  # Verify existing settings preserved
  local gateway_mode
  gateway_mode="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.gateway.mode);")"
  assert_eq "local" "$gateway_mode" "Existing gateway.mode setting preserved"

  # Verify other plugin still present
  local other_plugin
  other_plugin="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['some-other-plugin'].enabled);")"
  assert_eq "true" "$other_plugin" "Existing plugin entries preserved"

  # Verify claude-mem entry was added
  local cm_enabled
  cm_enabled="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);")"
  assert_eq "true" "$cm_enabled" "claude-mem entry added and enabled"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_configure_existing_config

# Test: configure_memory_slot() — preserves existing claude-mem config
test_configure_preserves_existing_cm_config() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  mkdir -p "${fake_home}/.openclaw"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  node -e "
    const config = {
      plugins: {
        slots: { memory: 'memory-core' },
        entries: {
          'claude-mem': {
            enabled: false,
            config: {
              workerPort: 38888,
              observationFeed: { enabled: true, channel: 'telegram', to: '12345' }
            }
          }
        }
      }
    };
    require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));
  "

  configure_memory_slot >/dev/null 2>&1

  # Should enable it but preserve existing config
  local cm_enabled
  cm_enabled="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].enabled);")"
  assert_eq "true" "$cm_enabled" "claude-mem entry enabled when previously disabled"

  local custom_port
  custom_port="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);")"
  assert_eq "38888" "$custom_port" "Existing custom workerPort preserved"

  local feed_channel
  feed_channel="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);")"
  assert_eq "telegram" "$feed_channel" "Existing observationFeed config preserved"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_configure_preserves_existing_cm_config

###############################################################################
# Test: version_gte() — already exists from phase 1
###############################################################################

echo ""
echo "=== version_gte() ==="

if version_gte "1.2.0" "1.1.14"; then
  test_pass "version_gte: 1.2.0 >= 1.1.14"
else
  test_fail "version_gte: 1.2.0 >= 1.1.14"
fi

if version_gte "1.1.14" "1.1.14"; then
  test_pass "version_gte: 1.1.14 >= 1.1.14 (equal)"
else
  test_fail "version_gte: 1.1.14 >= 1.1.14 (equal)"
fi

if ! version_gte "1.0.0" "1.1.14"; then
  test_pass "version_gte: 1.0.0 < 1.1.14"
else
  test_fail "version_gte: 1.0.0 < 1.1.14"
fi

###############################################################################
# Test: Script structure validation
###############################################################################

echo ""
echo "=== Script structure ==="

# Verify all required functions exist
for fn in find_openclaw check_openclaw install_plugin configure_memory_slot; do
  if declare -f "$fn" &>/dev/null; then
    test_pass "Function ${fn}() is defined"
  else
    test_fail "Function ${fn}() should be defined"
  fi
done

# Verify the CLAUDE_MEM_REPO constant
assert_contains "$CLAUDE_MEM_REPO" "github.com/thedotmack/claude-mem" "CLAUDE_MEM_REPO points to correct repository"

# Verify AI provider functions exist
for fn in setup_ai_provider write_settings mask_api_key; do
  if declare -f "$fn" &>/dev/null; then
    test_pass "Function ${fn}() is defined"
  else
    test_fail "Function ${fn}() should be defined"
  fi
done

###############################################################################
# Test: mask_api_key()
###############################################################################

echo ""
echo "=== mask_api_key() ==="

masked=$(mask_api_key "sk-1234567890abcdef")
assert_eq "***************cdef" "$masked" "mask_api_key masks all but last 4 chars"

masked_short=$(mask_api_key "abcd")
assert_eq "****" "$masked_short" "mask_api_key masks keys <= 4 chars entirely"

masked_five=$(mask_api_key "12345")
assert_eq "*2345" "$masked_five" "mask_api_key masks 5-char key correctly"

###############################################################################
# Test: setup_ai_provider() — non-interactive mode defaults to Claude
###############################################################################

echo ""
echo "=== setup_ai_provider() ==="

test_setup_ai_provider_non_interactive() {
  # NON_INTERACTIVE is readonly, so test in a child bash that sources with --non-interactive
  local ai_result
  ai_result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--non-interactive"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    echo "$AI_PROVIDER"
  ' 2>/dev/null)" || true

  assert_eq "claude" "$ai_result" "Non-interactive mode defaults to claude provider"
}

test_setup_ai_provider_non_interactive

###############################################################################
# Test: write_settings() — creates new settings.json with defaults
###############################################################################

echo ""
echo "=== write_settings() ==="

test_write_settings_new_file() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  AI_PROVIDER="claude"
  AI_PROVIDER_API_KEY=""

  write_settings >/dev/null 2>&1

  local settings_file="${fake_home}/.claude-mem/settings.json"
  assert_file_exists "$settings_file" "settings.json created at ~/.claude-mem/settings.json"

  # Verify it's valid JSON with expected defaults
  local provider
  provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")"
  assert_eq "claude" "$provider" "CLAUDE_MEM_PROVIDER set to claude"

  local auth_method
  auth_method="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_CLAUDE_AUTH_METHOD);")"
  assert_eq "cli" "$auth_method" "CLAUDE_MEM_CLAUDE_AUTH_METHOD set to cli for Claude provider"

  local worker_port
  worker_port="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_WORKER_PORT);")"
  assert_eq "37777" "$worker_port" "CLAUDE_MEM_WORKER_PORT defaults to 37777"

  local model
  model="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_MODEL);")"
  assert_eq "claude-sonnet-4-5" "$model" "CLAUDE_MEM_MODEL defaults to claude-sonnet-4-5"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_settings_new_file

# Test: write_settings() — Gemini provider with API key
test_write_settings_gemini() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  AI_PROVIDER="gemini"
  AI_PROVIDER_API_KEY="test-gemini-key-1234"

  write_settings >/dev/null 2>&1

  local settings_file="${fake_home}/.claude-mem/settings.json"

  local provider
  provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")"
  assert_eq "gemini" "$provider" "Gemini: CLAUDE_MEM_PROVIDER set to gemini"

  local api_key
  [REDACTED_SECRET] -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_API_KEY);")"
  assert_eq "test-gemini-key-1234" "$api_key" "Gemini: API key stored in settings"

  local gemini_model
  gemini_model="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_MODEL);")"
  assert_eq "gemini-2.5-flash-lite" "$gemini_model" "Gemini: model defaults to gemini-2.5-flash-lite"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_settings_gemini

# Test: write_settings() — OpenRouter provider with API key
test_write_settings_openrouter() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  AI_PROVIDER="openrouter"
  AI_PROVIDER_API_KEY="sk-or-test-key-5678"

  write_settings >/dev/null 2>&1

  local settings_file="${fake_home}/.claude-mem/settings.json"

  local provider
  provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")"
  assert_eq "openrouter" "$provider" "OpenRouter: CLAUDE_MEM_PROVIDER set to openrouter"

  local api_key
  [REDACTED_SECRET] -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_OPENROUTER_API_KEY);")"
  assert_eq "sk-or-test-key-5678" "$api_key" "OpenRouter: API key stored in settings"

  local or_model
  or_model="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_OPENROUTER_MODEL);")"
  assert_eq "xiaomi/mimo-v2-flash:free" "$or_model" "OpenRouter: model defaults to xiaomi/mimo-v2-flash:free"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_settings_openrouter

# Test: write_settings() — preserves existing user customizations
test_write_settings_preserves_existing() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  # Create existing settings with custom values
  mkdir -p "${fake_home}/.claude-mem"
  local settings_file="${fake_home}/.claude-mem/settings.json"
  node -e "
    const settings = {
      CLAUDE_MEM_PROVIDER: 'gemini',
      CLAUDE_MEM_GEMINI_API_KEY: 'old-key',
      CLAUDE_MEM_WORKER_PORT: '38888',
      CLAUDE_MEM_LOG_LEVEL: 'DEBUG'
    };
    require('fs').writeFileSync('${settings_file}', JSON.stringify(settings, null, 2));
  "

  # Now run write_settings with a new provider
  AI_PROVIDER="claude"
  AI_PROVIDER_API_KEY=""
  write_settings >/dev/null 2>&1

  # Provider should be updated to claude
  local provider
  provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")"
  assert_eq "claude" "$provider" "Preserve: provider updated to new selection"

  # Custom port should be preserved (not overwritten by defaults)
  local custom_port
  custom_port="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_WORKER_PORT);")"
  assert_eq "38888" "$custom_port" "Preserve: existing custom WORKER_PORT preserved"

  # Custom log level should be preserved
  local log_level
  log_level="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_LOG_LEVEL);")"
  assert_eq "DEBUG" "$log_level" "Preserve: existing custom LOG_LEVEL preserved"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_settings_preserves_existing

# Test: write_settings() — flat schema has all expected keys
test_write_settings_complete_schema() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  AI_PROVIDER="claude"
  AI_PROVIDER_API_KEY=""

  write_settings >/dev/null 2>&1

  local settings_file="${fake_home}/.claude-mem/settings.json"

  # Verify key count matches SettingsDefaultsManager (34 keys)
  local key_count
  key_count="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(Object.keys(s).length);")"

  # Settings should have all 34 keys from SettingsDefaultsManager
  if (( key_count >= 30 )); then
    test_pass "Settings file has ${key_count} keys (complete schema)"
  else
    test_fail "Settings file has ${key_count} keys, expected >= 30" "Schema may be incomplete"
  fi

  # Verify it does NOT have nested { env: {...} } format
  local has_env_key
  has_env_key="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.env !== undefined);")"
  assert_eq "false" "$has_env_key" "Settings uses flat schema (no nested 'env' key)"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_settings_complete_schema

###############################################################################
# Test: find_claude_mem_install_dir() — not found scenario
###############################################################################

echo ""
echo "=== find_claude_mem_install_dir() ==="

test_find_install_dir_not_found() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  if find_claude_mem_install_dir 2>/dev/null; then
    test_fail "find_claude_mem_install_dir should return 1 when not found"
  else
    test_pass "find_claude_mem_install_dir returns 1 when not found"
  fi

  assert_eq "" "$CLAUDE_MEM_INSTALL_DIR" "CLAUDE_MEM_INSTALL_DIR is empty when not found"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_find_install_dir_not_found

# Test: find_claude_mem_install_dir() — found in ~/.openclaw/extensions/claude-mem/
test_find_install_dir_openclaw_extensions() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  # Create the expected directory structure
  mkdir -p "${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts"
  touch "${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts/worker-service.cjs"

  if find_claude_mem_install_dir 2>/dev/null; then
    test_pass "find_claude_mem_install_dir finds dir in ~/.openclaw/extensions/claude-mem/"
    assert_eq "${fake_home}/.openclaw/extensions/claude-mem" "$CLAUDE_MEM_INSTALL_DIR" "CLAUDE_MEM_INSTALL_DIR set correctly for openclaw extensions"
  else
    test_fail "find_claude_mem_install_dir should find dir in ~/.openclaw/extensions/claude-mem/"
  fi

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_find_install_dir_openclaw_extensions

# Test: find_claude_mem_install_dir() — found in ~/.claude/plugins/marketplaces/thedotmack/
test_find_install_dir_marketplace() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  mkdir -p "${fake_home}/.claude/plugins/marketplaces/thedotmack/plugin/scripts"
  touch "${fake_home}/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"

  if find_claude_mem_install_dir 2>/dev/null; then
    test_pass "find_claude_mem_install_dir finds dir in marketplace path"
    assert_eq "${fake_home}/.claude/plugins/marketplaces/thedotmack" "$CLAUDE_MEM_INSTALL_DIR" "CLAUDE_MEM_INSTALL_DIR set correctly for marketplace"
  else
    test_fail "find_claude_mem_install_dir should find dir in marketplace path"
  fi

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_find_install_dir_marketplace

###############################################################################
# Test: start_worker() — fails gracefully when install dir not found
###############################################################################

echo ""
echo "=== start_worker() ==="

test_start_worker_no_install_dir() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  local output
  if output="$(start_worker 2>&1)"; then
    test_fail "start_worker should fail when install dir not found"
  else
    test_pass "start_worker returns error when install dir not found"
  fi

  assert_contains "$output" "Cannot find claude-mem plugin installation directory" "start_worker error message mentions install dir"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_start_worker_no_install_dir

###############################################################################
# Test: verify_health() — fails when no server is running
###############################################################################

echo ""
echo "=== verify_health() ==="

test_verify_health_no_server() {
  # verify_health should fail gracefully when nothing is running on 37777
  # We use a very short test — just 1 attempt to keep the test fast
  # Override the function to test with fewer attempts by running in a subshell
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    # Call verify_health which will attempt 10 polls — capture exit code
    verify_health 2>/dev/null && echo "PASS" || echo "FAIL"
  ' 2>/dev/null)" || true

  # Note: This test may take ~10 seconds due to polling
  # If curl is not available, it will also fail
  if [[ "$result" == *"FAIL"* ]]; then
    test_pass "verify_health returns failure when no server is running"
  else
    # Could pass if something is actually running on 37777
    test_pass "verify_health returned success (worker may already be running on 37777)"
  fi
}

# Only run the health check test if curl is available
if command -v curl &>/dev/null; then
  test_verify_health_no_server
else
  test_pass "verify_health test skipped (curl not available)"
fi

###############################################################################
# Test: print_completion_summary() — runs without error
###############################################################################

echo ""
echo "=== print_completion_summary() ==="

test_print_completion_summary() {
  AI_PROVIDER="claude"
  WORKER_PID=""
  FEED_CONFIGURED=false
  FEED_CHANNEL=""
  FEED_TARGET_ID=""

  local output
  output="$(print_completion_summary 2>&1)"

  assert_contains "$output" "Installation Complete" "Completion summary shows 'Installation Complete'"
  assert_contains "$output" "Claude Max Plan" "Completion summary shows correct provider"
  assert_contains "$output" "not configured" "Completion summary shows feed 'not configured' when skipped"
  assert_contains "$output" "What's next" "Completion summary shows What's next section"
  assert_contains "$output" "/claude-mem-status" "Completion summary mentions status command"
  assert_contains "$output" "localhost:37777" "Completion summary mentions viewer URL"
  assert_contains "$output" "re-run this installer" "Completion summary shows re-run instructions"
}

test_print_completion_summary

test_print_completion_summary_gemini() {
  AI_PROVIDER="gemini"
  WORKER_PID=""
  FEED_CONFIGURED=false

  local output
  output="$(print_completion_summary 2>&1)"

  assert_contains "$output" "Gemini" "Gemini provider shown in completion summary"
}

test_print_completion_summary_gemini

test_print_completion_summary_openrouter() {
  AI_PROVIDER="openrouter"
  WORKER_PID=""
  FEED_CONFIGURED=false

  local output
  output="$(print_completion_summary 2>&1)"

  assert_contains "$output" "OpenRouter" "OpenRouter provider shown in completion summary"
}

test_print_completion_summary_openrouter

###############################################################################
# Test: Script structure — new functions exist
###############################################################################

echo ""
echo "=== New function existence ==="

for fn in find_claude_mem_install_dir start_worker verify_health print_completion_summary; do
  if declare -f "$fn" &>/dev/null; then
    test_pass "Function ${fn}() is defined"
  else
    test_fail "Function ${fn}() should be defined"
  fi
done

###############################################################################
# Test: main() function calls new functions in correct order
###############################################################################

echo ""
echo "=== main() function structure ==="

# Verify main calls the new functions by checking the install.sh source
test_main_calls_start_worker() {
  if grep -q 'start_worker' "$INSTALL_SCRIPT"; then
    test_pass "main() calls start_worker"
  else
    test_fail "main() should call start_worker"
  fi
}

test_main_calls_start_worker

test_main_calls_verify_health() {
  if grep -q 'verify_health' "$INSTALL_SCRIPT"; then
    test_pass "main() calls verify_health"
  else
    test_fail "main() should call verify_health"
  fi
}

test_main_calls_verify_health

test_main_calls_completion_summary() {
  if grep -q 'print_completion_summary' "$INSTALL_SCRIPT"; then
    test_pass "main() calls print_completion_summary"
  else
    test_fail "main() should call print_completion_summary"
  fi
}

test_main_calls_completion_summary

test_main_has_progress_indicators() {
  if grep -q '\[1/8\]' "$INSTALL_SCRIPT" && grep -q '\[8/8\]' "$INSTALL_SCRIPT"; then
    test_pass "main() has progress indicators [1/8] through [8/8]"
  else
    test_fail "main() should have progress indicators [1/8] through [8/8]"
  fi
}

test_main_has_progress_indicators

test_main_calls_setup_observation_feed() {
  if grep -q 'setup_observation_feed' "$INSTALL_SCRIPT"; then
    test_pass "main() calls setup_observation_feed"
  else
    test_fail "main() should call setup_observation_feed"
  fi
}

test_main_calls_setup_observation_feed

test_main_calls_write_observation_feed_config() {
  if grep -q 'write_observation_feed_config' "$INSTALL_SCRIPT"; then
    test_pass "main() calls write_observation_feed_config"
  else
    test_fail "main() should call write_observation_feed_config"
  fi
}

test_main_calls_write_observation_feed_config

###############################################################################
# Test: setup_observation_feed() — function exists and non-interactive skips
###############################################################################

echo ""
echo "=== setup_observation_feed() ==="

for fn in setup_observation_feed write_observation_feed_config; do
  if declare -f "$fn" &>/dev/null; then
    test_pass "Function ${fn}() is defined"
  else
    test_fail "Function ${fn}() should be defined"
  fi
done

test_setup_observation_feed_non_interactive() {
  # Non-interactive mode should skip feed setup without error
  local feed_result
  feed_result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--non-interactive"
    source "$tmp"
    rm -f "$tmp"
    setup_observation_feed 2>/dev/null
    echo "CHANNEL=$FEED_CHANNEL"
    echo "CONFIGURED=$FEED_CONFIGURED"
  ' 2>/dev/null)" || true

  assert_contains "$feed_result" "CHANNEL=" "Non-interactive mode: FEED_CHANNEL is empty"
  assert_contains "$feed_result" "CONFIGURED=false" "Non-interactive mode: FEED_CONFIGURED is false"
}

test_setup_observation_feed_non_interactive

###############################################################################
# Test: write_observation_feed_config() — writes correct JSON structure
###############################################################################

echo ""
echo "=== write_observation_feed_config() ==="

test_write_observation_feed_config_writes_json() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  # Create an existing openclaw.json with claude-mem entry
  mkdir -p "${fake_home}/.openclaw"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  node -e "
    const config = {
      plugins: {
        slots: { memory: 'claude-mem' },
        entries: {
          'claude-mem': {
            enabled: true,
            config: { workerPort: 37777, syncMemoryFile: true }
          }
        }
      }
    };
    require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));
  "

  FEED_CHANNEL="telegram"
  FEED_TARGET_ID="123456789"
  FEED_CONFIGURED="true"

  write_observation_feed_config >/dev/null 2>&1

  # Verify observationFeed was written
  local feed_enabled
  feed_enabled="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.enabled);")"
  assert_eq "true" "$feed_enabled" "observationFeed.enabled is true"

  local feed_channel
  feed_channel="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);")"
  assert_eq "telegram" "$feed_channel" "observationFeed.channel is telegram"

  local feed_to
  feed_to="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);")"
  assert_eq "123456789" "$feed_to" "observationFeed.to is 123456789"

  # Verify existing config preserved
  local worker_port
  worker_port="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);")"
  assert_eq "37777" "$worker_port" "Existing workerPort preserved after feed config write"

  HOME="$ORIGINAL_HOME"
  FEED_CHANNEL=""
  FEED_TARGET_ID=""
  FEED_CONFIGURED=false
  rm -rf "$fake_home"
}

test_write_observation_feed_config_writes_json

test_write_observation_feed_config_skips_when_not_configured() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  # Create minimal config
  mkdir -p "${fake_home}/.openclaw"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  node -e "
    require('fs').writeFileSync('${config_file}', JSON.stringify({ plugins: {} }, null, 2));
  "

  FEED_CONFIGURED="false"

  write_observation_feed_config >/dev/null 2>&1

  # Config should be unchanged — no observationFeed key
  local has_feed
  has_feed="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries !== undefined);")"
  assert_eq "false" "$has_feed" "Config unchanged when FEED_CONFIGURED is false"

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_write_observation_feed_config_skips_when_not_configured

test_write_observation_feed_config_discord() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"

  mkdir -p "${fake_home}/.openclaw"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  node -e "
    const config = {
      plugins: {
        entries: {
          'claude-mem': { enabled: true, config: {} }
        }
      }
    };
    require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));
  "

  FEED_CHANNEL="discord"
  FEED_TARGET_ID="1234567890123456789"
  FEED_CONFIGURED="true"

  write_observation_feed_config >/dev/null 2>&1

  local feed_channel
  feed_channel="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);")"
  assert_eq "discord" "$feed_channel" "Discord channel type written correctly"

  local feed_to
  feed_to="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);")"
  assert_eq "1234567890123456789" "$feed_to" "Discord channel ID written correctly"

  HOME="$ORIGINAL_HOME"
  FEED_CHANNEL=""
  FEED_TARGET_ID=""
  FEED_CONFIGURED=false
  rm -rf "$fake_home"
}

test_write_observation_feed_config_discord

###############################################################################
# Test: write_observation_feed_config() — jq/python3/node fallback paths
###############################################################################

echo ""
echo "=== write_observation_feed_config() — fallback paths ==="

# Helper: verify feed config JSON was written correctly
verify_feed_config_json() {
  local config_file="$1" expected_channel="$2" expected_target="$3" label="$4"

  local feed_enabled
  feed_enabled="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.enabled);")"
  assert_eq "true" "$feed_enabled" "${label}: observationFeed.enabled is true"

  local feed_channel
  feed_channel="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.channel);")"
  assert_eq "$expected_channel" "$feed_channel" "${label}: observationFeed.channel correct"

  local feed_to
  feed_to="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.observationFeed.to);")"
  assert_eq "$expected_target" "$feed_to" "${label}: observationFeed.to correct"

  # Verify existing config preserved
  local worker_port
  worker_port="$(node -e "const c = JSON.parse(require('fs').readFileSync('${config_file}','utf8')); console.log(c.plugins.entries['claude-mem'].config.workerPort);")"
  assert_eq "37777" "$worker_port" "${label}: existing workerPort preserved"
}

# Create a seed config file for fallback tests
create_seed_config() {
  local config_file="$1"
  mkdir -p "$(dirname "$config_file")"
  node -e "
    const config = {
      plugins: {
        slots: { memory: 'claude-mem' },
        entries: {
          'claude-mem': {
            enabled: true,
            config: { workerPort: 37777, syncMemoryFile: true }
          }
        }
      }
    };
    require('fs').writeFileSync('${config_file}', JSON.stringify(config, null, 2));
  "
}

# Test: jq path (if jq is available)
test_write_feed_config_jq_path() {
  if ! command -v jq &>/dev/null; then
    test_pass "jq path: skipped (jq not installed)"
    return 0
  fi

  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  local config_file="${fake_home}/.openclaw/openclaw.json"
  create_seed_config "$config_file"

  FEED_CHANNEL="slack"
  FEED_TARGET_ID="C01ABC2DEFG"
  FEED_CONFIGURED="true"

  # jq is first in the chain, so just call directly
  write_observation_feed_config >/dev/null 2>&1

  verify_feed_config_json "$config_file" "slack" "C01ABC2DEFG" "jq path"

  HOME="$ORIGINAL_HOME"
  FEED_CHANNEL=""
  FEED_TARGET_ID=""
  FEED_CONFIGURED=false
  rm -rf "$fake_home"
}

test_write_feed_config_jq_path

# Test: python3 fallback path (hide jq)
test_write_feed_config_python3_path() {
  if ! command -v python3 &>/dev/null; then
    test_pass "python3 path: skipped (python3 not installed)"
    return 0
  fi

  local fake_home
  fake_home="$(mktemp -d)"

  # Run in a subshell that hides jq from PATH
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    export HOME="'"$fake_home"'"

    # Create seed config using node (node is always available)
    mkdir -p "'"${fake_home}"'/.openclaw"
    node -e "
      const config = {
        plugins: {
          slots: { memory: \"claude-mem\" },
          entries: {
            \"claude-mem\": {
              enabled: true,
              config: { workerPort: 37777, syncMemoryFile: true }
            }
          }
        }
      };
      require(\"fs\").writeFileSync(\"'"${fake_home}"'/.openclaw/openclaw.json\", JSON.stringify(config, null, 2));
    "

    # Source install.sh functions
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"

    # Hide jq by creating a PATH without it
    SAFE_PATH=""
    IFS=":" read -ra path_parts <<< "$PATH"
    for p in "${path_parts[@]}"; do
      if [[ ! -x "${p}/jq" ]]; then
        SAFE_PATH="${SAFE_PATH:+${SAFE_PATH}:}${p}"
      fi
    done
    export PATH="$SAFE_PATH"

    FEED_CHANNEL="signal"
    FEED_TARGET_ID="+15551234567"
    FEED_CONFIGURED="true"
    write_observation_feed_config >/dev/null 2>&1
    echo "DONE"
  ' 2>/dev/null)" || true

  if [[ "$result" == *"DONE"* ]]; then
    # Verify the JSON using node
    local config_file="${fake_home}/.openclaw/openclaw.json"
    verify_feed_config_json "$config_file" "signal" "+15551234567" "python3 path"
  else
    test_fail "python3 path: write_observation_feed_config failed"
  fi

  rm -rf "$fake_home"
}

test_write_feed_config_python3_path

# Test: node fallback path (hide both jq and python3)
test_write_feed_config_node_path() {
  local fake_home
  fake_home="$(mktemp -d)"

  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    export HOME="'"$fake_home"'"

    # Create seed config
    mkdir -p "'"${fake_home}"'/.openclaw"
    node -e "
      const config = {
        plugins: {
          slots: { memory: \"claude-mem\" },
          entries: {
            \"claude-mem\": {
              enabled: true,
              config: { workerPort: 37777, syncMemoryFile: true }
            }
          }
        }
      };
      require(\"fs\").writeFileSync(\"'"${fake_home}"'/.openclaw/openclaw.json\", JSON.stringify(config, null, 2));
    "

    # Create a shadow directory with non-functional jq and python3
    # This makes "command -v" find them but they will fail, so the
    # install script will not actually use them successfully.
    # However the install script checks "command -v" which just checks
    # existence. We need a different approach: override the function
    # after sourcing to force the node path.

    # Source install.sh functions
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"

    # Override write_observation_feed_config to only use the node path
    # by extracting just the node branch logic
    INSTALLER_FEED_CHANNEL="whatsapp" \
    INSTALLER_FEED_TARGET_ID="[REDACTED_SECRET]" \
    INSTALLER_CONFIG_FILE="'"${fake_home}"'/.openclaw/openclaw.json" \
    node -e "
      const fs = require(\"fs\");
      const configPath = process.env.INSTALLER_CONFIG_FILE;
      const channel = process.env.INSTALLER_FEED_CHANNEL;
      const targetId = process.env.INSTALLER_FEED_TARGET_ID;

      const config = JSON.parse(fs.readFileSync(configPath, \"utf8\"));

      if (!config.plugins) config.plugins = {};
      if (!config.plugins.entries) config.plugins.entries = {};
      if (!config.plugins.entries[\"claude-mem\"]) {
        config.plugins.entries[\"claude-mem\"] = { enabled: true, config: {} };
      }
      if (!config.plugins.entries[\"claude-mem\"].config) {
        config.plugins.entries[\"claude-mem\"].config = {};
      }

      config.plugins.entries[\"claude-mem\"].config.observationFeed = {
        enabled: true,
        channel: channel,
        to: targetId
      };

      fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
    "
    echo "DONE"
  ' 2>/dev/null)" || true

  if [[ "$result" == *"DONE"* ]]; then
    local config_file="${fake_home}/.openclaw/openclaw.json"
    verify_feed_config_json "$config_file" "whatsapp" "[REDACTED_SECRET]" "node path"
  else
    test_fail "node path: write_observation_feed_config failed"
  fi

  rm -rf "$fake_home"
}

test_write_feed_config_node_path

# Test: write_observation_feed_config uses jq/python3/node fallback chain
test_feed_config_fallback_chain_in_source() {
  if grep -q 'command -v jq' "$INSTALL_SCRIPT"; then
    test_pass "write_observation_feed_config checks for jq first"
  else
    test_fail "write_observation_feed_config should check for jq"
  fi

  if grep -q 'command -v python3' "$INSTALL_SCRIPT"; then
    test_pass "write_observation_feed_config has python3 fallback"
  else
    test_fail "write_observation_feed_config should have python3 fallback"
  fi

  if grep -q 'node -e' "$INSTALL_SCRIPT"; then
    test_pass "write_observation_feed_config has node fallback"
  else
    test_fail "write_observation_feed_config should have node fallback"
  fi
}

test_feed_config_fallback_chain_in_source

###############################################################################
# Test: print_completion_summary() — shows observation feed status
###############################################################################

echo ""
echo "=== print_completion_summary() — observation feed ==="

test_completion_summary_with_feed() {
  AI_PROVIDER="claude"
  WORKER_PID=""
  FEED_CONFIGURED="true"
  FEED_CHANNEL="telegram"
  FEED_TARGET_ID="123456789"

  local output
  output="$(print_completion_summary 2>&1)"

  assert_contains "$output" "telegram" "Summary shows feed channel when configured"
  assert_contains "$output" "123456789" "Summary shows feed target when configured"
  assert_contains "$output" "What's next" "Summary includes What's next section"
  assert_contains "$output" "/claude-mem-feed" "Summary includes feed check command when configured"

  FEED_CONFIGURED=false
  FEED_CHANNEL=""
  FEED_TARGET_ID=""
}

test_completion_summary_with_feed

test_completion_summary_without_feed() {
  AI_PROVIDER="claude"
  WORKER_PID=""
  FEED_CONFIGURED=false
  FEED_CHANNEL=""
  FEED_TARGET_ID=""

  local output
  output="$(print_completion_summary 2>&1)"

  assert_contains "$output" "not configured" "Summary shows 'not configured' when feed skipped"
  assert_contains "$output" "What's next" "Summary includes What's next section without feed"
  assert_contains "$output" "/claude-mem-status" "Summary includes status check command"
  assert_contains "$output" "localhost:37777" "Summary includes viewer URL"
}

test_completion_summary_without_feed

###############################################################################
# Test: Channel type instructions exist in install.sh
###############################################################################

echo ""
echo "=== Channel instructions ==="

for channel in telegram discord slack signal whatsapp line; do
  if grep -qi "$channel" "$INSTALL_SCRIPT"; then
    test_pass "Channel '${channel}' instructions exist in install.sh"
  else
    test_fail "Channel '${channel}' instructions should exist in install.sh"
  fi
done

# Verify specific instruction content
assert_contains "$(grep -A2 'userinfobot' "$INSTALL_SCRIPT" 2>/dev/null || echo '')" "userinfobot" "Telegram instructions include @userinfobot"
assert_contains "$(grep -A2 'Developer Mode' "$INSTALL_SCRIPT" 2>/dev/null || echo '')" "Developer Mode" "Discord instructions include Developer Mode"
assert_contains "$(grep -A2 'C01ABC2DEFG' "$INSTALL_SCRIPT" 2>/dev/null || echo '')" "C01ABC2DEFG" "Slack instructions include sample channel ID"

###############################################################################
# Test: TTY detection — setup_tty() and read_tty() exist
###############################################################################

echo ""
echo "=== TTY detection ==="

for fn in setup_tty read_tty; do
  if declare -f "$fn" &>/dev/null; then
    test_pass "Function ${fn}() is defined"
  else
    test_fail "Function ${fn}() should be defined"
  fi
done

# Verify TTY_FD is initialized (defaults to 0)
if declare -p TTY_FD &>/dev/null; then
  test_pass "TTY_FD variable is defined"
else
  test_fail "TTY_FD variable should be defined"
fi

# Verify setup_tty is called from main()
if grep -q 'setup_tty' "$INSTALL_SCRIPT"; then
  test_pass "main() calls setup_tty"
else
  test_fail "main() should call setup_tty"
fi

###############################################################################
# Test: Argument parsing — --provider flag
###############################################################################

echo ""
echo "=== Argument parsing — --provider flag ==="

test_provider_flag_claude() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--provider=claude"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    echo "$AI_PROVIDER"
  ' 2>/dev/null)" || true

  assert_eq "claude" "$result" "--provider=claude sets AI_PROVIDER to claude"
}

test_provider_flag_claude

test_provider_flag_gemini_with_api_key() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--provider=gemini" "--[REDACTED_SECRET]"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    echo "PROVIDER=$AI_PROVIDER"
    echo "KEY=$AI_PROVIDER_API_KEY"
  ' 2>/dev/null)" || true

  assert_contains "$result" "PROVIDER=gemini" "--provider=gemini sets AI_PROVIDER to gemini"
  assert_contains "$result" "KEY=test-key-123" "--[REDACTED_SECRET] sets API key"
}

test_provider_flag_gemini_with_api_key

test_provider_flag_openrouter() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--provider=openrouter" "--[REDACTED_SECRET]"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    echo "PROVIDER=$AI_PROVIDER"
    echo "KEY=$AI_PROVIDER_API_KEY"
  ' 2>/dev/null)" || true

  assert_contains "$result" "PROVIDER=openrouter" "--provider=openrouter sets AI_PROVIDER"
  assert_contains "$result" "KEY=sk-or-test" "--api-key sets API key for openrouter"
}

test_provider_flag_openrouter

test_provider_flag_invalid() {
  local exit_code=0
  bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--provider=invalid"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider
  ' >/dev/null 2>&1 || exit_code=$?

  if [[ "$exit_code" -ne 0 ]]; then
    test_pass "--provider=invalid exits with error"
  else
    test_fail "--provider=invalid should exit with error"
  fi
}

test_provider_flag_invalid

###############################################################################
# Test: Argument parsing — --non-interactive flag (new format)
###############################################################################

echo ""
echo "=== Argument parsing — --non-interactive ==="

test_non_interactive_flag() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--non-interactive"
    source "$tmp"
    rm -f "$tmp"
    echo "NON_INTERACTIVE=$NON_INTERACTIVE"
  ' 2>/dev/null)" || true

  assert_contains "$result" "NON_INTERACTIVE=true" "--non-interactive sets NON_INTERACTIVE=true"
}

test_non_interactive_flag

test_non_interactive_with_provider() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--non-interactive" "--provider=gemini" "--[REDACTED_SECRET]"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    echo "PROVIDER=$AI_PROVIDER"
    echo "KEY=$AI_PROVIDER_API_KEY"
    echo "NON_INTERACTIVE=$NON_INTERACTIVE"
  ' 2>/dev/null)" || true

  assert_contains "$result" "PROVIDER=gemini" "--non-interactive + --provider: provider set correctly"
  assert_contains "$result" "KEY=my-key" "--non-interactive + --api-key: key set correctly"
  assert_contains "$result" "NON_INTERACTIVE=true" "--non-interactive flag parsed alongside --provider"
}

test_non_interactive_with_provider

###############################################################################
# Test: --non-interactive mode completes without hanging
###############################################################################

echo ""
echo "=== --non-interactive full flow ==="

test_non_interactive_completes() {
  # Run the full setup_ai_provider + setup_observation_feed in non-interactive mode
  # This should complete without any prompts or hangs
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--non-interactive"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider 2>/dev/null
    setup_observation_feed 2>/dev/null
    echo "AI=$AI_PROVIDER"
    echo "FEED=$FEED_CONFIGURED"
  ' 2>/dev/null)" || true

  assert_contains "$result" "AI=claude" "--non-interactive: AI provider defaults to claude"
  assert_contains "$result" "FEED=false" "--non-interactive: observation feed skipped"
}

test_non_interactive_completes

###############################################################################
# Test: Script structure — curl | bash usage comment
###############################################################################

echo ""
echo "=== curl | bash usage comment ==="

if grep -q 'curl -fsSL.*raw.githubusercontent.com.*install.sh | bash' "$INSTALL_SCRIPT"; then
  test_pass "install.sh contains curl | bash usage comment"
else
  test_fail "install.sh should contain curl | bash usage comment"
fi

if grep -q 'bash -s -- --provider=' "$INSTALL_SCRIPT"; then
  test_pass "install.sh documents --provider flag in usage comment"
else
  test_fail "install.sh should document --provider flag in usage comment"
fi

###############################################################################
# Test: write_settings with --provider flag end-to-end
###############################################################################

echo ""
echo "=== write_settings with --provider flag ==="

test_write_settings_via_provider_flag() {
  local fake_home
  fake_home="$(mktemp -d)"

  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    export HOME="'"$fake_home"'"
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--provider=gemini" "--[REDACTED_SECRET]"
    source "$tmp"
    rm -f "$tmp"
    setup_ai_provider >/dev/null 2>&1
    write_settings >/dev/null 2>&1
    echo "DONE"
  ' 2>/dev/null)" || true

  if [[ "$result" == *"DONE"* ]]; then
    local settings_file="${fake_home}/.claude-mem/settings.json"
    local provider
    provider="$(node -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_PROVIDER);")"
    assert_eq "gemini" "$provider" "--provider flag: settings.json has provider=gemini"

    local api_key
    [REDACTED_SECRET] -e "const s = JSON.parse(require('fs').readFileSync('${settings_file}','utf8')); console.log(s.CLAUDE_MEM_GEMINI_API_KEY);")"
    assert_eq "test-end-to-end-key" "$api_key" "--provider flag: settings.json has correct API key"
  else
    test_fail "--provider flag: write_settings failed"
  fi

  rm -rf "$fake_home"
}

test_write_settings_via_provider_flag

###############################################################################
# Test: --upgrade flag parsing
###############################################################################

echo ""
echo "=== --upgrade flag parsing ==="

test_upgrade_flag() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--upgrade"
    source "$tmp"
    rm -f "$tmp"
    echo "UPGRADE=$UPGRADE_MODE"
  ' 2>/dev/null)" || true

  assert_contains "$result" "UPGRADE=true" "--upgrade sets UPGRADE_MODE=true"
}

test_upgrade_flag

test_upgrade_flag_with_provider() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    set -- "--upgrade" "--provider=gemini" "--[REDACTED_SECRET]"
    source "$tmp"
    rm -f "$tmp"
    echo "UPGRADE=$UPGRADE_MODE"
    echo "PROVIDER=$CLI_PROVIDER"
    echo "KEY=$CLI_API_KEY"
  ' 2>/dev/null)" || true

  assert_contains "$result" "UPGRADE=true" "--upgrade + --provider: upgrade flag parsed"
  assert_contains "$result" "PROVIDER=gemini" "--upgrade + --provider: provider flag parsed"
  assert_contains "$result" "KEY=upgrade-key" "--upgrade + --api-key: API key parsed"
}

test_upgrade_flag_with_provider

test_upgrade_not_set_by_default() {
  local result
  result="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    echo "UPGRADE=${UPGRADE_MODE:-}"
  ' 2>/dev/null)" || true

  assert_eq "UPGRADE=" "$result" "UPGRADE_MODE is empty by default"
}

test_upgrade_not_set_by_default

###############################################################################
# Test: is_claude_mem_installed() — upgrade detection
###############################################################################

echo ""
echo "=== is_claude_mem_installed() ==="

test_is_claude_mem_installed_found() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  # Create the expected directory structure
  mkdir -p "${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts"
  touch "${fake_home}/.openclaw/extensions/claude-mem/plugin/scripts/worker-service.cjs"

  if is_claude_mem_installed; then
    test_pass "is_claude_mem_installed returns true when plugin exists"
  else
    test_fail "is_claude_mem_installed should return true when plugin exists"
  fi

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_is_claude_mem_installed_found

test_is_claude_mem_installed_not_found() {
  local fake_home
  fake_home="$(mktemp -d)"
  HOME="$fake_home"
  CLAUDE_MEM_INSTALL_DIR=""

  if is_claude_mem_installed; then
    test_fail "is_claude_mem_installed should return false when plugin not found"
  else
    test_pass "is_claude_mem_installed returns false when plugin not found"
  fi

  HOME="$ORIGINAL_HOME"
  rm -rf "$fake_home"
}

test_is_claude_mem_installed_not_found

###############################################################################
# Test: check_git() — git availability check
###############################################################################

echo ""
echo "=== check_git() ==="

test_check_git_available() {
  # git should be available in test environment
  if command -v git &>/dev/null; then
    local output
    output="$(check_git 2>&1)" || true
    test_pass "check_git succeeds when git is installed"
  else
    test_pass "check_git test skipped (git not available)"
  fi
}

test_check_git_available

test_check_git_not_available() {
  # Test that check_git fails gracefully when git is not in PATH
  local exit_code=0
  PLATFORM="macos"
  bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    PATH="/nonexistent"
    check_git
  ' >/dev/null 2>&1 || exit_code=$?

  if [[ "$exit_code" -ne 0 ]]; then
    test_pass "check_git exits with error when git is missing"
  else
    test_fail "check_git should exit with error when git is missing"
  fi
}

test_check_git_not_available

test_check_git_macos_message() {
  local output
  output="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    PATH="/nonexistent"
    PLATFORM="macos"
    check_git
  ' 2>&1)" || true

  assert_contains "$output" "xcode-select" "check_git suggests xcode-select on macOS"
}

test_check_git_macos_message

test_check_git_linux_message() {
  local output
  output="$(bash -c '
    set -euo pipefail
    TERM=dumb
    tmp=$(mktemp)
    sed "$ d" "'"${INSTALL_SCRIPT}"'" > "$tmp"
    echo "main() { :; }" >> "$tmp"
    source "$tmp"
    rm -f "$tmp"
    PATH="/nonexistent"
    PLATFORM="linux"
    check_git
  ' 2>&1)" || true

  assert_contains "$output" "apt install git" "check_git suggests apt on Linux"
}

test_check_git_linux_message

###############################################################################
# Test: check_port_37777() — port conflict detection
###############################################################################

echo ""
echo "=== check_port_37777() ==="

test_check_port_function_exists() {
  if declare -f check_port_37777 &>/dev/null; then
    test_pass "Function check_port_37777() is defined"
  else
    test_fail "Function check_port_37777() should be defined"
  fi
}

test_check_port_function_exists

###############################################################################
# Test: cleanup_on_exit() — global cleanup trap
###############################################################################

echo ""
echo "=== cleanup_on_exit() ==="

test_cleanup_trap_functions_exist() {
  if declare -f register_cleanup_dir &>/dev/null; then
    test_pass "Function register_cleanup_dir() is defined"
  else
    test_fail "Function register_cleanup_dir() should be defined"
  fi

  if declare -f cleanup_on_exit &>/dev/null; then
    test_pass "Function cleanup_on_exit() is defined"
  else
    test_fail "Function cleanup_on_exit() should be defined"
  fi
}

test_cleanup_trap_functions_exist

test_register_cleanup_dir() {
  local test_dir
  test_dir="$(mktemp -d)"

  # Save existing cleanup dirs
  local saved_dirs=("${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}")
  CLEANUP_DIRS=()

  register_cleanup_dir "$test_dir"

  if [[ "${#CLEANUP_DIRS[@]}" -eq 1 ]] && [[ "${CLEANUP_DIRS[0]}" == "$test_dir" ]]; then
    test_pass "register_cleanup_dir adds directory to CLEANUP_DIRS"
  else
    test_fail "register_cleanup_dir should add directory to CLEANUP_DIRS"
  fi

  # Restore
  CLEANUP_DIRS=("${saved_dirs[@]+"${saved_dirs[@]}"}")
  rm -rf "$test_dir"
}

test_register_cleanup_dir

###############################################################################
# Test: ensure_jq_or_fallback() — JSON utility function
###############################################################################

echo ""
echo "=== ensure_jq_or_fallback() ==="

test_ensure_jq_or_fallback_exists() {
  if declare -f ensure_jq_or_fallback &>/dev/null; then
    test_pass "Function ensure_jq_or_fallback() is defined"
  else
    test_fail "Function ensure_jq_or_fallback() should be defined"
  fi
}

test_ensure_jq_or_fallback_exists

test_ensure_jq_with_jq_available() {
  if ! command -v jq &>/dev/null; then
    test_pass "ensure_jq jq-path: skipped (jq not installed)"
    return 0
  fi

  local tmp_json
  tmp_json="$(mktemp)"
  echo '{"name": "test", "value": 1}' > "$tmp_json"

  if ensure_jq_or_fallback "$tmp_json" '.name = "updated"'; then
    local result
    result="$(node -e "const j = JSON.parse(require('fs').readFileSync('${tmp_json}','utf8')); console.log(j.name);")"
    assert_eq "updated" "$result" "ensure_jq_or_fallback updates JSON via jq"
  else
    test_fail "ensure_jq_or_fallback should succeed with jq available"
  fi

  rm -f "$tmp_json"
}

test_ensure_jq_with_jq_available

###############################################################################
# Test: main() references new functions
###############################################################################

echo ""
echo "=== main() references new functions ==="

test_main_calls_check_port() {
  if grep -q 'check_port_37777' "$INSTALL_SCRIPT"; then
    test_pass "main() calls check_port_37777"
  else
    test_fail "main() should call check_port_37777"
  fi
}

test_main_calls_check_port

test_main_calls_is_claude_mem_installed() {
  if grep -q 'is_claude_mem_installed' "$INSTALL_SCRIPT"; then
    test_pass "main() calls is_claude_mem_installed for upgrade detection"
  else
    test_fail "main() should call is_claude_mem_installed"
  fi
}

test_main_calls_is_claude_mem_installed

test_main_references_upgrade_mode() {
  if grep -q 'UPGRADE_MODE' "$INSTALL_SCRIPT"; then
    test_pass "main() references UPGRADE_MODE"
  else
    test_fail "main() should reference UPGRADE_MODE"
  fi
}

test_main_references_upgrade_mode

test_install_plugin_calls_check_git() {
  if grep -q 'check_git' "$INSTALL_SCRIPT"; then
    test_pass "install_plugin() calls check_git"
  else
    test_fail "install_plugin() should call check_git"
  fi
}

test_install_plugin_calls_check_git

test_install_plugin_uses_register_cleanup() {
  if grep -q 'register_cleanup_dir' "$INSTALL_SCRIPT"; then
    test_pass "install_plugin() uses register_cleanup_dir"
  else
    test_fail "install_plugin() should use register_cleanup_dir"
  fi
}

test_install_plugin_uses_register_cleanup

test_usage_comment_includes_upgrade() {
  if grep -q '\-\-upgrade' "$INSTALL_SCRIPT"; then
    test_pass "Usage comment documents --upgrade flag"
  else
    test_fail "Usage comment should document --upgrade flag"
  fi
}

test_usage_comment_includes_upgrade

###############################################################################
# Test: Distribution readiness — URL, usage comment, SKILL.md reference
###############################################################################

echo ""
echo "=== Distribution readiness ==="

test_install_sh_has_shebang() {
  local first_line
  first_line="$(head -1 "$INSTALL_SCRIPT")"
  assert_eq "#!/usr/bin/env bash" "$first_line" "install.sh has correct shebang line"
}

test_install_sh_has_shebang

test_install_sh_has_set_euo_pipefail() {
  if grep -q 'set -euo pipefail' "$INSTALL_SCRIPT"; then
    test_pass "install.sh uses set -euo pipefail for safety"
  else
    test_fail "install.sh should use set -euo pipefail"
  fi
}

test_install_sh_has_set_euo_pipefail

test_install_sh_has_stable_url_in_usage() {
  if grep -q 'raw.githubusercontent.com/thedotmack/claude-mem/main/openclaw/install.sh' "$INSTALL_SCRIPT"; then
    test_pass "install.sh usage comment has stable raw.githubusercontent.com URL"
  else
    test_fail "install.sh should reference stable raw.githubusercontent.com URL in usage"
  fi
}

test_install_sh_has_stable_url_in_usage

test_install_sh_documents_all_flags() {
  local missing_flags=()

  for flag in "--non-interactive" "--upgrade" "--provider" "--api-key"; do
    if ! grep -Fq -- "$flag" "$INSTALL_SCRIPT"; then
      missing_flags+=("$flag")
    fi
  done

  if [[ ${#missing_flags[@]} -eq 0 ]]; then
    test_pass "install.sh documents all CLI flags (--non-interactive, --upgrade, --provider, --api-key)"
  else
    test_fail "install.sh missing documentation for flags: ${missing_flags[*]}"
  fi
}

test_install_sh_documents_all_flags

test_install_sh_has_installer_version() {
  if grep -q 'INSTALLER_VERSION=' "$INSTALL_SCRIPT"; then
    test_pass "install.sh defines INSTALLER_VERSION constant"
  else
    test_fail "install.sh should define INSTALLER_VERSION"
  fi
}

test_install_sh_has_installer_version

test_skill_md_references_one_liner() {
  local skill_file="${SCRIPT_DIR}/SKILL.md"
  if [[ ! -f "$skill_file" ]]; then
    test_fail "SKILL.md not found at ${skill_file}"
    return
  fi

  if grep -q 'curl -fsSL.*raw.githubusercontent.com.*install.sh | bash' "$skill_file"; then
    test_pass "SKILL.md references the one-liner installer"
  else
    test_fail "SKILL.md should reference the one-liner installer"
  fi
}

test_skill_md_references_one_liner

test_skill_md_has_quick_install_section() {
  local skill_file="${SCRIPT_DIR}/SKILL.md"
  if [[ ! -f "$skill_file" ]]; then
    test_fail "SKILL.md not found at ${skill_file}"
    return
  fi

  if grep -q 'Quick Install' "$skill_file"; then
    test_pass "SKILL.md has Quick Install section"
  else
    test_fail "SKILL.md should have Quick Install section"
  fi
}

test_skill_md_has_quick_install_section

test_skill_md_documents_options() {
  local skill_file="${SCRIPT_DIR}/SKILL.md"
  if [[ ! -f "$skill_file" ]]; then
    test_fail "SKILL.md not found at ${skill_file}"
    return
  fi

  local missing=()
  for option in "--provider" "--non-interactive" "--upgrade"; do
    if ! grep -Fq -- "$option" "$skill_file"; then
      missing+=("$option")
    fi
  done

  if [[ ${#missing[@]} -eq 0 ]]; then
    test_pass "SKILL.md documents all installer options (--provider, --non-interactive, --upgrade)"
  else
    test_fail "SKILL.md missing documentation for: ${missing[*]}"
  fi
}

test_skill_md_documents_options

###############################################################################
# Summary
###############################################################################

echo ""
echo "========================================"
echo "Results: ${TESTS_PASSED}/${TESTS_RUN} passed, ${TESTS_FAILED} failed"
echo "========================================"

if [[ "$TESTS_FAILED" -gt 0 ]]; then
  exit 1
fi

exit 0


path: package.json
content: {
  "name": "@openclaw/claude-mem",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "test": "tsc && node --test dist/index.test.js"
  },
  "devDependencies": {
    "@types/node": "^25.2.1",
    "typescript": "^5.3.0"
  },
  "openclaw": {
    "extensions": [
      "./dist/index.js"
    ]
  }
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "lib": ["ES2022", "DOM"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "allowSyntheticDefaultImports": true
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}


path: test-e2e.sh
content: #!/usr/bin/env bash
# test-e2e.sh — Run E2E test of claude-mem plugin on real OpenClaw
#
# Usage:
#   ./test-e2e.sh              # Automated E2E test (build + run + verify)
#   ./test-e2e.sh --interactive # Drop into shell for manual testing
#   ./test-e2e.sh --build-only  # Just build the image, don't run
set -euo pipefail

cd "$(dirname "$0")"

IMAGE_NAME="openclaw-claude-mem-e2e"

echo "=== Building E2E test image ==="
echo "  Base: ghcr.io/openclaw/openclaw:main"
echo "  Plugin: @claude-mem/openclaw-plugin (PR #1012)"
echo ""

docker build -f Dockerfile.e2e -t "$IMAGE_NAME" .

if [ "${1:-}" = "--build-only" ]; then
  echo ""
  echo "Image built: $IMAGE_NAME"
  echo "Run manually with: docker run --rm $IMAGE_NAME"
  exit 0
fi

echo ""
echo "=== Running E2E verification ==="
echo ""

if [ "${1:-}" = "--interactive" ]; then
  echo "Dropping into interactive shell."
  echo ""
  echo "Useful commands inside the container:"
  echo "  node openclaw.mjs plugins list          # Verify plugin is installed"
  echo "  node openclaw.mjs plugins info claude-mem  # Plugin details"
  echo "  node openclaw.mjs plugins doctor         # Check for issues"
  echo "  node /app/mock-worker.js &               # Start mock worker"
  echo "  node openclaw.mjs gateway --allow-unconfigured --verbose  # Start gateway"
  echo "  /bin/bash /app/e2e-verify.sh             # Run automated verification"
  echo ""
  docker run --rm -it "$IMAGE_NAME" /bin/bash
else
  docker run --rm "$IMAGE_NAME"
fi


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)