-
Notifications
You must be signed in to change notification settings - Fork 64
Expand file tree
/
Copy pathinstall.sh
More file actions
1141 lines (1045 loc) · 49.9 KB
/
Copy pathinstall.sh
File metadata and controls
1141 lines (1045 loc) · 49.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/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" <expected_seconds> 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 <python> (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/tty) 2>/dev/null; then
"$_o_bin" onboard </dev/tty || true
else
"$_o_bin" onboard || true
fi
}
# 0 => 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) return 1 ;;
esac
if ! (exec </dev/tty) 2>/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 </dev/tty || _g_ans=""
case "$(printf '%s' "$_g_ans" | tr -d '\r' | tr '[:upper:]' '[:lower:]')" in
y|yes)
echo ""
return 0
;;
esac
echo ""
echo -e " ${DIM}Keeping your current setup.${NC}"
echo -e " ${DIM}↻ Change it anytime:${NC} ${GREEN}clawmetry onboard${NC}"
CM_HINTED=1
return 1
}
# <<< CM_EXISTING_SETUP_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}"
# 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")
# ── 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 <container> 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/tty) 2>/dev/null; then
# --key-only: OTP flow without starting daemon on host (no OpenClaw on host)
"$CLAWMETRY_BIN" connect --key-only </dev/tty || true
_read_host_config
else
echo -e " ${DIM}Run to authenticate:${NC}"
echo -e " ${GREEN}clawmetry connect --key-only${NC}"
fi
fi
# Step 2c: Connect each sandbox using the key (non-interactive, starts daemon inside sandbox)
if [ -n "$HOST_API_KEY" ] && [ -n "$HOST_ENC_KEY" ]; then
echo ""
echo "$SANDBOX_NAMES" | while IFS= read -r sb; do
[ -z "$sb" ] && continue
echo -e " → Connecting sandbox ${BOLD}${sb}${NC} to ClawMetry Cloud..."
# Check if already connected with the CURRENT API key
SB_KEY=$(docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \
bash -c 'test -f /root/.clawmetry/config.json && python3 -c "import json; print(json.load(open(\"/root/.clawmetry/config.json\")).get(\"api_key\",\"\"))" 2>/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)
if docker exec "$CLUSTER_CONTAINER" kubectl exec -n openshell "$sb" -- \
clawmetry connect --key "$HOST_API_KEY" --enc-key "$HOST_ENC_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 <root>/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") # <openclaw-root>
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]