#!/bin/bash # ClawMetry β€” One-line installer (macOS + Linux) # Usage: curl -fsSL https://clawmetry.com/install.sh | bash set -e # Colors RED='\033[0;31m' GREEN='\033[0;32m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' echo "" echo -e " ${BOLD}🦞 ClawMetry${NC} ${DIM}Real-time observability & governance for AI agents${NC}" echo -e " $(printf '%.0s─' {1..50})" echo "" # Overall wall-clock so the final "βœ“ installed" line can show "(Xs total)". _T0=$(date +%s) # ── Spinner helper for silent stages ──────────────────────────────────────── # Curl-bash users used to stare at "Installing clawmetry from PyPI…" for 5+ # seconds with no feedback while pip ran. ``_step`` wraps any silent command # with a labeled spinner that shows elapsed-vs-expected seconds, surfaces # captured stdout+stderr if the command fails, and obeys ``set -e``. # # Usage: ``_step "Label" cmd args...`` # (Note: no ``--`` separator β€” args are passed through as-is, so unset # variables like an empty $USE_SUDO flatten cleanly via word-splitting at # the call site, then ``"$@"`` inside the function preserves quoting.) # # Non-TTY (CI, logfile redirect): degrades to a plain echo + foreground run. _step() { local label="$1"; shift local expected="$1"; shift local logfile logfile=$(mktemp -t clawmetry-step.XXXXXX 2>/dev/null || mktemp) # Non-interactive output: plain log line, foreground exec, no spinner. if ! [ -t 1 ]; then echo " β†’ ${label}..." if "$@" >"$logfile" 2>&1; then rm -f "$logfile" return 0 else local _rc=$? echo " βœ— ${label} (exit ${_rc})" >&2 cat "$logfile" >&2 rm -f "$logfile" return "$_rc" fi fi # Interactive: run the command in the background, redraw a spinner line # every 100ms. Frames cycle through Braille dots; pct climbs toward 95% # using the caller-supplied ``expected`` budget, then we switch to a # neutral "still working…" once we overshoot so we never lie about 99%. local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' local frame_count=${#frames} local i=0 local start_ms start_ms=$(date +%s) ( "$@" >"$logfile" 2>&1 ) & local pid=$! # Spin until the worker exits. ``kill -0`` is the cheap "is it alive?" # probe; busy-loop is fine at 10Hz. while kill -0 "$pid" 2>/dev/null; do local now elapsed pct frame now=$(date +%s) elapsed=$(( now - start_ms )) frame="${frames:$(( i % frame_count )):1}" if [ "$elapsed" -lt "$expected" ]; then # Cap displayed pct at 95 so we never claim 100% before we're done. pct=$(( elapsed * 95 / (expected > 0 ? expected : 1) )) [ "$pct" -gt 95 ] && pct=95 printf "\r β†’ %s %s %ds/~%ds (%d%%) " "$label" "$frame" "$elapsed" "$expected" "$pct" else printf "\r β†’ %s %s %ds (still working…) " "$label" "$frame" "$elapsed" fi i=$(( i + 1 )) sleep 0.1 2>/dev/null || sleep 1 # POSIX sleep fallback done # Reap the worker so $? reflects its exit code (set -e wants this clean). if wait "$pid"; then local total total=$(( $(date +%s) - start_ms )) # Pad with spaces to overwrite the longest possible spinner line. printf "\r ${GREEN}βœ“${NC} %s ${DIM}(%ds)${NC}%-40s\n" "$label" "$total" "" rm -f "$logfile" return 0 else local _rc=$? local total total=$(( $(date +%s) - start_ms )) printf "\r ${RED}βœ—${NC} %s ${DIM}(%ds, exit %d)${NC}%-30s\n" "$label" "$total" "$_rc" "" >&2 # Surface captured output so users see the actual error, not a vague spinner. cat "$logfile" >&2 rm -f "$logfile" return "$_rc" fi } # ── Pre-flight: detect existing daemon ────────────────────────────────────── # Re-running ``curl install.sh | bash`` against an already-installed copy used # to leave the OLD pip-launched daemon (running stale code) alive next to the # fresh venv binary. Both processes raced for the DuckDB write lock and every # internal query 500'd with "Conflicting lock is held in (PID …)". # The launchctl/systemd restart blocks below only kick OS-managed jobs β€” they # do nothing for daemons that the user started by hand. We track the # pre-existing daemon here so the post-install cleanup block (further down) # can ``pkill -f`` it after the new code is in place. CLAWMETRY_RESTART_AFTER=0 _existing_pids=$(pgrep -f "clawmetry\.sync|clawmetry --port|clawmetry$" 2>/dev/null || true) if [ -n "$_existing_pids" ]; then _count=$(echo "$_existing_pids" | wc -l | tr -d ' ') echo -e " ${DIM}↻ Detected ${_count} existing clawmetry process(es) β€” will restart after upgrade:${NC}" for _p in $_existing_pids; do _cmd=$(ps -p "$_p" -o command= 2>/dev/null | cut -c1-90 || echo "(gone)") echo -e " ${DIM} pid $_p: $_cmd${NC}" done CLAWMETRY_RESTART_AFTER=1 fi # ── Detect OS ─────────────────────────────────────────────────────────────── OS="$(uname -s)" case "$OS" in Darwin) echo -e " β†’ Detected macOS" INSTALL_DIR="$HOME/.clawmetry" BIN_DIR="$HOME/.local/bin" USE_SUDO="" if ! command -v python3 &>/dev/null; then if command -v brew &>/dev/null; then echo -e " β†’ Installing Python via Homebrew..." brew install python3 else echo -e "${RED} βœ— Python3 not found. Install: brew install python3${NC}" exit 1 fi fi ;; Linux) echo -e " β†’ Detected Linux" INSTALL_DIR="/opt/clawmetry" BIN_DIR="/usr/local/bin" USE_SUDO="sudo" if command -v apt-get &>/dev/null; then sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv python3-pip >/dev/null 2>&1 elif command -v yum &>/dev/null; then sudo yum install -y python3 python3-pip >/dev/null 2>&1 elif command -v dnf &>/dev/null; then sudo dnf install -y python3 python3-pip >/dev/null 2>&1 elif command -v apk &>/dev/null; then sudo apk add python3 py3-pip >/dev/null 2>&1 elif command -v pacman &>/dev/null; then sudo pacman -Sy --noconfirm python python-pip >/dev/null 2>&1 fi ;; *) echo -e "${RED} βœ— Unsupported OS: $OS (macOS and Linux only)${NC}" exit 1 ;; esac # ── Stale-duplicate sweep ───────────────────────────────────────────────── # The venv at $INSTALL_DIR is the ONLY environment auto-update keeps current. # A clawmetry copy left behind in some OTHER interpreter (e.g. a plain # `pip install --user clawmetry` from before this installer switched to a # per-app venv, or a Homebrew/pyenv python that got `pip install`ed into # directly) never updates, and if it resolves first on PATH it shadows the # venv binary β€” `clawmetry --version` then reports a stale version while the # real install is current. Sweep every python3 interpreter reachable on PATH # and uninstall clawmetry from all of them except the venv we're about to # (re)build. Best-effort: never fail the install over a sweep miss. _cm_seen_pythons="" IFS=':' read -r -a _cm_path_dirs <<< "$PATH" for _cm_dir in "${_cm_path_dirs[@]}"; do for _cm_name in python3 python; do _cm_candidate="$_cm_dir/$_cm_name" [ -x "$_cm_candidate" ] || continue _cm_real=$(cd "$(dirname "$_cm_candidate")" 2>/dev/null && pwd -P)/$(basename "$_cm_candidate") case " $_cm_seen_pythons " in *" $_cm_real "*) continue ;; esac _cm_seen_pythons="$_cm_seen_pythons $_cm_real" case "$_cm_real" in "$INSTALL_DIR"/*) continue ;; esac if "$_cm_real" -m pip show clawmetry >/dev/null 2>&1; then echo -e " ${DIM}β†’ Removing stale clawmetry copy from $_cm_real...${NC}" $USE_SUDO "$_cm_real" -m pip uninstall -y clawmetry >/dev/null 2>&1 || true fi done done # >>> CM_EXISTING_SETUP_BLOCK_START (tests source everything between these # sentinels; keep them around the helpers) >>> # ── Existing setup: account probe + "re-onboard?" gate ────────────────────── # Re-running `curl … | bash` on a machine that is ALREADY set up used to replay # the whole first-run wizard (plans, [1]/[2], runtime grid) as if ClawMetry had # never been installed β€” even though the account, the cloud-vs-local choice and # the license were all sitting on disk. Now the installer reads that state back # first, prints it, and only re-runs `clawmetry onboard` when the user asks for # it. A machine with NO account linked keeps the original behaviour: straight # into the wizard. # # ``_cm_probe_account`` sets: CM_CONNECTED (0/1), CM_EMAIL, CM_PLAN, # CM_SYNC (cloud|local-only), CM_NODE, CM_VER. CM_CONNECTED=0 CM_EMAIL="" CM_PLAN="" CM_SYNC="" CM_NODE="" CM_VER="" CM_E2E=0 CM_DASH="" # Set once the "↻ Change it anytime: clawmetry onboard" line has been printed, # so the closing hint at the bottom of the installer doesn't repeat it. CM_HINTED=0 # Reads `clawmetry status --json` (authoritative: it resolves the live account # email/plan and honours every local-only signal) and falls back to the config # files on disk when the CLI is too old, offline or broken β€” the probe must # never be the reason an install fails, so every branch degrades to "not # connected" and the caller just runs the wizard as before. _CM_PROBE_PY=$(cat <<'PYEOF' import json, os, shlex, sys HOME = os.path.expanduser("~") def _read_json(path): try: with open(path) as fh: return json.load(fh) or {} except Exception: return {} try: _raw = sys.stdin.read() except Exception: _raw = "" try: snap = json.loads(_raw) if _raw.strip() else {} except Exception: snap = {} if not isinstance(snap, dict): snap = {} cloud = snap.get("cloud_sync") or {} acct = cloud.get("account") or {} cfg = _read_json(os.path.join(HOME, ".clawmetry", "config.json")) api_key = str(cfg.get("api_key") or "") or os.environ.get("CLAWMETRY_API_KEY", "") connected = bool(api_key) or bool(cloud.get("api_key_masked")) # A placeholder account (…@clawmetry.auto / …@clawmetry.linked) is the daemon's # zero-friction auto-registration, not the user's login β€” it is invisible from # their dashboard, so treat it as "not connected" and let the wizard run. email = str(acct.get("email") or cfg.get("account_email") or "").strip() if bool(acct.get("placeholder")) or email.lower().endswith(("@clawmetry.auto", "@clawmetry.linked")): connected = False email = "" plan = str(acct.get("plan") or "").strip() if not plan: plan = str(_read_json(os.path.join(HOME, ".clawmetry", "cloud_plan.json")).get("plan") or "").strip() plan_label = "" if plan: try: from clawmetry.entitlements import tier_label as _tl plan_label = _tl(plan) except Exception: plan_label = plan.replace("cloud_", "").replace("_", " ").title() # Never promise a dashboard URL that nothing answers on, and never guess the # port: the daemon records the live one in server.json (8961 on a box where # 8900 was taken). Any HTTP answer -- including 401/302 -- counts as "up". def _dashboard_url(): import urllib.error import urllib.request ports, seen = [], set() try: _p = int(_read_json(os.path.join(HOME, ".clawmetry", "server.json")).get("port") or 0) except Exception: _p = 0 for cand in (_p, 8900): if cand and cand not in seen: seen.add(cand) ports.append(cand) for port in ports: url = "http://127.0.0.1:%d/" % port try: urllib.request.urlopen(url, timeout=0.8).close() return "http://localhost:%d" % port except urllib.error.HTTPError: return "http://localhost:%d" % port except Exception: continue return "" local_only = cloud.get("local_only") if local_only is None: local_only = ( bool(cfg.get("local_only")) or os.path.isfile(os.path.join(HOME, ".clawmetry", "nocloud")) or os.environ.get("CLAWMETRY_NO_CLOUD", "").strip().lower() in ("1", "true", "yes", "on") ) out = { "CM_CONNECTED": "1" if connected else "0", "CM_EMAIL": email, "CM_PLAN": plan_label, "CM_SYNC": "local-only" if local_only else "cloud", "CM_NODE": str(cloud.get("node_id") or cfg.get("node_id") or ""), "CM_VER": str(snap.get("version") or ""), "CM_E2E": "1" if ((cloud.get("encryption") or {}).get("enabled") or cfg.get("encryption_key")) else "0", "CM_DASH": _dashboard_url(), } for _k, _v in out.items(): print("%s=%s" % (_k, shlex.quote(str(_v)))) PYEOF ) _cm_probe_account() { CM_CONNECTED=0 CM_EMAIL="" CM_PLAN="" CM_SYNC="" CM_NODE="" CM_VER="" CM_E2E=0 CM_DASH="" _p_bin="${1:-$INSTALL_DIR/bin/clawmetry}" _p_py="$INSTALL_DIR/bin/python3" if [ ! -x "$_p_py" ]; then _p_py="$(command -v python3 2>/dev/null || true)" fi if [ -z "$_p_py" ]; then return 0 fi _p_snap="" if [ -x "$_p_bin" ]; then _p_snap=$("$_p_bin" status --json 2>/dev/null || true) fi _p_vals=$(printf '%s' "$_p_snap" | "$_p_py" -c "$_CM_PROBE_PY" 2>/dev/null || true) if [ -n "$_p_vals" ]; then eval "$_p_vals" fi # `status --json` is the version source; the console script is the fallback # for the file-only path (CLI too old for --json, or the snapshot failed). if [ -z "$CM_VER" ] && [ -x "$_p_bin" ]; then CM_VER=$("$_p_bin" --version 2>/dev/null | awk '{print $NF}') fi return 0 } # Show the setup that is already on this machine, so the user can tell at a # glance which account/plan this node reports to before deciding to change it. _cm_print_existing() { echo "" echo -e " ${GREEN}${BOLD}βœ“ You're already connected to ClawMetry${NC}" echo "" if [ -n "$CM_EMAIL" ]; then if [ -n "$CM_PLAN" ]; then echo -e " ${DIM}Account:${NC} ${BOLD}${CM_EMAIL}${NC} ${DIM}(${CM_PLAN} plan)${NC}" else echo -e " ${DIM}Account:${NC} ${BOLD}${CM_EMAIL}${NC}" fi fi if [ "$CM_SYNC" = "local-only" ]; then echo -e " ${DIM}Cloud sync:${NC} Local-only ${DIM}(data stays on this machine)${NC}" elif [ "$CM_E2E" = "1" ]; then echo -e " ${DIM}Cloud sync:${NC} On ${DIM}(E2E-encrypted snapshots to app.clawmetry.com)${NC}" else echo -e " ${DIM}Cloud sync:${NC} On ${DIM}(app.clawmetry.com)${NC}" fi if [ -n "$CM_VER" ]; then echo -e " ${DIM}Version:${NC} ${CM_VER}" fi if [ -n "$CM_NODE" ]; then echo -e " ${DIM}Node:${NC} ${CM_NODE}" fi if [ -n "$CM_DASH" ]; then echo -e " ${DIM}Dashboard:${NC} ${CM_DASH}" else echo -e " ${DIM}Dashboard:${NC} not running ${DIM}(start it:${NC} ${GREEN}clawmetry${NC}${DIM})${NC}" fi echo "" } _cm_run_onboard() { _o_bin="${1:-$CLAWMETRY_BIN}" if (exec /dev/null; then "$_o_bin" onboard caller should re-run the wizard, 1 => keep the current setup untouched. # Never re-onboards without an explicit yes: a non-interactive re-install (CI, # provisioning script, `| bash` with no tty) keeps whatever is already set up. _cm_reonboard_gate() { case "${CLAWMETRY_REONBOARD:-}" in 1|true|yes|on|TRUE|YES|ON) return 0 ;; 0|false|no|off|FALSE|NO|OFF) echo -e " ${DIM}Keeping your current setup.${NC}" echo -e " ${DIM}↻ Change it anytime:${NC} ${GREEN}clawmetry onboard${NC}" CM_HINTED=1 return 1 ;; esac if ! (exec /dev/null; then echo -e " ${DIM}Non-interactive install: keeping your current setup.${NC}" echo -e " ${DIM}↻ Change it anytime:${NC} ${GREEN}clawmetry onboard${NC}" CM_HINTED=1 return 1 fi _g_ans="" printf " Re-run setup (account, cloud vs local-only, license)? [y/N]: " read -r _g_ans >> CM_PRO_SYNC_BLOCK_START (tests source everything between these sentinels; # see tests/test_install_script_pro_upgrade.py) # ── Paid runtime adapters: keep clawmetry-pro in step with the core ───────── # The core wheel and the closed-source ``clawmetry-pro`` wheel (the paid # runtime adapters β€” Claude Code, Codex, Cursor, …) ship on separate cadences, # and this installer only ever upgraded the core. A node on Trial / Starter / # Pro / Enterprise therefore kept whatever pro wheel it happened to install at # connect time, and the "already up to date" early exit never looked at pro at # all β€” so re-running the installer could not repair it (founder, 2026-08-28). # # ``clawmetry.license.auto_provision_pro()`` is the SAME entitlement-gated, # idempotent, never-raises entry point the sync daemon uses: it probes # ``/api/license/entitlement``, installs NOTHING for a free/un-entitled # account, and downloads the wheel only when it is strictly newer than the # installed one. The installer just calls it at the two points it owns. CM_PRO_STATE="" # ""|none|current|updated|kept|failed CM_PRO_FROM="" CM_PRO_TO="" CM_PRO_MSG="" CM_PRO_CHANGED=0 _CM_PRO_PY=$(cat <<'PYEOF' import json, os, shlex HOME = os.path.expanduser("~") def emit(**kw): for k, v in kw.items(): print("%s=%s" % (k, shlex.quote(str(v or "")))) def _read_json(path): try: with open(path) as fh: return json.load(fh) or {} except Exception: return {} cfg = _read_json(os.path.join(HOME, ".clawmetry", "config.json")) key = str(cfg.get("api_key") or "").strip() or os.environ.get("CLAWMETRY_API_KEY", "").strip() if not key.startswith("cm_"): # No cloud account on this machine. (A self-hosted signed license takes the # other path: `clawmetry license activate` provisions the wheel itself.) emit(CM_PRO_STATE="none") raise SystemExit(0) try: from clawmetry.license import ( _pro_installed_version as _ver, auto_provision_pro as _provision, ensure_pro_on_path as _ensure, ) except Exception as exc: # CLI too old / broken install β€” never fail the run emit(CM_PRO_STATE="none", CM_PRO_MSG="clawmetry.license unavailable: %s" % exc) raise SystemExit(0) try: _ensure() # a prior fallback-dir install must be visible before we compare except Exception: pass before = _ver() or "" try: ok, msg = _provision(key, cfg.get("node_id")) except Exception as exc: # belt-and-suspenders: the helper already never raises emit(CM_PRO_STATE="failed", CM_PRO_FROM=before, CM_PRO_TO=before, CM_PRO_MSG=str(exc)) raise SystemExit(0) after = _ver() or "" if not ok: # Free / un-entitled account, offline mode, or a probe that could not # reach the license server. Report only what is true on disk: either pro # is absent (say nothing) or it is present and we left it alone. if after: emit(CM_PRO_STATE="kept", CM_PRO_FROM=before, CM_PRO_TO=after, CM_PRO_MSG=msg) else: emit(CM_PRO_STATE=("failed" if msg else "none"), CM_PRO_MSG=msg) raise SystemExit(0) emit( CM_PRO_STATE=("updated" if after != before else "current"), CM_PRO_FROM=before, CM_PRO_TO=after, CM_PRO_MSG=msg, ) PYEOF ) # Reconcile the pro wheel for an entitled account. Prints ONE line (nothing at # all for a free account) and never fails the install: every failure mode ends # in a dim note plus a zero exit. Sets CM_PRO_CHANGED=1 when the wheel on disk # actually moved, so the caller knows a daemon restart is owed. _cm_sync_pro() { CM_PRO_STATE="" CM_PRO_FROM="" CM_PRO_TO="" CM_PRO_MSG="" CM_PRO_CHANGED=0 _pro_py="${1:-$INSTALL_DIR/bin/python3}" if [ ! -x "$_pro_py" ]; then _pro_py="$(command -v python3 2>/dev/null || true)" fi [ -n "$_pro_py" ] || return 0 _pro_vals=$("$_pro_py" -c "$_CM_PRO_PY" 2>/dev/null || true) if [ -n "$_pro_vals" ]; then eval "$_pro_vals" || true fi case "$CM_PRO_STATE" in updated) CM_PRO_CHANGED=1 if [ -n "$CM_PRO_FROM" ]; then echo -e " ${GREEN}βœ“ Pro runtime adapters updated${NC} ${DIM}(clawmetry-pro ${CM_PRO_FROM} β†’ ${CM_PRO_TO})${NC}" else echo -e " ${GREEN}βœ“ Pro runtime adapters installed${NC} ${DIM}(clawmetry-pro ${CM_PRO_TO})${NC}" fi ;; current) echo -e " ${DIM}βœ“ Pro runtime adapters up to date (clawmetry-pro ${CM_PRO_TO})${NC}" ;; kept) echo -e " ${DIM}↻ clawmetry-pro ${CM_PRO_TO} kept β€” could not confirm entitlement right now${NC}" ;; failed) echo -e " ${DIM}↻ clawmetry-pro update skipped: ${CM_PRO_MSG}${NC}" ;; esac return 0 } # Restart the OS-managed daemons so a freshly-installed pro wheel is actually # imported. A running daemon holds the OLD adapter modules in memory, so # without this the wheel on disk is new and the process is not. Only used on # the early-exit path β€” the full install path has its own (larger) restart # block that also rewrites stale plist/unit paths. Never fails the install. _cm_kick_daemons() { _kick_found=0 case "${OS:-}" in Darwin) for _kick_plist in "$HOME/Library/LaunchAgents"/com.clawmetry.*.plist; do [ -f "$_kick_plist" ] || continue _kick_found=1 ( launchctl kickstart -k "gui/$(id -u)/$(basename "$_kick_plist" .plist)" >/dev/null 2>&1 || true ) & done disown -a 2>/dev/null || true ;; Linux) if command -v systemctl >/dev/null 2>&1; then if [ "$(id -u)" = "0" ] && systemctl list-unit-files 2>/dev/null | grep -q '^clawmetry-sync\.service'; then systemctl restart --no-block clawmetry-sync.service >/dev/null 2>&1 && _kick_found=1 elif systemctl --user list-unit-files 2>/dev/null | grep -q '^clawmetry-sync\.service'; then systemctl --user restart --no-block clawmetry-sync.service >/dev/null 2>&1 && _kick_found=1 fi fi ;; esac if [ "$_kick_found" = "1" ]; then echo -e " ${DIM} β†Ί Restarting daemons so the new adapters load${NC}" else echo -e " ${DIM} Restart clawmetry to load the new adapters${NC}" fi return 0 } # <<< CM_PRO_SYNC_BLOCK_END <<< # ── Early exit: already up to date ────────────────────────────────────────── if [ -x "$INSTALL_DIR/bin/clawmetry" ]; then _CURRENT=$("$INSTALL_DIR/bin/clawmetry" --version 2>/dev/null | awk '{print $NF}') _LATEST=$("$INSTALL_DIR/bin/python3" -c " import json, urllib.request r = urllib.request.urlopen('https://pypi.org/pypi/clawmetry/json', timeout=2) print(json.loads(r.read())['info']['version']) " 2>/dev/null) if [ -n "$_CURRENT" ] && [ "$_CURRENT" = "$_LATEST" ] && [ -n "$_existing_pids" ]; then echo -e " ${GREEN}${BOLD}βœ“ ClawMetry $_CURRENT already up to date${NC}" # The CORE being current says nothing about the paid runtime adapters: # clawmetry-pro ships on its own cadence, so an entitled node can sit on a # stale pro wheel behind a perfectly current core. Reconcile it here β€” # this early exit is exactly where a user re-runs the installer hoping to # be brought fully current. No-op (and silent) for a free account. _cm_sync_pro "$INSTALL_DIR/bin/python3" if [ "$CM_PRO_CHANGED" = "1" ]; then # The daemons already running hold the OLD adapter modules in memory. _cm_kick_daemons fi # Nothing to install β€” but if this node is already linked to an account, # say so (email, plan, cloud-vs-local) and offer the wizard instead of # dead-ending on a one-line hint. _cm_probe_account "$INSTALL_DIR/bin/clawmetry" if [ "$CM_CONNECTED" = "1" ]; then _cm_print_existing if _cm_reonboard_gate; then _cm_run_onboard "$INSTALL_DIR/bin/clawmetry" fi echo "" exit 0 fi echo "" echo -e " ${DIM}↻ Change your setup (local-only ↔ cloud, license key)? Run:${NC} ${GREEN}clawmetry onboard${NC}" exit 0 fi fi # ── Install into venv ──────────────────────────────────────────────────────── # Back up config (node_id, encryption_key) as a belt-and-suspenders guard β€” # the in-place upgrade below preserves it, but keep a copy in case a future # change reintroduces a venv rebuild. _CM_CFG_BAK="" if [ -f "$INSTALL_DIR/config.json" ]; then _CM_CFG_BAK=$(mktemp) cp "$INSTALL_DIR/config.json" "$_CM_CFG_BAK" elif [ -f "$HOME/.clawmetry/config.json" ] && [ "$INSTALL_DIR" = "$HOME/.clawmetry" ]; then _CM_CFG_BAK=$(mktemp) cp "$HOME/.clawmetry/config.json" "$_CM_CFG_BAK" fi # Upgrade in place when a venv already exists β€” do NOT `rm -rf "$INSTALL_DIR"`. # That directory also holds the user's DuckDB store (~/.clawmetry/clawmetry.duckdb), # config.json, sync.pid and the LIVE sync daemon's working files. A blanket wipe # (a) silently destroys local history on every upgrade, and (b) races the running # daemon, which keeps recreating DuckDB WAL/tmp files mid-delete so the final # rmdir fails with "Directory not empty" and `set -e` aborts the whole install. # Reported 2026-05-25 (curl … | bash on a machine with an active daemon: pid 9316). _venv_exists=0 _CM_STASH="" if [ -x "$INSTALL_DIR/bin/python3" ] && [ -f "$INSTALL_DIR/pyvenv.cfg" ]; then _venv_exists=1 elif [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then # Dir present but no valid venv (stale/partial β€” e.g. a previous installer # interrupted mid-wipe). We must recreate the venv WITHOUT destroying the # co-located data (config.json, the DuckDB store, sync-state.json, logs) that # shares INSTALL_DIR. `uv venv` refuses a non-empty target, so stash the # non-venv files aside, create the venv into a now-clean dir, and restore them # below. Stale venv subdirs are dropped, not stashed. _CM_STASH=$(mktemp -d) ( shopt -s dotglob nullglob for _e in "$INSTALL_DIR"/*; do case "$(basename "$_e")" in bin|lib|lib64|include|share|pyvenv.cfg) $USE_SUDO rm -rf "$_e" 2>/dev/null || true ;; *) $USE_SUDO mv "$_e" "$_CM_STASH/" 2>/dev/null || true ;; esac done ) fi # Try `uv` (Astral's Rust-based pip replacement) for ~5x faster installs. # Bootstrap a copy if missing; on any failure, silently fall back to pip so # corporate proxies / restrictive networks still work. if ! command -v uv >/dev/null 2>&1; then # Bootstrapping uv ships ~12MB; ~4s on a warm connection. ``|| true`` so # network blockage falls through to the pip path below instead of aborting. _step "Bootstrapping uv (faster installer)" 4 \ bash -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true # uv installs to ~/.local/bin (default) or ~/.cargo/bin (older) export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" fi if command -v uv >/dev/null 2>&1; then # Capture full path so `sudo uv` works even when sudo uses a restricted # PATH that doesn't include ~/.local/bin (the default uv install location). _UV_BIN="$(command -v uv)" # Estimates from observed timings on PyPI cold-cache + Apple Silicon / # mid-range Linux. uv handles the heavy lifting; --quiet suppresses uv's # native progress bar so OUR spinner is the only thing on screen. if [ "$_venv_exists" = "0" ]; then _step "Creating virtual environment (uv)" 2 \ $USE_SUDO "$_UV_BIN" venv "$INSTALL_DIR" --quiet fi # --refresh forces uv to re-fetch the PyPI index even if a cached copy # exists. Without this, running install.sh seconds after a [RELEASE] # auto-publish silently no-ops ("already at latest" against a stale # index that doesn't list the just-published version), leaving the # daemon on the OLD wheel even after the launchctl kicks below fire. # Verified locally on 2026-05-15 via PR #1260 β†’ 0.12.197 publish race. _step "Installing clawmetry from PyPI (uv)" 5 \ $USE_SUDO "$_UV_BIN" pip install --python "$INSTALL_DIR/bin/python3" --quiet --upgrade --refresh clawmetry else # pip path is much slower (~30s for the install alone) β€” make sure the # spinner conveys that so users don't think the script is wedged. if [ "$_venv_exists" = "0" ]; then _step "Bootstrapping pip fallback venv" 5 \ $USE_SUDO python3 -m venv "$INSTALL_DIR" fi _step "Upgrading pip" 5 \ $USE_SUDO "$INSTALL_DIR/bin/pip" install --upgrade pip _step "Installing clawmetry from PyPI (pip)" 30 \ $USE_SUDO "$INSTALL_DIR/bin/pip" install --no-cache-dir --upgrade clawmetry fi # Restore data files stashed aside for the venv rebuild (DuckDB store, config, # sync-state, logs) back into the freshly-created venv dir. (See stash above.) if [ -n "$_CM_STASH" ] && [ -d "$_CM_STASH" ]; then ( shopt -s dotglob nullglob for _e in "$_CM_STASH"/*; do $USE_SUDO mv "$_e" "$INSTALL_DIR/" 2>/dev/null || true; done ) rmdir "$_CM_STASH" 2>/dev/null || $USE_SUDO rm -rf "$_CM_STASH" 2>/dev/null || true fi # Restore config if it was backed up if [ -n "$_CM_CFG_BAK" ] && [ -f "$_CM_CFG_BAK" ]; then $USE_SUDO cp "$_CM_CFG_BAK" "$INSTALL_DIR/config.json" rm -f "$_CM_CFG_BAK" fi # ── Self-heal: console script missing despite "latest" metadata ───────────── # A pip/uv install killed mid-flight (daemon self-update timeout, Ctrl-C'd # installer) can leave site-packages claiming the latest version is installed # while bin/clawmetry is GONE β€” the wheel's files land before entry points are # generated. The --upgrade installs above then no-op ("already latest") and # the symlink below dangles (bash: ~/.local/bin/clawmetry: No such file or # directory β€” seen live 2026-07-30). Force-reinstall regenerates the scripts. if [ ! -x "$INSTALL_DIR/bin/clawmetry" ]; then if [ -n "${_UV_BIN:-}" ]; then _step "Repairing clawmetry entry point (force reinstall)" 5 \ $USE_SUDO "$_UV_BIN" pip install --python "$INSTALL_DIR/bin/python3" --quiet --force-reinstall --no-deps clawmetry else $USE_SUDO "$INSTALL_DIR/bin/python3" -m ensurepip --upgrade --default-pip >/dev/null 2>&1 || true _step "Repairing clawmetry entry point (force reinstall)" 15 \ $USE_SUDO "$INSTALL_DIR/bin/python3" -m pip install --no-cache-dir --force-reinstall --no-deps clawmetry fi fi # Create symlink mkdir -p "$BIN_DIR" 2>/dev/null || $USE_SUDO mkdir -p "$BIN_DIR" $USE_SUDO ln -sf "$INSTALL_DIR/bin/clawmetry" "$BIN_DIR/clawmetry" # Purge pip's interrupted-upgrade leftovers (site-packages/~lawmetry, ~outes, # …). A kill mid-upgrade (our own stray-daemon pkill can be the killer) strands # these renamed dirs; importlib.metadata then reads the STALE dist-info and the # banner below lies about the version (founder saw "0.12.552 installed" on a # 0.12.597 machine, 2026-07-30), and entry-point resolution can break. find "$INSTALL_DIR"/lib/python*/site-packages -maxdepth 1 -name '~*' -exec $USE_SUDO rm -rf {} + 2>/dev/null || true # Prefer the venv binary directly: the $BIN_DIR symlink can be missing for a # beat mid-upgrade (console-script blink), which crashed onboard with # "No such file or directory" (founder, 2026-07-30). if [ -x "$INSTALL_DIR/bin/clawmetry" ]; then CLAWMETRY_BIN="$INSTALL_DIR/bin/clawmetry" else CLAWMETRY_BIN="$BIN_DIR/clawmetry" fi # The console script is authoritative for the banner; importlib fallback keeps # -I isolation (CWD off sys.path) for source-checkout runs. CLAWMETRY_VERSION=$("$INSTALL_DIR/bin/clawmetry" --version 2>/dev/null | awk '{print $NF}') [ -n "$CLAWMETRY_VERSION" ] || CLAWMETRY_VERSION=$("$INSTALL_DIR/bin/python3" -I -c "import importlib.metadata; print(importlib.metadata.version('clawmetry'))" 2>/dev/null || echo "installed") # ── Paid runtime adapters (clawmetry-pro) ──────────────────────────────────── # Refresh clawmetry-pro for an entitled account NOW β€” i.e. after the core # wheel is on disk and BEFORE the launchd/systemd restarts below, so the # daemons come back up on a matched pair (new core + new adapters) instead of # waiting for the daemon's own ~30-min entitlement watcher. Silent + no-op for # a free account, and never fails the install. _cm_sync_pro "$INSTALL_DIR/bin/python3" # ── Restart launchd jobs (macOS) ───────────────────────────────────────────── # After a venv reinstall, the dashboard/sync daemons launched at boot are # still running against the old (now deleted) venv. They'll either keep # serving stale code or crash-loop until reboot. `launchctl kickstart -k` # restarts them cleanly. Issue #1127. # # We also detect any plist whose ProgramArguments[0] points at a stale path # (e.g. a previous Homebrew clawmetry or ~/.local) and rewrite it to the # fresh venv binary so the next reboot picks up the right interpreter. if [ "$OS" = "Darwin" ]; then echo -e " β†’ Refreshing macOS launchd jobs..." _LA_DIR="$HOME/Library/LaunchAgents" _UID=$(id -u) _DARWIN_PLIST_FOUND=0 # Step 1: rewrite stale ProgramArguments[0] in any com.clawmetry.* plist. for _plist in "$_LA_DIR"/com.clawmetry.*.plist; do [ -f "$_plist" ] || continue _DARWIN_PLIST_FOUND=1 _current=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$_plist" 2>/dev/null || echo "") _label=$(basename "$_plist" .plist) case "$_current" in "$INSTALL_DIR/bin/"*) # Already pointing at the fresh venv β€” no rewrite needed. ;; *) # Sync daemon plists invoke `python3 -m clawmetry.sync`; the # dashboard plist runs the `clawmetry` console script. Pick the # matching target binary inside the new venv. if [ "$_label" = "com.clawmetry.sync" ] || [[ "$_current" == *python* ]]; then _new="$INSTALL_DIR/bin/python3" else _new="$INSTALL_DIR/bin/clawmetry" fi if [ -n "$_current" ] && [ "$_current" != "$_new" ]; then /usr/libexec/PlistBuddy -c "Set :ProgramArguments:0 $_new" "$_plist" 2>/dev/null || true fi ;; esac # Issue #1310 β€” ensure CLAWMETRY_ENABLE_WS_TAP=1 is set on the sync # plist so Telegram/Signal/Slack channel messages reach DuckDB. The # gateway WS tap was flipped opt-in by PR #1228 (gateway_tap.py:589 # gated on this env var); without it operators see an empty Brain # feed despite active channel traffic. Idempotent β€” Add fails if the # key already exists, then Set updates it. Sync plist only. if [ "$_label" = "com.clawmetry.sync" ]; then /usr/libexec/PlistBuddy -c "Add :EnvironmentVariables dict" "$_plist" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:CLAWMETRY_ENABLE_WS_TAP string 1" "$_plist" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Set :EnvironmentVariables:CLAWMETRY_ENABLE_WS_TAP 1" "$_plist" 2>/dev/null || true fi done # Step 2: kickstart any registered com.clawmetry.* job in the BACKGROUND. # # ``launchctl kickstart -k`` is *synchronous* β€” it blocks until the daemon # process is alive again. For NemoClaw sandbox plists that run # ``docker exec kubectl exec ...``, "alive" means the docker+ # kubectl handshake has completed, which routinely costs 30-50s per plist. # On a machine with two sandbox plists installed that's 60-100s of dead # time install.sh used to wait for. The user's job here is to put fresh # files on disk; the daemon respawn is best-effort and doesn't need to # block the prompt return. (#1215) # # `|| true` so systems without launchd running (CI containers, Linux # subprocess) don't abort the installer. for _plist in "$_LA_DIR"/com.clawmetry.*.plist; do [ -f "$_plist" ] || continue _label=$(basename "$_plist" .plist) ( launchctl kickstart -k "gui/$_UID/$_label" >/dev/null 2>&1 || true ) & done # Detach the backgrounded kicks so install.sh can exit without waiting # for them. ``disown -a`` clears bash's job table; the kernel keeps the # children alive (their parent reparents to launchd/init). disown -a 2>/dev/null || true if [ "$_DARWIN_PLIST_FOUND" = "1" ]; then echo -e " ${DIM} β†Ί launchd jobs restarting in background${NC}" fi # Cross-platform sanity: no plist means user installed via pip directly # without running `clawmetry connect`, so there is no managed daemon to # restart. Print a manual hint instead of staying silent. if [ "$_DARWIN_PLIST_FOUND" = "0" ]; then echo -e " ${DIM}Hint: no managed daemon found. If clawmetry was already running,${NC}" echo -e " ${DIM}restart it with: pkill -f clawmetry && nohup clawmetry &${NC}" fi fi # ── Restart user daemon (Linux + WSL) ──────────────────────────────────────── # Same stale-venv problem as macOS (#1182). Linux uses systemd --user units # registered as `clawmetry-sync.service` (see clawmetry/cli.py::_register_systemd). # WSL ships without systemd by default, so we fall back to a pkill hint. if [ "$OS" = "Linux" ]; then echo -e " β†’ Refreshing systemd user services..." _IS_WSL=0 if grep -qi microsoft /proc/version 2>/dev/null; then _IS_WSL=1 fi _RESTARTED=0 # Prefer systemd --user when both systemctl is present AND a clawmetry unit # is registered. `list-unit-files` enumerates installed units even when none # are running, which is what we want here. # # ``systemctl --user restart`` blocks until the unit reports active, which # for the sync daemon means DuckDB open + cloud heartbeat round-trip # (5-30s on slow networks). We background it with ``--no-block`` so # install.sh doesn't stall on daemon startup β€” matches the macOS launchd # fire-and-forget pattern. (#1215) if [ "$_IS_WSL" = "0" ] && command -v systemctl >/dev/null 2>&1; then # root over SSH usually has no `systemctl --user` D-Bus session, so # clawmetry/cli.py::_register_systemd installs a SYSTEM service for root. # Restart that one for root; the --user unit for everyone else. if [ "$(id -u)" = "0" ] && systemctl list-unit-files 2>/dev/null | grep -q '^clawmetry-sync\.service'; then systemctl daemon-reload >/dev/null 2>&1 || true if systemctl restart --no-block clawmetry-sync.service >/dev/null 2>&1; then echo -e " ${DIM}β†Ί clawmetry-sync (system) restarting in background${NC}" _RESTARTED=1 fi elif systemctl --user list-unit-files 2>/dev/null | grep -q '^clawmetry-sync\.service'; then systemctl --user daemon-reload >/dev/null 2>&1 || true if systemctl --user restart --no-block clawmetry-sync.service >/dev/null 2>&1; then echo -e " ${DIM}β†Ί clawmetry-sync restarting in background${NC}" _RESTARTED=1 fi fi fi if [ "$_RESTARTED" = "0" ]; then if [ "$_IS_WSL" = "1" ]; then echo -e " ${DIM}WSL detected β€” systemd user services not available by default.${NC}" else echo -e " ${DIM}Hint: no systemd user unit found for clawmetry.${NC}" fi echo -e " ${DIM}If clawmetry was already running, restart it with:${NC}" echo -e " ${DIM} pkill -f clawmetry && nohup clawmetry &${NC}" fi fi # ── Post-install: kill stray pre-existing daemons ─────────────────────────── # The launchctl/systemd blocks above only restart OS-managed jobs. If the # user originally started clawmetry by hand (``pip install clawmetry && # clawmetry --port 8900``), that pid is still attached to the OLD venv we # just deleted β€” and now races the freshly-installed daemon for the DuckDB # write lock. ``pkill -f`` here ensures the OLD daemon exits so the NEW one # (which the launchctl/systemd block above will respawn, or which the user # will respawn with ``clawmetry``) takes over cleanly. # # Guarded by the pre-flight detect so we don't kill a daemon that wasn't # there when the installer started β€” that case would either (a) be the # launchctl/systemd job we just kickstarted, or (b) be unrelated. if [ "$CLAWMETRY_RESTART_AFTER" = "1" ]; then echo -e " β†’ Killing stray pre-existing daemon(s)..." pkill -f "clawmetry\.sync" >/dev/null 2>&1 || true # No sleep here β€” the freshly-spawned daemon's DuckDB open already retries # on lock contention (clawmetry/local_store.py), so install.sh doesn't # need to babysit the kernel. Saves 1s of fixed dead time. (#1215) echo -e " ${DIM} β†Ί Stray daemon(s) signalled${NC}" fi echo "" _ELAPSED=$(( $(date +%s) - _T0 )) echo -e " ${GREEN}${BOLD}βœ“ ClawMetry $CLAWMETRY_VERSION installed${NC} ${DIM}(${_ELAPSED}s total)${NC}" echo "" echo -e " $(printf '%.0s─' {1..50})" echo "" # ── NemoClaw detection ─────────────────────────────────────────────────────── NEMOCLAW_DETECTED=0 # Ensure common install paths are checked (non-interactive shells may have minimal PATH) for _p in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin"; do [[ ":$PATH:" != *":$_p:"* ]] && [ -d "$_p" ] && export PATH="$_p:$PATH" done if command -v nemoclaw &>/dev/null; then NEMOCLAW_DETECTED=1 echo -e " ${BOLD}🟒 NemoClaw detected${NC}" echo "" # Step 1: Find and auto-apply the bundled preset script PRESET_SCRIPT=$("$INSTALL_DIR/bin/python3" -c " import importlib.resources try: pkg = importlib.resources.files('clawmetry') / 'resources' / 'add-nemoclaw-clawmetry-preset.sh' print(str(pkg)) except Exception: pass " 2>/dev/null || true) if [ -n "$PRESET_SCRIPT" ] && [ -f "$PRESET_SCRIPT" ]; then echo -e " β†’ Applying ClawMetry preset to NemoClaw sandboxes..." bash "$PRESET_SCRIPT" >/dev/null 2>&1 \ && echo -e " ${GREEN}${BOLD}βœ“ NemoClaw preset applied${NC}" \ || echo -e " ${DIM}⚠ Preset incomplete. Run manually: bash $PRESET_SCRIPT${NC}" echo "" fi # Step 2: Auto-install ClawMetry inside sandbox + interactive connect SANDBOX_NAMES=$(nemoclaw list 2>/dev/null | awk ' /^ Sandboxes:/ { in_list=1; next } /^ \* = default sandbox/ { in_list=0; next } in_list && /^ [^ ]/ { name=$1; gsub(/\*/, "", name); if (name != "") print name } ' | head -5) if [ -n "$SANDBOX_NAMES" ]; then # Find the OpenShell cluster container for kubectl access CLUSTER_CONTAINER=$(docker ps --format '{{.Names}}' 2>/dev/null | grep 'openshell-cluster' | head -1) if [ -n "$CLUSTER_CONTAINER" ]; then # Step 2a: Install ClawMetry inside all sandboxes via kubectl exec echo "$SANDBOX_NAMES" | while IFS= read -r sb; do [ -z "$sb" ] && continue echo -e " β†’ Installing ClawMetry inside sandbox ${BOLD}${sb}${NC}..." # Always upgrade to latest echo -e " ${DIM}β†’ Upgrading to latest...${NC}" if docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \ pip install --break-system-packages --quiet --upgrade clawmetry 2>/dev/null; then NEW_VER=$(docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \ clawmetry --version 2>/dev/null | grep -o '[0-9]*\.[0-9]*\.[0-9]*' || true) echo -e " ${GREEN}${BOLD}βœ“ ClawMetry ${NEW_VER} installed${NC}" else echo -e " ${DIM}⚠ Auto-install failed. Install manually:${NC}" echo -e " ${GREEN}nemoclaw $sb connect${NC}" echo -e " ${GREEN}pip install --break-system-packages --upgrade clawmetry${NC}" fi done echo "" # Step 2b: OTP on HOST (--key-only: saves key+enc_key, no daemon β€” host has no OpenClaw) HOST_CONFIG="$HOME/.clawmetry/config.json" HOST_API_KEY="" HOST_ENC_KEY="" _read_host_config() { if [ -f "$HOST_CONFIG" ]; then # Use the venv python directly (most reliable on macOS) _PY="$INSTALL_DIR/bin/python3" [ -x "$_PY" ] || _PY="python3" HOST_API_KEY=$("$_PY" -c "import json; print(json.load(open('$HOST_CONFIG')).get('api_key',''))" 2>/dev/null || true) HOST_ENC_KEY=$("$_PY" -c "import json; print(json.load(open('$HOST_CONFIG')).get('encryption_key',''))" 2>/dev/null || true) fi } _read_host_config if [ -n "$HOST_API_KEY" ]; then echo -e " ${GREEN}${BOLD}βœ“ ClawMetry Cloud already authenticated${NC}" elif [ -z "$HOST_API_KEY" ]; then echo -e " ${BOLD}Authenticate with ClawMetry Cloud${NC}" echo -e " ${DIM}Enter your email to get a one-time code.${NC}" echo "" if (exec /dev/null; then # --key-only: OTP flow without starting daemon on host (no OpenClaw on host) "$CLAWMETRY_BIN" connect --key-only /dev/null || echo ""' 2>/dev/null || true) if [ -n "$SB_KEY" ] && [ "$SB_KEY" = "$HOST_API_KEY" ]; then echo -e " ${GREEN}${BOLD}βœ“ Sandbox $sb already connected${NC}" else # Clear stale config + sync state if key doesn't match (new account) if [ -n "$SB_KEY" ] && [ "$SB_KEY" != "$HOST_API_KEY" ]; then docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \ bash -s >/dev/null 2>&1 << 'CLEAR_SCRIPT' rm -f /root/.clawmetry/config.json /sandbox/.clawmetry/config.json # Reset sync state so events re-upload under new account for state_file in /root/.clawmetry/sync-state.json /sandbox/.clawmetry/sync-state.json; do if [ -f "$state_file" ]; then python3 -c "import json; p='$state_file'; s=json.load(open(p)); s['last_event_ids']={} ; json.dump(s,open(p,'w'))" fi done CLEAR_SCRIPT echo -e " ${DIM}β†Ί Cleared stale config (different account)${NC}" fi # Pre-write config so --key matches _saved_api_key (skips OTP verification) CONNECT_TS=$(date -u +%Y-%m-%dT%H:%M:%S 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) # Write config to both root and sandbox user homes docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \ bash -c " python3 - << PYEOF import json, os, shutil cfg = {'api_key':'$HOST_API_KEY','node_id':'$sb','platform':'Linux','connected_at':'$CONNECT_TS','encryption_key':'$HOST_ENC_KEY'} for d in ['/root/.clawmetry', '/sandbox/.clawmetry']: os.makedirs(d, exist_ok=True) json.dump(cfg, open(d + '/config.json', 'w')) # chown sandbox home to sandbox user os.system('chown -R sandbox:sandbox /sandbox/.clawmetry 2>/dev/null') PYEOF " 2>/dev/null || true # Connect non-interactively (OTP skipped β€” key matches saved config). # SECURITY (2026-08-24 review, finding 11): the encryption key goes # through the CM_KEY env var, not --enc-key. An argv string is # readable by every other process on both the host and the pod for # as long as the command runs (`ps`, /proc//cmdline); an env # var passed by `kubectl exec --env` is not. if docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" \ --env="CM_KEY=$HOST_ENC_KEY" -- \ clawmetry connect --key "$HOST_API_KEY" --node-id "$sb" --no-daemon >/dev/null 2>&1; then echo -e " ${GREEN}${BOLD}βœ“ Sandbox $sb connected (node: $sb)${NC}" # Ensure daemon survives kubectl exec session end via supervisord if available docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \ bash -c 'command -v supervisorctl >/dev/null 2>&1 && supervisorctl start clawmetry-sync >/dev/null 2>&1 || true' 2>/dev/null || true else echo -e " ${DIM}⚠ Could not connect sandbox $sb automatically.${NC}" echo -e " ${DIM}Connect manually: nemoclaw $sb connect β†’ clawmetry connect${NC}" fi fi done fi # Step 2d: Start supervisord inside each sandbox to keep daemon alive echo "$SANDBOX_NAMES" | while IFS= read -r sb; do [ -z "$sb" ] && continue echo -e " β†’ Starting supervisor in sandbox ${BOLD}${sb}${NC}..." # Ensure PyPI + ClawMetry policies applied before pip install for _pol in clawmetry pypi; do printf '%s\ny\n' "$_pol" | nemoclaw "$sb" policy-add >/dev/null 2>&1 || true done # Wait for network policy to propagate inside sandbox sleep 5 _sb_out=$(docker exec -i "$CLUSTER_CONTAINER" kubectl exec -i -n openshell "$sb" -- \ bash -s 2>&1 << 'SANDBOX_SCRIPT' set -e # Install supervisord if missing command -v supervisord >/dev/null 2>&1 || pip install --break-system-packages --quiet supervisor 2>/dev/null # Detect the real OpenClaw data directory (NemoClaw stores it at /sandbox/.openclaw-data) # Walk /sandbox, /root and /home to find agents/main/sessions β€” do NOT hardcode the path. _oc_dir="" for _search_root in /sandbox /root /home; do _hit=$(find "$_search_root" -maxdepth 6 -name "sessions.json" \ -path "*/agents/main/sessions/*" 2>/dev/null | head -1) if [ -n "$_hit" ]; then # Walk up 4 levels from sessions.json to reach the openclaw root # sessions.json lives at /agents/main/sessions/sessions.json _oc_dir=$(dirname "$_hit") # .../agents/main/sessions _oc_dir=$(dirname "$_oc_dir") # .../agents/main _oc_dir=$(dirname "$_oc_dir") # .../agents _oc_dir=$(dirname "$_oc_dir") # break fi done # Fallback: use the clawmetry config path (guaranteed to exist after connect) if [ -z "$_oc_dir" ]; then _clawmetry_config=$(cat /sandbox/.clawmetry/config.json 2>/dev/null || cat /root/.clawmetry/config.json 2>/dev/null || echo "") _oc_dir="/sandbox/.openclaw-data" echo "WARN: openclaw sessions not found; defaulting to $_oc_dir" fi echo "INFO: CLAWMETRY_OPENCLAW_DIR=$_oc_dir" # Resolve the clawmetry config path if [ -f /sandbox/.clawmetry/config.json ]; then _cm_config="/sandbox/.clawmetry/config.json" _cm_log="/sandbox/.clawmetry/sync.log" else _cm_config="/root/.clawmetry/config.json" _cm_log="/root/.clawmetry/sync.log" fi # Resolve sync.py path SYNC_PATH=$(python3 -c "import clawmetry.sync, os; print(os.path.abspath(clawmetry.sync.__file__))") # Write supervisord configs mkdir -p /etc/supervisor/conf.d /var/log/supervisor /var/run cat > /etc/supervisor/supervisord.conf << 'SUPEOF' [unix_http_server] file=/var/run/supervisor.sock [supervisord] logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid nodaemon=false [rpcinterface:supervisor] supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [supervisorctl] serverurl=unix:///var/run/supervisor.sock [include] files = /etc/supervisor/conf.d/*.conf SUPEOF cat > /etc/supervisor/conf.d/clawmetry-sync.conf << PROGEOF [program:clawmetry-sync] command=python3 ${SYNC_PATH} autostart=true autorestart=true startretries=10 startsecs=3 stdout_logfile=${_cm_log} stderr_logfile=${_cm_log} stdout_logfile_maxbytes=10MB environment=HOME="/sandbox",CLAWMETRY_CONFIG="${_cm_config}",CLAWMETRY_OPENCLAW_DIR="${_oc_dir}" PROGEOF # Verify conf files were actually written before proceeding if [ ! -f /etc/supervisor/supervisord.conf ]; then echo "ERROR: failed to write /etc/supervisor/supervisord.conf" >&2 exit 1 fi if [ ! -f /etc/supervisor/conf.d/clawmetry-sync.conf ]; then echo "ERROR: failed to write /etc/supervisor/conf.d/clawmetry-sync.conf" >&2 exit 1 fi # Kill ALL stray sync.py daemons before supervisord takes over kill "$(cat /root/.clawmetry/sync.pid 2>/dev/null)" 2>/dev/null || true kill "$(cat /sandbox/.clawmetry/sync.pid 2>/dev/null)" 2>/dev/null || true rm -f /root/.clawmetry/sync.pid /sandbox/.clawmetry/sync.pid for _f in /proc/[0-9]*/cmdline; do _p="${_f%/cmdline}"; _p="${_p#/proc/}" if grep -qa "sync.py" "$_f" 2>/dev/null && grep -qa "clawmetry" "$_f" 2>/dev/null; then kill "$_p" 2>/dev/null || true fi done sleep 1 # Shut down existing supervisord cleanly, then start fresh if supervisorctl -c /etc/supervisor/supervisord.conf pid >/dev/null 2>&1; then supervisorctl -c /etc/supervisor/supervisord.conf shutdown >/dev/null 2>&1 || true sleep 2 fi kill "$(cat /var/run/supervisord.pid 2>/dev/null)" 2>/dev/null || true rm -f /var/run/supervisord.pid /var/run/supervisor.sock sleep 1 supervisord -c /etc/supervisor/supervisord.conf sleep 3 supervisorctl -c /etc/supervisor/supervisord.conf status SANDBOX_SCRIPT ) _rc=$? # Surface any WARN/ERROR/INFO lines from the sandbox script _info=$(echo "$_sb_out" | grep -E "^(INFO|WARN|ERROR):" || true) if [ "$_rc" -eq 0 ]; then echo -e " ${GREEN}${BOLD}βœ“ Supervisor running in $sb${NC}" [ -n "$_info" ] && echo -e " ${DIM}$_info${NC}" else echo -e " ${DIM}⚠ Could not start supervisor in $sb${NC}" [ -n "$_sb_out" ] && echo -e " ${DIM}$_sb_out${NC}" fi done echo "" echo -e " ${GREEN}${BOLD}βœ“ All done! Open app.clawmetry.com to see your sandboxes${NC}" else # No cluster container found, fall back to manual instructions FIRST_SANDBOX=$(echo "$SANDBOX_NAMES" | head -1) echo -e " ${BOLD}Next: install ClawMetry inside sandbox ${FIRST_SANDBOX}${NC}" echo "" echo -e " ${DIM}Connect to the sandbox and run:${NC}" echo "" echo -e " ${GREEN}nemoclaw $FIRST_SANDBOX connect${NC}" echo -e " ${GREEN}pip install --break-system-packages clawmetry${NC}" echo -e " ${GREEN}clawmetry connect${NC}" echo -e " ${GREEN}clawmetry --host 0.0.0.0 --port 8900 &${NC}" echo "" fi echo "" else echo -e " ${DIM}No NemoClaw sandboxes found yet.${NC}" echo -e " ${DIM}Once you create a sandbox, install ClawMetry inside:${NC}" echo -e " ${GREEN}nemoclaw connect${NC}" echo -e " ${GREEN}pip install --break-system-packages clawmetry${NC}" echo "" fi fi # ── Onboarding ─────────────────────────────────────────────────────────────── # Runs: clawmetry onboard (skipped when NemoClaw is detected β€” onboard happens inside sandbox) # Local-only opt-out: CLAWMETRY_LOCAL_ONLY=1 means "never create a cloud # account, nothing leaves this machine." Write the persistent marker now so it # holds even when onboard is skipped; onboard itself also defaults to local. case "${CLAWMETRY_LOCAL_ONLY:-}" in 1|true|yes|on|TRUE|YES|ON) mkdir -p "$HOME/.clawmetry" 2>/dev/null || true touch "$HOME/.clawmetry/nocloud" 2>/dev/null || true echo -e " ${DIM}Local-only mode (CLAWMETRY_LOCAL_ONLY set): no cloud account will be created.${NC}" ;; esac if [ "${CLAWMETRY_SKIP_ONBOARD:-}" = "1" ] || [ "$NEMOCLAW_DETECTED" = "1" ]; then [ "$NEMOCLAW_DETECTED" = "1" ] || echo -e " ${DIM}Skipping onboard (CLAWMETRY_SKIP_ONBOARD=1) β€” set up later with:${NC} ${GREEN}clawmetry onboard${NC}" else # Already linked to an account? The upgrade is done; show the setup that is # on this box and ask before replaying the wizard over it. No account (fresh # install, or a local-only node that never linked one) keeps the old path. _cm_probe_account "$CLAWMETRY_BIN" if [ "$CM_CONNECTED" = "1" ]; then _cm_print_existing if _cm_reonboard_gate; then _cm_run_onboard "$CLAWMETRY_BIN" fi else _cm_run_onboard "$CLAWMETRY_BIN" fi fi # ── PATH reminder if needed ────────────────────────────────────────────────── if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then echo "" echo -e " ${BOLD}⚠️ Add $BIN_DIR to your PATH:${NC}" SHELL_NAME="$(basename "$SHELL")" case "$SHELL_NAME" in zsh) echo -e " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;; bash) echo -e " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;; *) echo -e " export PATH=\"$BIN_DIR:\$PATH\"" ;; esac echo "" fi # ── Closing hint ───────────────────────────────────────────────────────────── # `clawmetry onboard` is the setup wizard (local-only / cloud / license key) # and is always safe to re-run β€” advertise it on the way out. if [ "${CM_HINTED:-0}" != "1" ]; then echo "" echo -e " ${DIM}↻ Change your setup anytime (local-only ↔ cloud, license key):${NC} ${GREEN}clawmetry onboard${NC}" echo "" fi