Files
claude-mem/openclaw/install.sh

1902 lines
65 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 --api-key=YOUR_KEY
# # 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 [[ "$NON_INTERACTIVE" == "true" ]]; then
# In non-interactive mode, do not require /dev/tty
TTY_FD=0
elif [[ -r /dev/tty ]]; then
# stdin is piped (curl | bash) but /dev/tty is available and readable
exec 3</dev/tty
TTY_FD=3
else
# No terminal available at all
echo "Error: No terminal available for interactive prompts." >&2
echo "Use --non-interactive or run directly: bash install.sh" >&2
exit 1
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}"
}
###############################################################################
# uv detection and installation
# Translated from plugin/scripts/smart-install.js patterns
###############################################################################
UV_PATH=""
find_uv_path() {
# Try PATH first
if command -v uv &>/dev/null; then
UV_PATH="$(command -v uv)"
return 0
fi
# Check common installation paths (handles fresh installs before PATH reload)
local -a uv_paths=(
"${HOME}/.local/bin/uv"
"${HOME}/.cargo/bin/uv"
"/usr/local/bin/uv"
"/opt/homebrew/bin/uv"
)
for candidate in "${uv_paths[@]}"; do
if [[ -x "$candidate" ]]; then
UV_PATH="$candidate"
return 0
fi
done
UV_PATH=""
return 1
}
check_uv() {
if ! find_uv_path; then
return 1
fi
local uv_version
uv_version="$("$UV_PATH" --version 2>/dev/null)" || return 1
success "uv ${uv_version} found at ${UV_PATH}"
return 0
}
install_uv() {
info "Installing uv (Python package manager for Chroma support)..."
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
error "Failed to install uv automatically"
error "Please install manually:"
error " curl -LsSf https://astral.sh/uv/install.sh | sh"
error " Or: brew install uv (macOS)"
error "Then restart your terminal and re-run this installer."
exit 1
fi
# Re-detect after install
if ! find_uv_path; then
error "uv installation completed but binary not found in expected locations"
error "Please restart your terminal and re-run this installer."
exit 1
fi
local uv_version
uv_version="$("$UV_PATH" --version 2>/dev/null)" || true
success "uv ${uv_version} installed at ${UV_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'];
const allowHasClaudeMem = Array.isArray(config?.plugins?.allow) && config.plugins.allow.includes('claude-mem');
if (entry || config?.plugins?.slots?.memory === 'claude-mem' || allowHasClaudeMem) {
// 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 stale allowlist reference — this alone can block ALL CLI commands
if (Array.isArray(config?.plugins?.allow)) {
config.plugins.allow = config.plugins.allow.filter((x) => x !== '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
# Ensure claude-mem is present in plugins.allow after successful install+enable.
# Some OpenClaw environments require explicit allowlisting for local plugins.
# This write is guaranteed: if config doesn't exist, configure_memory_slot() will create it.
if [[ -f "$oc_config" ]]; then
if ! 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'));
if (!config.plugins) config.plugins = {};
if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
if (!config.plugins.allow.includes('claude-mem')) {
config.plugins.allow.push('claude-mem');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
console.log('Added claude-mem to plugins.allow');
} else {
console.log('claude-mem already in plugins.allow');
}
" 2>&1; then
warn "Failed to write plugins.allow — claude-mem may need manual allowlisting"
fi
else
# Config doesn't exist yet; configure_memory_slot() will create it with plugins.allow
# We'll add claude-mem to the allowlist in a follow-up step after config is materialized
info "OpenClaw config not yet materialized; will ensure allowlist in post-install"
# Force config materialization by running a harmless OpenClaw command
if run_openclaw status --json >/dev/null 2>&1 && [[ -f "$oc_config" ]]; then
if ! 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'));
if (!config.plugins) config.plugins = {};
if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
if (!config.plugins.allow.includes('claude-mem')) {
config.plugins.allow.push('claude-mem');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
console.log('Added claude-mem to plugins.allow (post-materialization)');
}
" 2>&1; then
warn "Failed to write plugins.allow after materialization — configure manually"
fi
fi
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 apiKey = process.env.INSTALLER_AI_API_KEY || '';
const settingsPath = process.env.INSTALLER_SETTINGS_FILE;
// All defaults from SettingsDefaultsManager.ts
const defaults = {
CLAUDE_MEM_MODEL: 'claude-sonnet-4-6',
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, uv)"
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
if ! check_uv; then
install_uv
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 "$@"