-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1432 lines (1238 loc) · 52.6 KB
/
install.sh
File metadata and controls
executable file
·1432 lines (1238 loc) · 52.6 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
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════
# onWatch Installer
# Usage: curl -fsSL https://raw.githubusercontent.com/onllm-dev/onwatch/main/install.sh | bash
# ═══════════════════════════════════════════════════════════════════════
set -euo pipefail
INSTALL_DIR="${ONWATCH_INSTALL_DIR:-$HOME/.onwatch}"
BIN_DIR="${INSTALL_DIR}/bin"
REPO="onllm-dev/onwatch"
SERVICE_NAME="onwatch"
SYSTEMD_MODE="user" # "user" or "system" — auto-detected at runtime
INSTALL_VERSION="latest"
# Collected during interactive setup, used by start_service
SETUP_USERNAME=""
SETUP_PASSWORD=""
SETUP_PORT=""
# ─── Colors ───────────────────────────────────────────────────────────
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'; BOLD=$'\033[1m'
DIM=$'\033[2m'; NC=$'\033[0m'
info() { printf " ${BLUE}info${NC} %s\n" "$*"; }
ok() { printf " ${GREEN} ok ${NC} %s\n" "$*"; }
warn() { printf " ${YELLOW}warn${NC} %s\n" "$*"; }
fail() { printf " ${RED}fail${NC} %b\n" "$*" >&2; exit 1; }
# ─── systemd Helpers ────────────────────────────────────────────────
# Wrappers that use --user or system-wide mode based on SYSTEMD_MODE
_systemctl() {
if [[ "$SYSTEMD_MODE" == "system" ]]; then
systemctl "$@"
else
systemctl --user "$@"
fi
}
_journalctl() {
if [[ "$SYSTEMD_MODE" == "system" ]]; then
journalctl -u onwatch "$@"
else
journalctl --user -u onwatch "$@"
fi
}
_sctl_cmd() {
if [[ "$SYSTEMD_MODE" == "system" ]]; then
echo "systemctl"
else
echo "systemctl --user"
fi
}
_jctl_cmd() {
if [[ "$SYSTEMD_MODE" == "system" ]]; then
echo "journalctl -u onwatch"
else
echo "journalctl --user -u onwatch"
fi
}
# ─── Input Helpers ──────────────────────────────────────────────────
# Generate a random 12-char alphanumeric password
generate_password() {
local bytes
bytes=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 12) || true
printf '%s' "$bytes"
}
# Numbered menu, returns selection number
# Usage: choice=$(prompt_choice "Which provider?" "Synthetic only" "Z.ai only" "Both")
prompt_choice() {
local prompt="$1"; shift
local options=("$@")
printf "\n ${BOLD}%s${NC}\n" "$prompt" >&2
for i in "${!options[@]}"; do
printf " ${CYAN}%d)${NC} %s\n" "$((i+1))" "${options[$i]}" >&2
done
while true; do
printf " ${BOLD}>${NC} " >&2
read -u 3 -r choice
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#options[@]} )); then
echo "$choice"
return
fi
printf " ${RED}Please enter 1-%d${NC}\n" "${#options[@]}" >&2
done
}
# Read a secret value (no echo), show masked version, validate with callback
# Usage: prompt_secret "Synthetic API key" synthetic_key "starts_with_syn"
prompt_secret() {
local prompt="$1" validation="$2"
local value=""
while true; do
printf " %s: " "$prompt" >&2
read -u 3 -rs value
echo "" >&2
if [[ -z "$value" ]]; then
printf " ${RED}Cannot be empty${NC}\n" >&2
continue
fi
# Run validation
if eval "$validation \"$value\""; then
local masked
if [[ ${#value} -gt 10 ]]; then
masked="${value:0:6}...${value: -4}"
else
masked="${value:0:3}..."
fi
printf " ${GREEN}✓${NC} ${DIM}%s${NC}\n" "$masked" >&2
echo "$value"
return
fi
done
}
# Prompt with a default value shown in brackets
# Usage: result=$(prompt_with_default "Dashboard port" "9211")
prompt_with_default() {
local prompt="$1" default="$2"
printf " %s ${DIM}[%s]${NC}: " "$prompt" "$default" >&2
read -u 3 -r value
if [[ -z "$value" ]]; then
echo "$default"
else
echo "$value"
fi
}
# ─── Validation Helpers ─────────────────────────────────────────────
validate_synthetic_key() {
local val="$1"
if [[ "$val" == syn_* ]]; then
return 0
fi
printf " ${RED}Key must start with 'syn_'${NC}\n" >&2
return 1
}
validate_nonempty() {
local val="$1"
if [[ -n "$val" ]]; then
return 0
fi
printf " ${RED}Cannot be empty${NC}\n" >&2
return 1
}
validate_https_url() {
local val="$1"
if [[ "$val" == https://* ]]; then
return 0
fi
printf " ${RED}URL must start with 'https://'${NC}\n" >&2
return 1
}
validate_port() {
local val="$1"
if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 1 && val <= 65535 )); then
return 0
fi
printf " ${RED}Must be a number between 1 and 65535${NC}\n" >&2
return 1
}
validate_interval() {
local val="$1"
if [[ "$val" =~ ^[0-9]+$ ]] && (( val >= 10 && val <= 3600 )); then
return 0
fi
printf " ${RED}Must be a number between 10 and 3600${NC}\n" >&2
return 1
}
print_usage() {
cat <<EOF
Usage: install.sh [--version <tag>]
Options:
--version <tag> Download a specific release tag instead of latest
--help Show this help text
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
[[ $# -ge 2 ]] || fail "--version requires a release tag"
INSTALL_VERSION="$2"
shift 2
;;
--help|-h)
print_usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
done
}
# ─── Detect Platform ─────────────────────────────────────────────────
detect_platform() {
local os arch
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
MINGW*|MSYS*|CYGWIN*)
fail "Windows detected. Use PowerShell installer instead:\n irm https://raw.githubusercontent.com/onllm-dev/onwatch/main/install.ps1 | iex" ;;
*) fail "Unsupported OS: $os (supported: Linux, macOS)" ;;
esac
case "$arch" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) fail "Unsupported architecture: $arch (supported: x86_64, arm64)" ;;
esac
PLATFORM="${OS}-${ARCH}"
resolve_asset_name
}
resolve_asset_name() {
ASSET_NAME="onwatch-${PLATFORM}"
}
# ─── Migrate from SynTrack ──────────────────────────────────────────
migrate_from_syntrack() {
local old_dir="$HOME/.syntrack"
if [[ ! -d "$old_dir" ]]; then
return
fi
info "Found existing SynTrack installation at ${old_dir}"
info "Migrating to onWatch..."
# Stop old service
if [[ "$OS" == "linux" ]] && command -v systemctl &>/dev/null; then
if [[ "$SYSTEMD_MODE" == "system" ]]; then
systemctl stop syntrack 2>/dev/null || true
systemctl disable syntrack 2>/dev/null || true
else
systemctl --user stop syntrack 2>/dev/null || true
systemctl --user disable syntrack 2>/dev/null || true
fi
elif [[ -f "${old_dir}/bin/syntrack" ]]; then
"${old_dir}/bin/syntrack" stop 2>/dev/null || true
sleep 1
fi
# Create new directories
mkdir -p "${INSTALL_DIR}" "${BIN_DIR}" "${INSTALL_DIR}/data"
# Copy and transform .env (SYNTRACK_ -> ONWATCH_)
# Also remove DB_PATH so the default (~/.onwatch/data/onwatch.db) takes effect
if [[ -f "${old_dir}/.env" ]]; then
sed -e 's/SYNTRACK_/ONWATCH_/g' -e '/^ONWATCH_DB_PATH=/d' -e '/^SYNTRACK_DB_PATH=/d' "${old_dir}/.env" > "${INSTALL_DIR}/.env"
ok "Migrated .env (SYNTRACK_* -> ONWATCH_*, removed DB_PATH override)"
fi
# Move DB files (rename syntrack.db -> onwatch.db)
for ext in "" "-journal" "-wal" "-shm"; do
if [[ -f "${old_dir}/data/syntrack.db${ext}" ]]; then
cp "${old_dir}/data/syntrack.db${ext}" "${INSTALL_DIR}/data/onwatch.db${ext}"
elif [[ -f "${old_dir}/syntrack.db${ext}" ]]; then
cp "${old_dir}/syntrack.db${ext}" "${INSTALL_DIR}/data/onwatch.db${ext}"
fi
done
ok "Migrated database files (syntrack.db -> onwatch.db)"
# Remove old systemd service
if [[ "$OS" == "linux" ]] && command -v systemctl &>/dev/null; then
if [[ "$SYSTEMD_MODE" == "system" ]]; then
rm -f /etc/systemd/system/syntrack.service
systemctl daemon-reload 2>/dev/null || true
else
rm -f "$HOME/.config/systemd/user/syntrack.service"
systemctl --user daemon-reload 2>/dev/null || true
fi
ok "Removed old syntrack systemd service"
fi
# Clean PATH entries for .syntrack
for rc_file in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do
if [[ -f "$rc_file" ]]; then
# Remove lines containing .syntrack PATH export and the comment above it
sed -i.bak '/# SynTrack/d; /\.syntrack/d' "$rc_file" 2>/dev/null || true
rm -f "${rc_file}.bak"
fi
done
# Remove old directory
rm -rf "$old_dir"
ok "Removed old SynTrack directory"
echo ""
printf " ${GREEN}${BOLD}Migration complete: SynTrack -> onWatch${NC}\n"
printf " ${DIM}Old directory ${old_dir} has been removed${NC}\n"
echo ""
}
# ─── Stop Existing Instance ──────────────────────────────────────────
stop_existing() {
if [[ -f "${BIN_DIR}/onwatch" ]]; then
if [[ "$OS" == "linux" ]] && command -v systemctl &>/dev/null; then
_systemctl stop onwatch 2>/dev/null || true
else
"${BIN_DIR}/onwatch" stop 2>/dev/null || true
fi
# Wait up to 5 seconds for the process to die.
# On Linux, writing to a running binary returns ETXTBSY,
# so we must ensure it's stopped before replacing.
local waited=0
while [[ $waited -lt 5 ]]; do
# Check if any onwatch process is still running from BIN_DIR
if ! pgrep -f "${BIN_DIR}/onwatch" >/dev/null 2>&1; then
break
fi
sleep 1
waited=$((waited + 1))
done
fi
}
# ─── Download Binary ─────────────────────────────────────────────────
# Downloads to /tmp first (avoids ETXTBSY and filesystem issues),
# then moves into place.
download() {
local url="https://github.com/${REPO}/releases/latest/download/${ASSET_NAME}"
if [[ "$INSTALL_VERSION" != "latest" ]]; then
url="https://github.com/${REPO}/releases/download/${INSTALL_VERSION}/${ASSET_NAME}"
fi
local dest="${BIN_DIR}/onwatch"
local tmp_dest="/tmp/onwatch-download-$$"
info "Downloading onwatch for ${BOLD}${PLATFORM}${NC}..."
info " URL: $url"
info " Dest: $dest"
local dl_exit=0
if command -v curl &>/dev/null; then
curl -fSL --progress-bar -o "$tmp_dest" "$url" 2>&1 || dl_exit=$?
if [[ $dl_exit -ne 0 ]]; then
local msg="curl failed with exit code $dl_exit."
[[ $dl_exit -eq 23 ]] && msg="$msg (Write error — check disk space and permissions)"
[[ $dl_exit -eq 22 ]] && msg="$msg (HTTP error from server)"
[[ $dl_exit -eq 6 ]] && msg="$msg (Could not resolve host)"
[[ $dl_exit -eq 7 ]] && msg="$msg (Connection refused)"
rm -f "$tmp_dest"
fail "Download failed: $msg\n URL: $url\n Tmp: $tmp_dest"
fi
elif command -v wget &>/dev/null; then
wget -q -O "$tmp_dest" "$url" || dl_exit=$?
if [[ $dl_exit -ne 0 ]]; then
rm -f "$tmp_dest"
fail "Download failed: wget exit code $dl_exit\n URL: $url"
fi
else
fail "curl or wget is required"
fi
# Validate download
if [[ ! -s "$tmp_dest" ]]; then
rm -f "$tmp_dest"
fail "Downloaded file is empty"
fi
local dl_size
dl_size=$(wc -c < "$tmp_dest" 2>/dev/null || echo 0)
info "Downloaded ${dl_size} bytes"
# Sanity check: binary should be at least 1MB
if [[ "$dl_size" -lt 1000000 ]]; then
rm -f "$tmp_dest"
fail "Downloaded file too small (${dl_size} bytes) - expected ~15MB binary"
fi
chmod +x "$tmp_dest"
# Move into place (rm old first to avoid ETXTBSY on Linux)
rm -f "$dest"
mv "$tmp_dest" "$dest"
local ver
ver="$("$dest" --version 2>/dev/null | head -1 || echo "unknown")"
ok "Installed ${BOLD}onwatch ${ver}${NC}"
}
# ─── Create Wrapper Script ───────────────────────────────────────────
# The binary loads .env from the working directory. This wrapper ensures
# we always cd to ~/.onwatch before running, so .env is always found.
create_wrapper() {
local wrapper="${INSTALL_DIR}/onwatch"
cat > "$wrapper" <<WRAPPER
#!/usr/bin/env bash
cd "\$HOME/.onwatch" 2>/dev/null && exec "\$HOME/.onwatch/bin/onwatch" "\$@"
WRAPPER
chmod +x "$wrapper"
}
# ─── .env Helpers ───────────────────────────────────────────────────
# Read a value from the existing .env file
# Usage: val=$(env_get "SYNTHETIC_API_KEY")
env_get() {
local key="$1" env_file="${INSTALL_DIR}/.env"
grep -E "^${key}=" "$env_file" 2>/dev/null | cut -d= -f2- | tr -d '[:space:]'
}
# Check if a provider key is configured (non-empty, not a placeholder)
has_synthetic_key() {
local val
val="$(env_get SYNTHETIC_API_KEY)"
[[ -n "$val" && "$val" != "syn_your_api_key_here" ]]
}
has_zai_key() {
local val
val="$(env_get ZAI_API_KEY)"
[[ -n "$val" && "$val" != "your_zai_api_key_here" ]]
}
# Append a provider section to the existing .env
append_synthetic_to_env() {
local key="$1" env_file="${INSTALL_DIR}/.env"
printf '\n# Synthetic API key (https://synthetic.new/settings/api)\nSYNTHETIC_API_KEY=%s\n' "$key" >> "$env_file"
}
append_zai_to_env() {
local key="$1" base_url="$2" env_file="${INSTALL_DIR}/.env"
printf '\n# Z.ai API key (https://www.z.ai/api-keys)\nZAI_API_KEY=%s\n\n# Z.ai base URL\nZAI_BASE_URL=%s\n' "$key" "$base_url" >> "$env_file"
}
append_codex_to_env() {
local token="$1" env_file="${INSTALL_DIR}/.env"
printf '\n# Codex OAuth token\nCODEX_TOKEN=%s\n' "$token" >> "$env_file"
}
# ─── Collect Z.ai Key + Base URL ────────────────────────────────────
# Shared between fresh setup and add-provider flow
collect_zai_config() {
local _zai_key _zai_base_url
printf "\n ${DIM}Get your key: https://www.z.ai/api-keys${NC}\n" >&2
_zai_key=$(prompt_secret "Z.ai API key" validate_nonempty)
printf "\n" >&2
local use_default_url
use_default_url=$(prompt_with_default "Use default Z.ai base URL (https://api.z.ai/api)? (Y/n)" "Y")
if [[ "$use_default_url" =~ ^[Nn] ]]; then
while true; do
_zai_base_url=$(prompt_with_default "Z.ai base URL" "https://open.bigmodel.cn/api")
if validate_https_url "$_zai_base_url" 2>/dev/null; then
break
fi
printf " ${RED}URL must start with 'https://'${NC}\n" >&2
done
else
_zai_base_url="https://api.z.ai/api"
fi
# Return both values separated by newline
printf '%s\n%s' "$_zai_key" "$_zai_base_url"
}
# Shared between fresh setup and add-provider flow
collect_codex_config() {
local _codex_token=""
printf "\n ${BOLD}Codex Token Setup${NC}\n" >&2
printf " ${DIM}onWatch can auto-detect your Codex OAuth token from auth.json.${NC}\n\n" >&2
local auto_token=""
auto_token=$(detect_codex_token 2>/dev/null) || true
if [[ -n "$auto_token" ]]; then
printf " ${GREEN}✓${NC} Auto-detected Codex token\n" >&2
local masked="${auto_token:0:8}...${auto_token: -4}"
printf " ${DIM}Token: %s${NC}\n" "$masked" >&2
local use_auto
use_auto=$(prompt_with_default "Use auto-detected token? (Y/n)" "Y")
if [[ "$use_auto" =~ ^[Yy] ]] || [[ -z "$use_auto" ]]; then
printf '%s' "$auto_token"
return
fi
else
printf " ${YELLOW}!${NC} Could not auto-detect Codex token from auth.json\n" >&2
fi
local entry_choice
entry_choice=$(prompt_choice "How would you like to provide the token?" \
"Enter token directly" \
"Show help for retrieving token")
if [[ "$entry_choice" == "2" ]]; then
printf "\n ${BOLD}How to retrieve your Codex token:${NC}\n\n" >&2
printf " ${CYAN}macOS / Linux (default path):${NC}\n" >&2
printf " python3 -c \"import json,os; p=os.path.expanduser('~/.codex/auth.json'); print(json.load(open(p))['tokens']['access_token'])\"\n\n" >&2
printf " ${CYAN}Custom CODEX_HOME:${NC}\n" >&2
printf " python3 -c \"import json,os; p=os.path.join(os.environ['CODEX_HOME'],'auth.json'); print(json.load(open(p))['tokens']['access_token'])\"\n\n" >&2
printf " ${CYAN}Windows (PowerShell):${NC}\n" >&2
printf " (Get-Content \"$env:USERPROFILE\\.codex\\auth.json\" | ConvertFrom-Json).tokens.access_token\n\n" >&2
printf " ${DIM}Paste the access token below after running the appropriate command above.${NC}\n" >&2
fi
_codex_token=$(prompt_secret "Codex token" validate_nonempty)
printf '%s' "$_codex_token"
}
# Shared between fresh setup and add-provider flow
collect_anthropic_config() {
local _anthropic_token=""
# Try auto-detection first
printf "\n ${BOLD}Anthropic (Claude Code) Token Setup${NC}\n" >&2
printf " ${DIM}onWatch can auto-detect your Claude Code credentials.${NC}\n\n" >&2
local auto_token=""
auto_token=$(detect_anthropic_token 2>/dev/null) || true
if [[ -n "$auto_token" ]]; then
printf " ${GREEN}✓${NC} Auto-detected Claude Code token\n" >&2
local masked="${auto_token:0:8}...${auto_token: -4}"
printf " ${DIM}Token: %s${NC}\n" "$masked" >&2
local use_auto
use_auto=$(prompt_with_default "Use auto-detected token? (Y/n)" "Y")
if [[ "$use_auto" =~ ^[Yy] ]] || [[ -z "$use_auto" ]]; then
printf '%s' "$auto_token"
return
fi
else
printf " ${YELLOW}!${NC} Could not auto-detect Claude Code credentials\n" >&2
fi
# Manual entry
local entry_choice
entry_choice=$(prompt_choice "How would you like to provide the token?" \
"Enter token directly" \
"Show help for retrieving token")
if [[ "$entry_choice" == "2" ]]; then
printf "\n ${BOLD}How to retrieve your Anthropic token:${NC}\n\n" >&2
printf " ${CYAN}macOS Keychain:${NC}\n" >&2
printf " security find-generic-password -s \"Claude Code-credentials\" -a \"\$(whoami)\" -w \\ \n" >&2
printf " | python3 -c \"import sys,json; print(json.loads(sys.stdin.read())['claudeAiOauth']['accessToken'])\"\n\n" >&2
printf " ${CYAN}Linux Keyring (GNOME/KDE):${NC}\n" >&2
printf " secret-tool lookup service \"Claude Code-credentials\" account \"\$(whoami)\" \\ \n" >&2
printf " | python3 -c \"import sys,json; print(json.loads(sys.stdin.read())['claudeAiOauth']['accessToken'])\"\n\n" >&2
printf " ${CYAN}Linux / macOS / Windows File Fallback:${NC}\n" >&2
printf " python3 -c \"import json; print(json.load(open('\$HOME/.claude/.credentials.json'))['claudeAiOauth']['accessToken'])\"\n\n" >&2
printf " ${CYAN}Windows (PowerShell):${NC}\n" >&2
printf " (Get-Content \"\$env:USERPROFILE\\.claude\\.credentials.json\" | ConvertFrom-Json).claudeAiOauth.accessToken\n\n" >&2
printf " ${DIM}Paste the token below after running the appropriate command above.${NC}\n" >&2
fi
_anthropic_token=$(prompt_secret "Anthropic token" validate_nonempty)
printf '%s' "$_anthropic_token"
}
# Auto-detect Anthropic token from Claude Code credentials
detect_anthropic_token() {
local creds_json=""
case "$(uname -s)" in
Darwin*)
creds_json=$(security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w 2>/dev/null) || true
;;
Linux*)
if command -v secret-tool &>/dev/null; then
creds_json=$(secret-tool lookup service "Claude Code-credentials" account "$(whoami)" 2>/dev/null) || true
fi
if [[ -z "$creds_json" ]] && [[ -f "$HOME/.claude/.credentials.json" ]]; then
creds_json=$(cat "$HOME/.claude/.credentials.json" 2>/dev/null) || true
fi
;;
*)
if [[ -f "$HOME/.claude/.credentials.json" ]]; then
creds_json=$(cat "$HOME/.claude/.credentials.json" 2>/dev/null) || true
fi
;;
esac
if [[ -z "$creds_json" ]]; then
return 1
fi
# Extract accessToken using python3
local token=""
token=$(printf '%s' "$creds_json" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['claudeAiOauth']['accessToken'])" 2>/dev/null) || true
if [[ -z "$token" ]]; then
return 1
fi
printf '%s' "$token"
}
# Auto-detect Codex OAuth access token from auth.json
detect_codex_token() {
local auth_file="" token=""
if [[ -n "${CODEX_HOME:-}" ]]; then
auth_file="${CODEX_HOME}/auth.json"
else
auth_file="${HOME}/.codex/auth.json"
fi
[[ -f "$auth_file" ]] || return 1
token=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print((d.get('tokens') or {}).get('access_token','').strip())" "$auth_file" 2>/dev/null) || true
[[ -n "$token" ]] || return 1
printf '%s' "$token"
}
# Check if Anthropic key is configured
has_anthropic_key() {
local val
val=$(env_get ANTHROPIC_TOKEN)
[[ -n "$val" && "$val" != "your_token_here" ]]
}
# Check if Codex token is configured
has_codex_key() {
local val
val=$(env_get CODEX_TOKEN)
[[ -n "$val" && "$val" != "your_codex_token_here" ]]
}
# Check if Antigravity is enabled
has_antigravity_enabled() {
local val
val=$(env_get ANTIGRAVITY_ENABLED)
[[ "$val" == "true" ]]
}
# Check if Gemini is enabled (auto-detected or explicit)
has_gemini_enabled() {
local val
val=$(env_get GEMINI_ENABLED)
[[ "$val" == "true" ]] || [[ -f "$HOME/.gemini/oauth_creds.json" ]]
}
# Append Anthropic config to existing .env
append_anthropic_to_env() {
local key="$1"
local env_file="${INSTALL_DIR}/.env"
{
echo ""
echo "# Anthropic token (Claude Code — auto-detected or manual)"
echo "ANTHROPIC_TOKEN=${key}"
} >> "$env_file"
}
# Append Antigravity config to existing .env
append_antigravity_to_env() {
local env_file="${INSTALL_DIR}/.env"
{
echo ""
echo "# Antigravity (Windsurf) - auto-detected from local process"
echo "ANTIGRAVITY_ENABLED=true"
} >> "$env_file"
}
# Append Gemini config to existing .env
append_gemini_to_env() {
local env_file="${INSTALL_DIR}/.env"
{
echo ""
echo "# Gemini CLI - auto-detected from ~/.gemini/oauth_creds.json"
echo "GEMINI_ENABLED=true"
} >> "$env_file"
}
# ─── Interactive Setup ──────────────────────────────────────────────
# Fully interactive .env configuration for fresh installs.
# On upgrade: checks for missing providers and offers to add them.
# Reads from /dev/tty (fd 3) for piped install compatibility.
interactive_setup() {
local env_file="${INSTALL_DIR}/.env"
local _opened_fd3=false
if [[ -f "$env_file" ]]; then
# Load existing values for start_service display
SETUP_PORT="$(env_get ONWATCH_PORT)"
SETUP_PORT="${SETUP_PORT:-9211}"
SETUP_USERNAME="$(env_get ONWATCH_ADMIN_USER)"
SETUP_USERNAME="${SETUP_USERNAME:-admin}"
SETUP_PASSWORD="" # Don't show existing password
local has_syn=false has_zai=false has_anth=false has_codex=false has_anti=false has_gemini=false
has_synthetic_key && has_syn=true
has_zai_key && has_zai=true
has_anthropic_key && has_anth=true
has_codex_key && has_codex=true
has_antigravity_enabled && has_anti=true
has_gemini_enabled && has_gemini=true
if $has_syn && $has_zai && $has_anth && $has_codex && $has_anti && $has_gemini; then
# All providers configured — nothing to do
info "Existing .env found — all providers configured"
return
fi
if ! $has_syn && ! $has_zai && ! $has_anth && ! $has_codex && ! $has_anti && ! $has_gemini; then
# .env exists but no keys at all — run full setup
warn "Existing .env found but no API keys configured"
info "Running interactive setup..."
# Remove the empty .env so the fresh setup flow creates a new one
rm -f "$env_file"
# Fall through to fresh setup below
else
# Some providers configured — offer to add missing ones
if ! { true <&3; } 2>/dev/null; then
exec 3</dev/tty || fail "Cannot read from terminal. Run the script directly instead of piping."
_opened_fd3=true
fi
local configured=""
$has_syn && configured="${configured}Synthetic "
$has_zai && configured="${configured}Z.ai "
$has_anth && configured="${configured}Anthropic "
$has_codex && configured="${configured}Codex "
$has_anti && configured="${configured}Antigravity "
$has_gemini && configured="${configured}Gemini "
info "Existing .env found — configured: ${configured}"
printf "\n"
if ! $has_syn; then
local add_syn
add_syn=$(prompt_with_default "Add Synthetic provider? (y/N)" "N")
if [[ "$add_syn" =~ ^[Yy] ]]; then
printf "\n ${DIM}Get your key: https://synthetic.new/settings/api${NC}\n"
local syn_key
syn_key=$(prompt_secret "Synthetic API key (syn_...)" validate_synthetic_key)
append_synthetic_to_env "$syn_key"
ok "Added Synthetic provider to .env"
fi
fi
if ! $has_zai; then
local add_zai
add_zai=$(prompt_with_default "Add Z.ai provider? (y/N)" "N")
if [[ "$add_zai" =~ ^[Yy] ]]; then
local zai_result zai_key zai_base_url
zai_result=$(collect_zai_config)
zai_key=$(echo "$zai_result" | head -1)
zai_base_url=$(echo "$zai_result" | tail -1)
append_zai_to_env "$zai_key" "$zai_base_url"
ok "Added Z.ai provider to .env"
fi
fi
if ! $has_anth; then
# Try auto-detection silently first
local auto_token=""
auto_token=$(detect_anthropic_token 2>/dev/null) || true
if [[ -n "$auto_token" ]]; then
printf " ${GREEN}✓${NC} Claude Code credentials detected on this system\n"
local add_anth
add_anth=$(prompt_with_default "Enable Anthropic tracking? (Y/n)" "Y")
if [[ "$add_anth" =~ ^[Yy] ]] || [[ -z "$add_anth" ]]; then
append_anthropic_to_env "$auto_token"
ok "Added Anthropic provider to .env (auto-detected)"
fi
else
local add_anth
add_anth=$(prompt_with_default "Add Anthropic (Claude Code) provider? (y/N)" "N")
if [[ "$add_anth" =~ ^[Yy] ]]; then
local anth_token
anth_token=$(collect_anthropic_config)
append_anthropic_to_env "$anth_token"
ok "Added Anthropic provider to .env"
fi
fi
fi
if ! $has_codex; then
local auto_codex=""
auto_codex=$(detect_codex_token 2>/dev/null) || true
if [[ -n "$auto_codex" ]]; then
printf " ${GREEN}✓${NC} Codex auth token detected on this system\n"
local add_codex
add_codex=$(prompt_with_default "Enable Codex tracking? (Y/n)" "Y")
if [[ "$add_codex" =~ ^[Yy] ]] || [[ -z "$add_codex" ]]; then
append_codex_to_env "$auto_codex"
ok "Added Codex provider to .env (auto-detected)"
fi
else
local add_codex
add_codex=$(prompt_with_default "Add Codex provider? (y/N)" "N")
if [[ "$add_codex" =~ ^[Yy] ]]; then
local codex_token
codex_token=$(collect_codex_config)
append_codex_to_env "$codex_token"
ok "Added Codex provider to .env"
fi
fi
fi
if ! $has_anti; then
# Try to detect if Antigravity (Windsurf) is running
if pgrep -f "antigravity" >/dev/null 2>&1; then
printf " ${GREEN}✓${NC} Windsurf (Antigravity) detected running\n"
local add_anti
add_anti=$(prompt_with_default "Enable Antigravity tracking? (Y/n)" "Y")
if [[ "$add_anti" =~ ^[Yy] ]] || [[ -z "$add_anti" ]]; then
append_antigravity_to_env
ok "Added Antigravity provider to .env"
fi
else
local add_anti
add_anti=$(prompt_with_default "Add Antigravity (Windsurf) provider? (y/N)" "N")
if [[ "$add_anti" =~ ^[Yy] ]]; then
append_antigravity_to_env
ok "Added Antigravity provider to .env"
printf " ${DIM}Note: Windsurf must be running for auto-detection${NC}\n"
fi
fi
fi
if ! $has_gemini; then
# Try to detect Gemini CLI credentials
if [[ -f "$HOME/.gemini/oauth_creds.json" ]]; then
printf " ${GREEN}✓${NC} Gemini CLI credentials detected on this system\n"
local add_gemini
add_gemini=$(prompt_with_default "Enable Gemini tracking? (Y/n)" "Y")
if [[ "$add_gemini" =~ ^[Yy] ]] || [[ -z "$add_gemini" ]]; then
append_gemini_to_env
ok "Added Gemini provider to .env (auto-detected)"
fi
else
local add_gemini
add_gemini=$(prompt_with_default "Add Gemini CLI provider? (y/N)" "N")
if [[ "$add_gemini" =~ ^[Yy] ]]; then
append_gemini_to_env
ok "Added Gemini provider to .env"
printf " ${DIM}Note: Install Gemini CLI and run 'gemini' to authenticate${NC}\n"
fi
fi
fi
$_opened_fd3 && exec 3<&- || true
return
fi
fi
# ── Fresh setup (no .env or empty keys) ──
# Open /dev/tty for reading — works even when script is piped via curl | bash
# Skip if fd 3 is already open (e.g., during testing)
if ! { true <&3; } 2>/dev/null; then
exec 3</dev/tty || fail "Cannot read from terminal. Run the script directly instead of piping."
_opened_fd3=true
fi
printf "\n"
printf " ${BOLD}━━━ Configuration ━━━${NC}\n"
# ── Provider Selection ──
local provider_choice
provider_choice=$(prompt_choice "Which providers do you want to track?" \
"Synthetic only" \
"Z.ai only" \
"Anthropic (Claude Code) only" \
"Codex only" \
"Antigravity (Windsurf) only" \
"Gemini CLI only" \
"Multiple (choose one at a time)" \
"All available")
local synthetic_key="" zai_key="" zai_base_url="" anthropic_token="" codex_token="" antigravity_enabled="" gemini_enabled=""
if [[ "$provider_choice" == "7" ]]; then
# ── Multiple: ask for each provider individually ──
local add_it
add_it=$(prompt_with_default "Add Synthetic provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
printf "\n ${DIM}Get your key: https://synthetic.new/settings/api${NC}\n"
synthetic_key=$(prompt_secret "Synthetic API key (syn_...)" validate_synthetic_key)
fi
add_it=$(prompt_with_default "Add Z.ai provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
local zai_result
zai_result=$(collect_zai_config)
zai_key=$(echo "$zai_result" | head -1)
zai_base_url=$(echo "$zai_result" | tail -1)
fi
add_it=$(prompt_with_default "Add Anthropic (Claude Code) provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
anthropic_token=$(collect_anthropic_config)
fi
add_it=$(prompt_with_default "Add Codex provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
codex_token=$(collect_codex_config)
fi
add_it=$(prompt_with_default "Add Antigravity (Windsurf) provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
antigravity_enabled="true"
printf " ${DIM}Antigravity auto-detects the running Windsurf process${NC}\n"
fi
add_it=$(prompt_with_default "Add Gemini CLI provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
gemini_enabled="true"
printf " ${DIM}Gemini auto-detects from ~/.gemini/oauth_creds.json${NC}\n"
fi
# Validate at least one provider selected
if [[ -z "$synthetic_key" && -z "$zai_key" && -z "$anthropic_token" && -z "$codex_token" && -z "$antigravity_enabled" && -z "$gemini_enabled" ]]; then
printf " ${RED}No providers selected. Please select at least one.${NC}\n"
# Re-run provider selection by recursion-safe retry
printf "\n"
add_it=$(prompt_with_default "Add Synthetic provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
printf "\n ${DIM}Get your key: https://synthetic.new/settings/api${NC}\n"
synthetic_key=$(prompt_secret "Synthetic API key (syn_...)" validate_synthetic_key)
fi
if [[ -z "$synthetic_key" ]]; then
add_it=$(prompt_with_default "Add Z.ai provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
local zai_result
zai_result=$(collect_zai_config)
zai_key=$(echo "$zai_result" | head -1)
zai_base_url=$(echo "$zai_result" | tail -1)
fi
fi
if [[ -z "$synthetic_key" && -z "$zai_key" ]]; then
add_it=$(prompt_with_default "Add Anthropic (Claude Code) provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
anthropic_token=$(collect_anthropic_config)
fi
fi
if [[ -z "$synthetic_key" && -z "$zai_key" && -z "$anthropic_token" ]]; then
add_it=$(prompt_with_default "Add Codex provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
codex_token=$(collect_codex_config)
fi
fi
if [[ -z "$synthetic_key" && -z "$zai_key" && -z "$anthropic_token" && -z "$codex_token" ]]; then
add_it=$(prompt_with_default "Add Antigravity (Windsurf) provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
antigravity_enabled="true"
fi
fi
if [[ -z "$synthetic_key" && -z "$zai_key" && -z "$anthropic_token" && -z "$codex_token" && -z "$antigravity_enabled" ]]; then
add_it=$(prompt_with_default "Add Gemini CLI provider? (y/N)" "N")
if [[ "$add_it" =~ ^[Yy] ]]; then
gemini_enabled="true"
fi
fi
if [[ -z "$synthetic_key" && -z "$zai_key" && -z "$anthropic_token" && -z "$codex_token" && -z "$antigravity_enabled" && -z "$gemini_enabled" ]]; then
fail "At least one provider is required"
fi
fi
else
# ── Single provider or All ──
# ── Synthetic API Key ──
if [[ "$provider_choice" == "1" || "$provider_choice" == "8" ]]; then
printf "\n ${DIM}Get your key: https://synthetic.new/settings/api${NC}\n"
synthetic_key=$(prompt_secret "Synthetic API key (syn_...)" validate_synthetic_key)
fi
# ── Z.ai API Key ──
if [[ "$provider_choice" == "2" || "$provider_choice" == "8" ]]; then
local zai_result
zai_result=$(collect_zai_config)
zai_key=$(echo "$zai_result" | head -1)
zai_base_url=$(echo "$zai_result" | tail -1)
fi
# ── Anthropic Token ──
if [[ "$provider_choice" == "3" || "$provider_choice" == "8" ]]; then
anthropic_token=$(collect_anthropic_config)
fi
# ── Codex Token ──
if [[ "$provider_choice" == "4" || "$provider_choice" == "8" ]]; then
codex_token=$(collect_codex_config)
fi
# ── Antigravity (Windsurf) ──
if [[ "$provider_choice" == "5" || "$provider_choice" == "8" ]]; then
antigravity_enabled="true"