@thinkbrowse/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1046 @@
1
+ #!/bin/bash
2
+ # browse — Portable browser automation for AI agent brains
3
+ #
4
+ # Modes:
5
+ # LOCAL: Controls your Chrome browser via the native messaging host
6
+ # Port auto-discovered from ~/.thinkbrowse/port (written by native host on startup)
7
+ # CLOUD: Controls a cloud Playwright browser via ThinkBrowse REST API
8
+ #
9
+ # Mode detection (in order):
10
+ # 1. THINKBROWSE_LOCAL=true or THINKBROWSE_NATIVE=true → local mode
11
+ # 2. Native host responding on discovered port → local mode
12
+ # 3. THINKBROWSE_API_KEY or config file → cloud mode
13
+ #
14
+ # Deps: curl, jq
15
+ #
16
+ # Usage:
17
+ # browse session-create Create session, print ID
18
+ # browse session-artifacts <sid> List cloud session artifacts
19
+ # browse artifact-download <id> [out] Download artifact by ID (cloud mode)
20
+ # browse goto <sid> <url> Navigate to URL
21
+ # browse click <sid> <selector> Click element
22
+ # browse extract <sid> [text|html] Extract page content
23
+ # browse session-delete <sid> Close session
24
+
25
+ set -euo pipefail
26
+
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+
29
+ # --- Config ---
30
+ CONFIG_FILE="$HOME/.config/thinkbrowse/config.json"
31
+ PORT_DISCOVERY_FILE="$HOME/.thinkbrowse/port"
32
+ TAB_DIR="/tmp/thinkbrowse-tabs"
33
+ LOCAL_HOST="${THINKBROWSE_LOCAL_HOST:-127.0.0.1}"
34
+ if [ "$LOCAL_HOST" = "localhost" ]; then
35
+ echo "WARN: localhost is non-deterministic (IPv4/IPv6); using 127.0.0.1 for ThinkBrowse bridge." >&2
36
+ LOCAL_HOST="127.0.0.1"
37
+ fi
38
+
39
+ # Port discovery: THINKBROWSE_LOCAL_PORT > THINKBROWSE_BRIDGE_PORT (legacy) > discovery file > default 3012
40
+ resolve_local_port() {
41
+ if [ -n "${THINKBROWSE_LOCAL_PORT:-}" ]; then
42
+ echo "$THINKBROWSE_LOCAL_PORT"
43
+ return
44
+ fi
45
+ if [ -n "${THINKBROWSE_BRIDGE_PORT:-}" ]; then
46
+ echo "$THINKBROWSE_BRIDGE_PORT"
47
+ return
48
+ fi
49
+ if [ -f "$PORT_DISCOVERY_FILE" ]; then
50
+ local port
51
+ port=$(cat "$PORT_DISCOVERY_FILE" 2>/dev/null | tr -d '[:space:]')
52
+ if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -gt 0 ] && [ "$port" -le 65535 ]; then
53
+ echo "$port"
54
+ return
55
+ fi
56
+ fi
57
+ echo "3012"
58
+ }
59
+
60
+ LOCAL_PORT=$(resolve_local_port)
61
+ LOCAL_URL="http://$LOCAL_HOST:$LOCAL_PORT"
62
+
63
+ is_thinkbrowse_health_response() {
64
+ local health_json="$1"
65
+ echo "$health_json" | jq -e '
66
+ .success == true
67
+ and (.data | type == "object")
68
+ and (.data.status == "ok")
69
+ and (.data.service == "thinkbrowse-native-host")
70
+ and ((.data.extensionConnected | type) == "boolean")
71
+ ' >/dev/null 2>&1
72
+ }
73
+
74
+ probe_local_bridge_health() {
75
+ local health_json
76
+ health_json=$(curl -4 -s --max-time 2 "$LOCAL_URL/health" 2>/dev/null || true)
77
+ if [ -z "$health_json" ]; then
78
+ return 1
79
+ fi
80
+ is_thinkbrowse_health_response "$health_json"
81
+ }
82
+
83
+ # --- Parse --mode flag before mode detection ---
84
+ MODE_OVERRIDE=""
85
+ ARGS=()
86
+ while [[ $# -gt 0 ]]; do
87
+ case "$1" in
88
+ --mode)
89
+ MODE_OVERRIDE="${2:-}"
90
+ shift 2
91
+ ;;
92
+ --mode=*)
93
+ MODE_OVERRIDE="${1#--mode=}"
94
+ shift
95
+ ;;
96
+ *)
97
+ ARGS+=("$1")
98
+ shift
99
+ ;;
100
+ esac
101
+ done
102
+ # Restore positional parameters without --mode flags
103
+ set -- "${ARGS[@]+"${ARGS[@]}"}"
104
+
105
+ # --- Mode Detection ---
106
+ MODE=""
107
+
108
+ if [ -n "$MODE_OVERRIDE" ]; then
109
+ case "$MODE_OVERRIDE" in
110
+ native|local) MODE="local" ;; # native is now an alias for local
111
+ cloud) MODE="cloud" ;;
112
+ *)
113
+ echo "ERROR: Unknown --mode value '$MODE_OVERRIDE' (must be local or cloud)" >&2
114
+ exit 1
115
+ ;;
116
+ esac
117
+ elif [ "${THINKBROWSE_NATIVE:-}" = "true" ] || [ "${THINKBROWSE_LOCAL:-}" = "true" ]; then
118
+ MODE="local"
119
+ elif probe_local_bridge_health; then
120
+ MODE="local"
121
+ else
122
+ # Try cloud mode
123
+ API_KEY=""
124
+ if [ -n "${THINKBROWSE_API_KEY:-}" ]; then
125
+ API_KEY="$THINKBROWSE_API_KEY"
126
+ elif [ -f "$CONFIG_FILE" ]; then
127
+ API_KEY=$(jq -r '.apiKey // empty' "$CONFIG_FILE" 2>/dev/null)
128
+ fi
129
+
130
+ if [ -n "${API_KEY:-}" ]; then
131
+ MODE="cloud"
132
+ else
133
+ echo "ERROR: No browser automation endpoint or API key found." >&2
134
+ echo " Local mode: Open Chrome with ThinkBrowse extension + native host installed" >&2
135
+ echo " Cloud mode: Set THINKBROWSE_API_KEY or create $CONFIG_FILE" >&2
136
+ exit 1
137
+ fi
138
+ fi
139
+
140
+ # --- Cloud config ---
141
+ if [ "$MODE" = "cloud" ]; then
142
+ # Resolve API key for explicit --mode cloud and auto-detected cloud mode.
143
+ if [ -n "${THINKBROWSE_API_KEY:-}" ]; then
144
+ API_KEY="$THINKBROWSE_API_KEY"
145
+ elif [ -f "$CONFIG_FILE" ]; then
146
+ API_KEY=$(jq -r '.apiKey // empty' "$CONFIG_FILE" 2>/dev/null)
147
+ else
148
+ API_KEY=""
149
+ fi
150
+ if [ -z "${API_KEY:-}" ]; then
151
+ echo "ERROR: Cloud mode requires THINKBROWSE_API_KEY or $CONFIG_FILE" >&2
152
+ exit 1
153
+ fi
154
+ # Read apiUrl from config file, fall back to env var or default
155
+ CONFIG_API_URL=""
156
+ if [ -f "$CONFIG_FILE" ]; then
157
+ CONFIG_API_URL=$(jq -r '.apiUrl // empty' "$CONFIG_FILE" 2>/dev/null)
158
+ fi
159
+ BASE="${THINKBROWSE_URL:-${CONFIG_API_URL:-https://api.thinkbrowse.io}}"
160
+ H=(-H "X-API-Key: $API_KEY" -H "Content-Type: application/json")
161
+ fi
162
+
163
+ # --- Local session sync helpers ---
164
+ LOCAL_SYNC_AUTH_INITIALIZED="false"
165
+ LOCAL_SYNC_API_KEY=""
166
+ LOCAL_SYNC_BASE=""
167
+
168
+ init_local_sync_auth() {
169
+ if [ "$LOCAL_SYNC_AUTH_INITIALIZED" = "true" ]; then
170
+ return
171
+ fi
172
+
173
+ local cfg_api_key=""
174
+ local cfg_api_url=""
175
+ if [ -f "$CONFIG_FILE" ]; then
176
+ cfg_api_key=$(jq -r '.apiKey // empty' "$CONFIG_FILE" 2>/dev/null)
177
+ cfg_api_url=$(jq -r '.apiUrl // empty' "$CONFIG_FILE" 2>/dev/null)
178
+ fi
179
+
180
+ LOCAL_SYNC_API_KEY="${THINKBROWSE_API_KEY:-$cfg_api_key}"
181
+ LOCAL_SYNC_BASE="${THINKBROWSE_URL:-${cfg_api_url:-https://api.thinkbrowse.io}}"
182
+ LOCAL_SYNC_AUTH_INITIALIZED="true"
183
+ }
184
+
185
+ can_sync_local_session() {
186
+ local sid="$1"
187
+ [ "$MODE" = "local" ] || return 1
188
+ [[ "$sid" == local-* ]] && return 1
189
+ init_local_sync_auth
190
+ [ -n "${LOCAL_SYNC_API_KEY:-}" ]
191
+ }
192
+
193
+ sync_local_action_event() {
194
+ local sid="$1"
195
+ local action_type="$2"
196
+ local success="$3"
197
+ local details_json="${4:-{}}"
198
+ local error_msg="${5:-}"
199
+ local artifact_b64="${6:-}"
200
+
201
+ can_sync_local_session "$sid" || return 0
202
+
203
+ local payload
204
+ payload=$(jq -cn \
205
+ --arg type "$action_type" \
206
+ --arg success "$success" \
207
+ --arg details_raw "$details_json" \
208
+ --arg error "$error_msg" \
209
+ --arg artifact "$artifact_b64" \
210
+ '{
211
+ type: $type,
212
+ success: ($success == "true"),
213
+ details: ($details_raw | fromjson? // {})
214
+ }
215
+ + (if ($error|length) > 0 then { error: $error } else {} end)
216
+ + (if ($artifact|length) > 0 then { artifactBase64: $artifact, artifactMimeType: "image/png" } else {} end)
217
+ ')
218
+
219
+ curl -s \
220
+ -H "X-API-Key: $LOCAL_SYNC_API_KEY" \
221
+ -H "Content-Type: application/json" \
222
+ -X POST "$LOCAL_SYNC_BASE/api/sessions/$sid/local-sync/action" \
223
+ -d "$payload" >/dev/null 2>&1 || true
224
+ }
225
+
226
+ maybe_sync_local_command() {
227
+ local sid="$1"
228
+ local http_code="$2"
229
+ local body="$3"
230
+ shift 3
231
+
232
+ can_sync_local_session "$sid" || return 0
233
+
234
+ local method="POST"
235
+ local req_data=""
236
+ local req_url=""
237
+ local prev=""
238
+ for arg in "$@"; do
239
+ if [ "$prev" = "-X" ]; then
240
+ method="$arg"
241
+ prev=""
242
+ continue
243
+ fi
244
+ if [ "$prev" = "-d" ]; then
245
+ req_data="$arg"
246
+ prev=""
247
+ continue
248
+ fi
249
+ case "$arg" in
250
+ -X|-d)
251
+ prev="$arg"
252
+ ;;
253
+ http://*|https://*)
254
+ req_url="$arg"
255
+ ;;
256
+ esac
257
+ done
258
+
259
+ [ -n "$req_url" ] || return 0
260
+ [ "$method" = "POST" ] || return 0
261
+
262
+ local endpoint="${req_url##*/api/}"
263
+ local action_type=""
264
+ case "$endpoint" in
265
+ navigate) action_type="navigate" ;;
266
+ click) action_type="click" ;;
267
+ fill) action_type="fill" ;;
268
+ type) action_type="type" ;;
269
+ press) action_type="press" ;;
270
+ hover) action_type="hover" ;;
271
+ select) action_type="select" ;;
272
+ wait) action_type="wait" ;;
273
+ wait-for-text) action_type="wait" ;;
274
+ evaluate) action_type="evaluate" ;;
275
+ extract) action_type="extract" ;;
276
+ snapshot) action_type="snapshot" ;;
277
+ screenshot) action_type="screenshot" ;;
278
+ scroll) action_type="scroll" ;;
279
+ go-back) action_type="go-back" ;;
280
+ go-forward) action_type="go-forward" ;;
281
+ *) return 0 ;;
282
+ esac
283
+
284
+ local details_json="{}"
285
+ if [ -n "$req_data" ]; then
286
+ details_json=$(printf '%s' "$req_data" | jq -c '.' 2>/dev/null || echo '{}')
287
+ fi
288
+ details_json=$(jq -cn --arg endpoint "$endpoint" --arg details_raw "$details_json" '{ endpoint: $endpoint } + ($details_raw | fromjson? // {})')
289
+
290
+ local success_bool error_msg artifact_b64
291
+ success_bool=$(echo "$body" | jq -r 'if (.success == false) then "false" else "true" end' 2>/dev/null || echo "true")
292
+ error_msg=$(echo "$body" | jq -r '.error // .message // empty' 2>/dev/null || echo "")
293
+ artifact_b64=""
294
+ if [ "$action_type" = "screenshot" ]; then
295
+ artifact_b64=$(echo "$body" | jq -r '.data.screenshot // .screenshot // empty' 2>/dev/null || echo "")
296
+ fi
297
+
298
+ sync_local_action_event "$sid" "$action_type" "$success_bool" "$details_json" "$error_msg" "$artifact_b64"
299
+ }
300
+
301
+ # --- Local helpers ---
302
+
303
+ # Check native host is reachable
304
+ ensure_local() {
305
+ local health_json
306
+ health_json=$(curl -4 -s --max-time 2 "$LOCAL_URL/health" 2>/dev/null || true)
307
+
308
+ if [ -z "$health_json" ]; then
309
+ echo "ERROR: Native host not responding on $LOCAL_URL" >&2
310
+ echo " Make sure Chrome is open with the ThinkBrowse extension installed." >&2
311
+ exit 1
312
+ fi
313
+
314
+ if ! is_thinkbrowse_health_response "$health_json"; then
315
+ echo "ERROR: Service on $LOCAL_URL is not the ThinkBrowse native host." >&2
316
+ echo " Received /health payload: $health_json" >&2
317
+ echo " This usually means another local service is using port $LOCAL_PORT" >&2
318
+ echo " or localhost/IPv6 resolved to a different process." >&2
319
+ if command -v lsof >/dev/null 2>&1; then
320
+ echo " Listening processes on :$LOCAL_PORT:" >&2
321
+ lsof -nP -iTCP:"$LOCAL_PORT" -sTCP:LISTEN >&2 || true
322
+ fi
323
+ echo " Fix: keep ThinkBrowse on $LOCAL_HOST:$LOCAL_PORT, move conflicting services," >&2
324
+ echo " or override host/port with THINKBROWSE_LOCAL_HOST / THINKBROWSE_LOCAL_PORT." >&2
325
+ exit 1
326
+ fi
327
+ }
328
+
329
+ # Save tab ID for a local session
330
+ save_tab_id() {
331
+ local sid="$1" tab_id="$2"
332
+ mkdir -p "$TAB_DIR"
333
+ echo "$tab_id" > "$TAB_DIR/$sid"
334
+ }
335
+
336
+ # Load tab ID for a local session
337
+ load_tab_id() {
338
+ local sid="$1"
339
+ local tab_file="$TAB_DIR/$sid"
340
+ if [ ! -f "$tab_file" ]; then
341
+ echo "ERROR: No tab found for session $sid" >&2
342
+ exit 1
343
+ fi
344
+ cat "$tab_file"
345
+ }
346
+
347
+ # Remove tab ID file for a local session
348
+ remove_tab_id() {
349
+ local sid="$1"
350
+ rm -f "$TAB_DIR/$sid"
351
+ }
352
+
353
+ # curl wrapper for local/native HTTP endpoints with tab pinning + retry
354
+ local_curl() {
355
+ local sid="$1"; shift
356
+ local tab_id
357
+ tab_id=$(load_tab_id "$sid")
358
+ local max_retries=3
359
+ local delay=1
360
+ for attempt in $(seq 1 $max_retries); do
361
+ RESP=$(curl -s -w "\n%{http_code}" -H "Content-Type: application/json" -H "X-Tab-Id: $tab_id" "$@")
362
+ HTTP_CODE=$(echo "$RESP" | tail -1)
363
+ BODY=$(echo "$RESP" | sed '$d')
364
+
365
+ # Check for TAB_NOT_FOUND — don't retry, clean up session
366
+ CODE=$(echo "$BODY" | jq -r '.code // empty' 2>/dev/null)
367
+ if [ "$CODE" = "TAB_NOT_FOUND" ]; then
368
+ echo "ERROR: Session tab was closed" >&2
369
+ remove_tab_id "$sid"
370
+ exit 1
371
+ fi
372
+
373
+ # Check for retryable conditions (structured field or HTTP status)
374
+ RETRYABLE=$(echo "$BODY" | jq -r '.retryable // false' 2>/dev/null)
375
+ case "$HTTP_CODE" in
376
+ 503|504) RETRYABLE=true ;;
377
+ esac
378
+ if [ "$RETRYABLE" = "true" ] && [ "$attempt" -lt "$max_retries" ]; then
379
+ sleep $delay
380
+ delay=$((delay * 2))
381
+ continue
382
+ fi
383
+ maybe_sync_local_command "$sid" "$HTTP_CODE" "$BODY" "$@"
384
+ echo "$BODY"
385
+ return 0
386
+ done
387
+ maybe_sync_local_command "$sid" "$HTTP_CODE" "$BODY" "$@"
388
+ echo "$BODY"
389
+ }
390
+
391
+ # --- Cloud helpers ---
392
+
393
+ # Retry helper for transient errors (Fly routing, timeouts, 502/503/504/524)
394
+ retry_curl() {
395
+ local max_retries=4
396
+ local delay=1
397
+ for attempt in $(seq 1 $max_retries); do
398
+ RESP=$(curl -s -w "\n%{http_code}" "${H[@]}" "$@")
399
+ HTTP_CODE=$(echo "$RESP" | tail -1)
400
+ BODY=$(echo "$RESP" | sed '$d')
401
+
402
+ # Check for retryable conditions
403
+ SHOULD_RETRY=false
404
+
405
+ # Structured retryable field from bridge/cloud API
406
+ RETRYABLE=$(echo "$BODY" | jq -r '.retryable // false' 2>/dev/null)
407
+ if [ "$RETRYABLE" = "true" ]; then
408
+ SHOULD_RETRY=true
409
+ fi
410
+
411
+ # HTTP status codes that indicate transient failures
412
+ case "$HTTP_CODE" in
413
+ 502|503|504|524) SHOULD_RETRY=true ;;
414
+ esac
415
+
416
+ # Legacy error string matching
417
+ ERROR=$(echo "$BODY" | jq -r '.error // empty' 2>/dev/null)
418
+ case "$ERROR" in
419
+ "Session not found"|"Session is still provisioning. Please wait for it to become active.")
420
+ SHOULD_RETRY=true ;;
421
+ esac
422
+
423
+ if [ "$SHOULD_RETRY" = "true" ] && [ "$attempt" -lt "$max_retries" ]; then
424
+ sleep $delay
425
+ delay=$((delay * 2))
426
+ continue
427
+ fi
428
+
429
+ echo "$BODY"
430
+ return 0
431
+ done
432
+ echo "$BODY"
433
+ }
434
+
435
+ # Validate local bridge identity once before command dispatch.
436
+ if [ "$MODE" = "local" ]; then
437
+ ensure_local
438
+ fi
439
+
440
+ # --- Commands ---
441
+ case "${1:-help}" in
442
+
443
+ # ========== Session Management ==========
444
+
445
+ session-create)
446
+ if [ "$MODE" = "local" ]; then
447
+ ensure_local
448
+ # Create a new tab via native host
449
+ RESP=$(curl -s -H "Content-Type: application/json" -X POST "$LOCAL_URL/api/tabs/new" \
450
+ -d '{"url":"about:blank"}')
451
+ SUCCESS=$(echo "$RESP" | jq -r '.success // false')
452
+ if [ "$SUCCESS" != "true" ]; then
453
+ echo "ERROR: Failed to create tab (mode: $MODE)" >&2
454
+ echo "$RESP" >&2
455
+ exit 1
456
+ fi
457
+ # Extract tab ID from response
458
+ TAB_ID=$(echo "$RESP" | jq -r '.data.tabId // .data.id // empty')
459
+ if [ -z "$TAB_ID" ]; then
460
+ echo "ERROR: No tab ID in response (mode: $MODE)" >&2
461
+ echo "$RESP" >&2
462
+ exit 1
463
+ fi
464
+ # Prefer backend-issued session IDs for local execution when API auth is available.
465
+ LOCAL_API_KEY=""
466
+ LOCAL_API_URL=""
467
+ if [ -n "${THINKBROWSE_API_KEY:-}" ]; then
468
+ LOCAL_API_KEY="$THINKBROWSE_API_KEY"
469
+ elif [ -f "$CONFIG_FILE" ]; then
470
+ LOCAL_API_KEY=$(jq -r '.apiKey // empty' "$CONFIG_FILE" 2>/dev/null)
471
+ fi
472
+ if [ -f "$CONFIG_FILE" ]; then
473
+ LOCAL_API_URL=$(jq -r '.apiUrl // empty' "$CONFIG_FILE" 2>/dev/null)
474
+ fi
475
+ LOCAL_BASE="${THINKBROWSE_URL:-${LOCAL_API_URL:-https://api.thinkbrowse.io}}"
476
+
477
+ SID=""
478
+ if [ -n "$LOCAL_API_KEY" ]; then
479
+ BACKEND_RESP=$(curl -s -H "X-API-Key: $LOCAL_API_KEY" -H "Content-Type: application/json" \
480
+ -X POST "$LOCAL_BASE/api/sessions/local" -d '{}')
481
+ SID=$(echo "$BACKEND_RESP" | jq -r '.data.sessionId // .sessionId // empty')
482
+ fi
483
+ if [ -z "$SID" ]; then
484
+ SID="local-$TAB_ID"
485
+ fi
486
+ save_tab_id "$SID" "$TAB_ID"
487
+ echo "$SID"
488
+ else
489
+ RESP=$(curl -s "${H[@]}" -X POST "$BASE/api/sessions" -d '{}')
490
+ SID=$(echo "$RESP" | jq -r '.data.sessionId // .sessionId // empty')
491
+ if [ -z "$SID" ]; then
492
+ echo "ERROR: Session creation failed" >&2
493
+ echo "$RESP" >&2
494
+ exit 1
495
+ fi
496
+ # Poll until ready (max 120s for cold machine start)
497
+ for i in $(seq 1 40); do
498
+ STATUS=$(curl -s "${H[@]}" "$BASE/api/sessions/$SID" | jq -r '.data.status // .status // "unknown"')
499
+ case "$STATUS" in
500
+ ready|active|running)
501
+ echo "$SID"
502
+ exit 0
503
+ ;;
504
+ creating|provisioning|pending)
505
+ printf "." >&2
506
+ sleep 3
507
+ ;;
508
+ failed|error|terminated)
509
+ echo "" >&2
510
+ echo "ERROR: Session $SID failed with status: $STATUS" >&2
511
+ exit 1
512
+ ;;
513
+ esac
514
+ done
515
+ echo "" >&2
516
+ echo "ERROR: Session $SID not ready after 120s" >&2
517
+ exit 1
518
+ fi
519
+ ;;
520
+
521
+ session-status)
522
+ if [ "$MODE" = "local" ]; then
523
+ ensure_local
524
+ TAB_ID=$(load_tab_id "$2")
525
+ curl -s -H "Content-Type: application/json" -H "X-Tab-Id: $TAB_ID" "$LOCAL_URL/sessions/$TAB_ID"
526
+ else
527
+ curl -s "${H[@]}" "$BASE/api/sessions/$2"
528
+ fi
529
+ ;;
530
+
531
+ session-delete)
532
+ if [ "$MODE" = "local" ]; then
533
+ TAB_ID=$(load_tab_id "$2")
534
+ curl -s -H "Content-Type: application/json" -X POST "$LOCAL_URL/api/tabs/close" \
535
+ -d "{\"tabId\":$TAB_ID}"
536
+ remove_tab_id "$2"
537
+ else
538
+ curl -s "${H[@]}" -X DELETE "$BASE/api/sessions/$2"
539
+ fi
540
+ ;;
541
+
542
+ session-details)
543
+ if [ "$MODE" = "local" ]; then
544
+ if can_sync_local_session "$2"; then
545
+ BACKEND_DETAILS=$(curl -s -H "X-API-Key: $LOCAL_SYNC_API_KEY" -H "Content-Type: application/json" \
546
+ -X GET "$LOCAL_SYNC_BASE/api/sessions/$2/details" 2>/dev/null || true)
547
+ if [ -n "$BACKEND_DETAILS" ] && [ "$(echo "$BACKEND_DETAILS" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
548
+ echo "$BACKEND_DETAILS"
549
+ exit 0
550
+ fi
551
+ fi
552
+ TAB_ID=$(load_tab_id "$2")
553
+ SESSION_JSON=$(curl -s -H "Content-Type: application/json" -H "X-Tab-Id: $TAB_ID" "$LOCAL_URL/sessions/$TAB_ID")
554
+ URL_VAL=$(echo "$SESSION_JSON" | jq -r '.data.url // ""')
555
+ TITLE_VAL=$(echo "$SESSION_JSON" | jq -r '.data.title // ""')
556
+ jq -n \
557
+ --arg sid "$2" \
558
+ --arg tabId "$TAB_ID" \
559
+ --arg url "$URL_VAL" \
560
+ --arg title "$TITLE_VAL" \
561
+ '{
562
+ success: true,
563
+ session: {
564
+ sessionId: $sid,
565
+ executionMode: "local",
566
+ executor: "native-host",
567
+ status: "active",
568
+ tabId: ($tabId|tonumber),
569
+ currentUrl: $url,
570
+ title: $title,
571
+ artifacts: [],
572
+ actions: [],
573
+ storageBacked: false
574
+ }
575
+ }'
576
+ else
577
+ retry_curl -X GET "$BASE/api/sessions/$2/details"
578
+ fi
579
+ ;;
580
+
581
+ session-actions)
582
+ if [ "$MODE" = "local" ]; then
583
+ if can_sync_local_session "$2"; then
584
+ LIMIT="${3:-50}"
585
+ OFFSET="${4:-0}"
586
+ TYPE="${5:-}"
587
+ ACTIONS_URL="$LOCAL_SYNC_BASE/api/sessions/$2/actions?limit=$LIMIT&offset=$OFFSET"
588
+ if [ -n "$TYPE" ]; then
589
+ ACTIONS_URL="${ACTIONS_URL}&type=$TYPE"
590
+ fi
591
+ BACKEND_ACTIONS=$(curl -s -H "X-API-Key: $LOCAL_SYNC_API_KEY" -H "Content-Type: application/json" \
592
+ -X GET "$ACTIONS_URL" 2>/dev/null || true)
593
+ if [ -n "$BACKEND_ACTIONS" ] && [ "$(echo "$BACKEND_ACTIONS" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
594
+ echo "$BACKEND_ACTIONS"
595
+ exit 0
596
+ fi
597
+ fi
598
+ TAB_ID=$(load_tab_id "$2")
599
+ CONSOLE_JSON=$(local_curl "$2" -X POST "$LOCAL_URL/api/console")
600
+ NETWORK_JSON=$(local_curl "$2" -X POST "$LOCAL_URL/api/network")
601
+ LIMIT="${3:-50}"
602
+ OFFSET="${4:-0}"
603
+ jq -n \
604
+ --arg sid "$2" \
605
+ --argjson limit "$LIMIT" \
606
+ --argjson offset "$OFFSET" \
607
+ --argjson console "$CONSOLE_JSON" \
608
+ --argjson network "$NETWORK_JSON" \
609
+ '{
610
+ success: true,
611
+ data: {
612
+ sessionId: $sid,
613
+ executionMode: "local",
614
+ actions: [],
615
+ console: ($console.data // []),
616
+ network: ($network.data // []),
617
+ total: 0,
618
+ successful: 0,
619
+ failed: 0,
620
+ hasMore: false,
621
+ note: "Local mode currently exposes console and network logs; action history persistence is cloud-only."
622
+ }
623
+ }'
624
+ else
625
+ LIMIT="${3:-50}"
626
+ OFFSET="${4:-0}"
627
+ TYPE="${5:-}"
628
+ ACTIONS_URL="$BASE/api/sessions/$2/actions?limit=$LIMIT&offset=$OFFSET"
629
+ if [ -n "$TYPE" ]; then
630
+ ACTIONS_URL="${ACTIONS_URL}&type=$TYPE"
631
+ fi
632
+ retry_curl -X GET "$ACTIONS_URL"
633
+ fi
634
+ ;;
635
+
636
+ session-artifacts)
637
+ if [ "$MODE" = "local" ]; then
638
+ if can_sync_local_session "$2"; then
639
+ BACKEND_ARTIFACTS=$(curl -s -H "X-API-Key: $LOCAL_SYNC_API_KEY" -H "Content-Type: application/json" \
640
+ -X GET "$LOCAL_SYNC_BASE/api/sessions/$2/artifacts" 2>/dev/null || true)
641
+ if [ -n "$BACKEND_ARTIFACTS" ] && [ "$(echo "$BACKEND_ARTIFACTS" | jq -r '.success // false' 2>/dev/null)" = "true" ]; then
642
+ echo "$BACKEND_ARTIFACTS"
643
+ exit 0
644
+ fi
645
+ fi
646
+ jq -n --arg sid "$2" '{
647
+ success: true,
648
+ sessionId: $sid,
649
+ executionMode: "local",
650
+ artifacts: [],
651
+ note: "Local mode does not persist artifacts to cloud storage. Use --mode cloud for reusable stored artifacts."
652
+ }'
653
+ else
654
+ retry_curl -X GET "$BASE/api/sessions/$2/artifacts"
655
+ fi
656
+ ;;
657
+
658
+ artifact-download)
659
+ if [ "$MODE" = "local" ]; then
660
+ echo "ERROR: artifact-download is available in cloud mode only (use --mode cloud)" >&2
661
+ exit 1
662
+ fi
663
+ ARTIFACT_ID="$2"
664
+ OUTFILE="${3:-artifact-$ARTIFACT_ID.bin}"
665
+ if ! curl -fsSL -H "X-API-Key: $API_KEY" "$BASE/api/storage/artifacts/$ARTIFACT_ID/download" -o "$OUTFILE"; then
666
+ echo "ERROR: Failed to download artifact $ARTIFACT_ID" >&2
667
+ exit 1
668
+ fi
669
+ echo "$OUTFILE"
670
+ ;;
671
+
672
+ # ========== Navigation ==========
673
+
674
+ goto)
675
+ if [ "$MODE" = "local" ]; then
676
+ local_curl "$2" -X POST "$LOCAL_URL/api/navigate" \
677
+ -d "{\"url\":$(printf '%s' "$3" | jq -Rs)}"
678
+ else
679
+ retry_curl -X POST "$BASE/api/sessions/$2/navigate" \
680
+ -d "{\"url\":$(printf '%s' "$3" | jq -Rs)}"
681
+ fi
682
+ ;;
683
+
684
+ scroll)
685
+ DIR="${3:-down}"
686
+ AMT="${4:-500}"
687
+ if ! [[ "$AMT" =~ ^[0-9]+$ ]]; then
688
+ echo "ERROR: Amount must be a positive integer" >&2
689
+ exit 1
690
+ fi
691
+ if [ "$MODE" = "local" ]; then
692
+ local_curl "$2" -X POST "$LOCAL_URL/api/scroll" \
693
+ -d "{\"direction\":\"$DIR\",\"amount\":$AMT}"
694
+ else
695
+ if [ "$DIR" = "up" ]; then
696
+ retry_curl -X POST "$BASE/api/sessions/$2/evaluate" \
697
+ -d "{\"script\":\"window.scrollBy(0, -$AMT)\"}"
698
+ else
699
+ retry_curl -X POST "$BASE/api/sessions/$2/evaluate" \
700
+ -d "{\"script\":\"window.scrollBy(0, $AMT)\"}"
701
+ fi
702
+ fi
703
+ ;;
704
+
705
+ # ========== Observation ==========
706
+
707
+ snapshot)
708
+ if [ "$MODE" = "local" ]; then
709
+ local_curl "$2" -X POST "$LOCAL_URL/api/snapshot" \
710
+ | jq -r '.data // .error // "No snapshot available"'
711
+ else
712
+ retry_curl -X POST "$BASE/api/sessions/$2/snapshot" \
713
+ | jq -r '.snapshot // .error // "No snapshot available"'
714
+ fi
715
+ ;;
716
+
717
+ screenshot)
718
+ if [ "$MODE" = "local" ]; then
719
+ local_curl "$2" -X POST "$LOCAL_URL/api/screenshot"
720
+ else
721
+ retry_curl -X POST "$BASE/api/sessions/$2/screenshot"
722
+ fi
723
+ ;;
724
+
725
+ extract)
726
+ TYPE="${3:-text}"
727
+ if [ "$MODE" = "local" ]; then
728
+ local_curl "$2" -X POST "$LOCAL_URL/api/extract" \
729
+ -d "{\"type\":$(printf '%s' "$TYPE" | jq -Rs)}"
730
+ else
731
+ retry_curl -X POST "$BASE/api/sessions/$2/extract" \
732
+ -d "{\"type\":$(printf '%s' "$TYPE" | jq -Rs)}"
733
+ fi
734
+ ;;
735
+
736
+ # ========== Interaction ==========
737
+
738
+ click)
739
+ if [ "$MODE" = "local" ]; then
740
+ local_curl "$2" -X POST "$LOCAL_URL/api/click" \
741
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs)}"
742
+ else
743
+ retry_curl -X POST "$BASE/api/sessions/$2/click" \
744
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs)}"
745
+ fi
746
+ ;;
747
+
748
+ fill)
749
+ if [ "$MODE" = "local" ]; then
750
+ local_curl "$2" -X POST "$LOCAL_URL/api/fill" \
751
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"value\":$(printf '%s' "$4" | jq -Rs)}"
752
+ else
753
+ retry_curl -X POST "$BASE/api/sessions/$2/fill" \
754
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"value\":$(printf '%s' "$4" | jq -Rs)}"
755
+ fi
756
+ ;;
757
+
758
+ type)
759
+ if [ "$MODE" = "local" ]; then
760
+ local_curl "$2" -X POST "$LOCAL_URL/api/type" \
761
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"text\":$(printf '%s' "$4" | jq -Rs)}"
762
+ else
763
+ retry_curl -X POST "$BASE/api/sessions/$2/type" \
764
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"text\":$(printf '%s' "$4" | jq -Rs)}"
765
+ fi
766
+ ;;
767
+
768
+ press)
769
+ if [ "$MODE" = "local" ]; then
770
+ local_curl "$2" -X POST "$LOCAL_URL/api/press" \
771
+ -d "{\"key\":$(printf '%s' "$3" | jq -Rs)}"
772
+ else
773
+ retry_curl -X POST "$BASE/api/sessions/$2/press" \
774
+ -d "{\"key\":$(printf '%s' "$3" | jq -Rs)}"
775
+ fi
776
+ ;;
777
+
778
+ hover)
779
+ if [ "$MODE" = "local" ]; then
780
+ local_curl "$2" -X POST "$LOCAL_URL/api/hover" \
781
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs)}"
782
+ else
783
+ retry_curl -X POST "$BASE/api/sessions/$2/hover" \
784
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs)}"
785
+ fi
786
+ ;;
787
+
788
+ select)
789
+ if [ "$MODE" = "local" ]; then
790
+ local_curl "$2" -X POST "$LOCAL_URL/api/select" \
791
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"value\":$(printf '%s' "$4" | jq -Rs)}"
792
+ else
793
+ retry_curl -X POST "$BASE/api/sessions/$2/select" \
794
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"value\":$(printf '%s' "$4" | jq -Rs)}"
795
+ fi
796
+ ;;
797
+
798
+ wait)
799
+ TIMEOUT="${4:-10000}"
800
+ if ! [[ "$TIMEOUT" =~ ^[0-9]+$ ]]; then
801
+ echo "ERROR: Timeout must be a positive integer (ms)" >&2
802
+ exit 1
803
+ fi
804
+ if [ "$MODE" = "local" ]; then
805
+ local_curl "$2" -X POST "$LOCAL_URL/api/wait" \
806
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"timeout\":$TIMEOUT}"
807
+ else
808
+ retry_curl -X POST "$BASE/api/sessions/$2/wait" \
809
+ -d "{\"selector\":$(printf '%s' "$3" | jq -Rs),\"timeout\":$TIMEOUT}"
810
+ fi
811
+ ;;
812
+
813
+ evaluate)
814
+ if [ "$MODE" = "local" ]; then
815
+ local_curl "$2" -X POST "$LOCAL_URL/api/evaluate" \
816
+ -d "{\"script\":$(printf '%s' "$3" | jq -Rs)}"
817
+ else
818
+ retry_curl -X POST "$BASE/api/sessions/$2/evaluate" \
819
+ -d "{\"script\":$(printf '%s' "$3" | jq -Rs)}"
820
+ fi
821
+ ;;
822
+
823
+ # ========== Navigation History ==========
824
+
825
+ back)
826
+ if [ "$MODE" = "local" ]; then
827
+ local_curl "$2" -X POST "$LOCAL_URL/api/go-back"
828
+ else
829
+ retry_curl -X POST "$BASE/api/sessions/$2/go-back"
830
+ fi
831
+ ;;
832
+
833
+ forward)
834
+ if [ "$MODE" = "local" ]; then
835
+ local_curl "$2" -X POST "$LOCAL_URL/api/go-forward"
836
+ else
837
+ retry_curl -X POST "$BASE/api/sessions/$2/go-forward"
838
+ fi
839
+ ;;
840
+
841
+ # ========== Dialog Handling ==========
842
+
843
+ dialog)
844
+ local dialog_action="${3:-status}"
845
+ case "$dialog_action" in
846
+ status)
847
+ if [ "$MODE" = "local" ]; then
848
+ local_curl "$2" -X POST "$LOCAL_URL/api/dialog"
849
+ else
850
+ retry_curl -X GET "$BASE/api/sessions/$2/dialog"
851
+ fi
852
+ ;;
853
+ accept)
854
+ if [ "$MODE" = "local" ]; then
855
+ echo '{"info":"Dialogs are auto-handled in local mode"}'
856
+ local_curl "$2" -X POST "$LOCAL_URL/api/dialog"
857
+ else
858
+ retry_curl -X POST "$BASE/api/sessions/$2/dialog" \
859
+ -d "{\"accept\":true$([ -n "${4:-}" ] && echo ",\"promptText\":$(printf '%s' "$4" | jq -Rs)" || echo "")}"
860
+ fi
861
+ ;;
862
+ dismiss)
863
+ if [ "$MODE" = "local" ]; then
864
+ echo '{"info":"Dialogs are auto-handled in local mode"}'
865
+ local_curl "$2" -X POST "$LOCAL_URL/api/dialog"
866
+ else
867
+ retry_curl -X POST "$BASE/api/sessions/$2/dialog" \
868
+ -d '{"accept":false}'
869
+ fi
870
+ ;;
871
+ *)
872
+ echo "ERROR: Unknown dialog action: $dialog_action (use status, accept, dismiss)" >&2
873
+ exit 1
874
+ ;;
875
+ esac
876
+ ;;
877
+
878
+ # ========== Wait for Text ==========
879
+
880
+ wait-for-text)
881
+ local text_val="$3"
882
+ local wft_timeout="${4:-30000}"
883
+ if [ "$MODE" = "local" ]; then
884
+ local_curl "$2" -X POST "$LOCAL_URL/api/wait-for-text" \
885
+ -d "{\"text\":$(printf '%s' "$text_val" | jq -Rs),\"timeout\":$wft_timeout}"
886
+ else
887
+ retry_curl -X POST "$BASE/api/sessions/$2/wait-for-text" \
888
+ -d "{\"text\":$(printf '%s' "$text_val" | jq -Rs),\"timeout\":$wft_timeout}"
889
+ fi
890
+ ;;
891
+
892
+ # ========== Observability ==========
893
+
894
+ console)
895
+ if [ "$MODE" = "local" ]; then
896
+ local_curl "$2" -X POST "$LOCAL_URL/api/console"
897
+ else
898
+ retry_curl -X GET "$BASE/api/sessions/$2/console"
899
+ fi
900
+ ;;
901
+
902
+ network)
903
+ if [ "$MODE" = "local" ]; then
904
+ local_curl "$2" -X POST "$LOCAL_URL/api/network"
905
+ else
906
+ retry_curl -X GET "$BASE/api/sessions/$2/network"
907
+ fi
908
+ ;;
909
+
910
+ url)
911
+ if [ "$MODE" = "local" ]; then
912
+ local_curl "$2" -X POST "$LOCAL_URL/api/url"
913
+ else
914
+ # Cloud: extract URL from snapshot
915
+ retry_curl -X POST "$BASE/api/sessions/$2/evaluate" \
916
+ -d '{"script":"window.location.href"}'
917
+ fi
918
+ ;;
919
+
920
+ title)
921
+ if [ "$MODE" = "local" ]; then
922
+ local_curl "$2" -X POST "$LOCAL_URL/api/title"
923
+ else
924
+ # Cloud: extract title from evaluate
925
+ retry_curl -X POST "$BASE/api/sessions/$2/evaluate" \
926
+ -d '{"script":"document.title"}'
927
+ fi
928
+ ;;
929
+
930
+ html)
931
+ if [ "$MODE" = "local" ]; then
932
+ local_curl "$2" -X POST "$LOCAL_URL/api/html"
933
+ else
934
+ retry_curl -X POST "$BASE/api/sessions/$2/extract" \
935
+ -d '{"type":"html"}'
936
+ fi
937
+ ;;
938
+
939
+ clear-logs)
940
+ if [ "$MODE" = "local" ]; then
941
+ local_curl "$2" -X POST "$LOCAL_URL/api/clear-logs"
942
+ else
943
+ echo "ERROR: clear-logs only available in local mode" >&2
944
+ exit 1
945
+ fi
946
+ ;;
947
+
948
+ # ========== Tab Management (local only) ==========
949
+
950
+ tabs)
951
+ if [ "$MODE" = "local" ]; then
952
+ curl -s -H "Content-Type: application/json" "$LOCAL_URL/api/tabs"
953
+ else
954
+ echo "ERROR: Tab management only available in local mode" >&2
955
+ exit 1
956
+ fi
957
+ ;;
958
+
959
+ switch-tab)
960
+ if [ "$MODE" = "local" ]; then
961
+ local_curl "$2" -X POST "$LOCAL_URL/api/tabs/switch" \
962
+ -d "{\"tabId\":$3}"
963
+ else
964
+ echo "ERROR: Tab management only available in local mode" >&2
965
+ exit 1
966
+ fi
967
+ ;;
968
+
969
+ # ========== Help ==========
970
+
971
+ help|*)
972
+ echo "browse — Portable browser automation for AI agents"
973
+ echo ""
974
+ echo "Mode: ${MODE:-not detected} (local=native host $LOCAL_HOST:$LOCAL_PORT, cloud=Playwright)"
975
+ echo ""
976
+ echo "Session Management:"
977
+ echo " session-create Create session, print ID"
978
+ echo " session-status <sid> Get session details"
979
+ echo " session-delete <sid> Close session"
980
+ echo " session-details <sid> Get session details (cloud/local)"
981
+ echo " session-actions <sid> [limit] [off] Get session actions/logs (cloud/local)"
982
+ echo " session-artifacts <sid> List session artifacts (cloud/local)"
983
+ echo " artifact-download <id> [out] Download cloud artifact by ID"
984
+ echo ""
985
+ echo "Navigation:"
986
+ echo " goto <sid> <url> Navigate to URL"
987
+ echo " back <sid> Go back in browser history"
988
+ echo " forward <sid> Go forward in browser history"
989
+ echo " scroll <sid> [up|down] [pixels] Scroll page (default: down 500)"
990
+ echo ""
991
+ echo "Observation:"
992
+ echo " snapshot <sid> Get DOM tree / accessibility snapshot"
993
+ echo " screenshot <sid> Take screenshot"
994
+ echo " extract <sid> [text|html] Extract page content"
995
+ echo " console <sid> Get browser console logs"
996
+ echo " network <sid> Get network requests"
997
+ echo " url <sid> Get current page URL"
998
+ echo " title <sid> Get current page title"
999
+ echo " html <sid> Get full page HTML"
1000
+ echo ""
1001
+ echo "Interaction:"
1002
+ echo " click <sid> <selector> Click element"
1003
+ echo " fill <sid> <selector> <value> Fill form field (clears first)"
1004
+ echo " type <sid> <selector> <text> Type text (appends)"
1005
+ echo " press <sid> <key> Press key (Enter, Tab, Escape)"
1006
+ echo " hover <sid> <selector> Hover over element"
1007
+ echo " select <sid> <selector> <value> Select dropdown option"
1008
+ echo " wait <sid> <selector> [timeout_ms] Wait for element (default 10000ms)"
1009
+ echo " wait-for-text <sid> <text> [timeout] Wait for text to appear (default 30000ms)"
1010
+ echo " dialog <sid> [status|accept|dismiss] Handle browser dialog (alert/confirm/prompt)"
1011
+ echo " evaluate <sid> <javascript> Run JavaScript on page"
1012
+ echo ""
1013
+ echo "Local Only:"
1014
+ echo " clear-logs <sid> Clear console/network logs"
1015
+ echo " tabs List all open tabs"
1016
+ echo " switch-tab <sid> <tabId> Switch to specific tab"
1017
+ echo ""
1018
+ echo "Tab Listing Examples:"
1019
+ echo " browse tabs | jq -r '.data.tabs[] | \"\\(.id)\\t\\(.active)\\t\\(.url)\"'"
1020
+ echo " browse tabs | jq -r '.data.tabs[] | \"\\(.id)\\t\\(.active)\\t\\(.title)\\t\\(.url)\"'"
1021
+ echo ""
1022
+ echo "Flags:"
1023
+ echo " --mode local|cloud Override mode detection"
1024
+ echo ""
1025
+ echo "Port Discovery:"
1026
+ echo " The native host writes its port to ~/.thinkbrowse/port on startup."
1027
+ echo " This script reads it automatically. Override: THINKBROWSE_LOCAL_PORT env var."
1028
+ echo ""
1029
+ echo "Environment:"
1030
+ echo " THINKBROWSE_LOCAL=true Force local mode"
1031
+ echo " THINKBROWSE_LOCAL_PORT=<port> Override native host port"
1032
+ echo " THINKBROWSE_BRIDGE_PORT=<port> Legacy override alias (deprecated)"
1033
+ echo " THINKBROWSE_API_KEY=... Cloud API key"
1034
+ echo " THINKBROWSE_URL=... Cloud API base URL"
1035
+ echo " ~/.config/thinkbrowse/config.json Cloud config file"
1036
+ echo ""
1037
+ echo "Mode detection order:"
1038
+ echo " 1. --mode flag or THINKBROWSE_LOCAL=true"
1039
+ echo " 2. Native host health check ($LOCAL_HOST:$LOCAL_PORT)"
1040
+ echo " 3. THINKBROWSE_API_KEY or config file → cloud"
1041
+ echo ""
1042
+ echo "Docs:"
1043
+ echo " https://thinkbrowse.io/docs"
1044
+ echo " https://thinkbrowse.io/llms.txt"
1045
+ ;;
1046
+ esac