browser-ctl 0.2.8__tar.gz → 0.2.10__tar.gz

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.
Files changed (27) hide show
  1. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/PKG-INFO +5 -2
  2. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/README.md +4 -1
  3. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/SKILL.md +39 -17
  4. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/cli.py +219 -0
  5. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/client.py +120 -0
  6. browser_ctl-0.2.10/browser_ctl/extension/actions.js +584 -0
  7. browser_ctl-0.2.10/browser_ctl/extension/background.js +406 -0
  8. browser_ctl-0.2.10/browser_ctl/extension/click.js +366 -0
  9. browser_ctl-0.2.10/browser_ctl/extension/content-script.js +1193 -0
  10. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/manifest.json +3 -2
  11. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/server.py +1 -0
  12. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/PKG-INFO +5 -2
  13. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/SOURCES.txt +3 -0
  14. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/pyproject.toml +1 -1
  15. browser_ctl-0.2.8/browser_ctl/extension/background.js +0 -1396
  16. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/LICENSE +0 -0
  17. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/__init__.py +0 -0
  18. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/__main__.py +0 -0
  19. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-128.png +0 -0
  20. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-16.png +0 -0
  21. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-32.png +0 -0
  22. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl/extension/icon-48.png +0 -0
  23. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/dependency_links.txt +0 -0
  24. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/entry_points.txt +0 -0
  25. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/requires.txt +0 -0
  26. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/browser_ctl.egg-info/top_level.txt +0 -0
  27. {browser_ctl-0.2.8 → browser_ctl-0.2.10}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browser-ctl
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Control your browser from the command line via a Chrome extension + WebSocket bridge
5
5
  Author-email: geb <853934146@qq.com>
6
6
  License-Expression: MIT
@@ -125,7 +125,7 @@ Then in Chrome: `chrome://extensions` → Enable **Developer mode** → **Load u
125
125
  **Step 3** — Verify:
126
126
 
127
127
  ```bash
128
- bctl ping
128
+ bctl ensure-ready
129
129
  # {"success": true, "data": {"server": true, "extension": true}}
130
130
  ```
131
131
 
@@ -216,7 +216,10 @@ All `<sel>` arguments accept CSS selectors **or** element refs from `snapshot` (
216
216
 
217
217
  | Command | Description |
218
218
  |---------|-------------|
219
+ | `bctl ensure-ready` | Ensure server + extension are ready (auto-starts server, auto-launches Chrome if needed) |
219
220
  | `bctl ping` | Check server & extension status |
221
+ | `bctl capabilities` | Show actions supported by the connected extension |
222
+ | `bctl self-test` | Run generic end-to-end smoke tests for core skill actions |
220
223
  | `bctl serve` | Start server in foreground |
221
224
  | `bctl stop` | Stop server |
222
225
  | `bctl setup` | Install extension to `~/.browser-ctl/extension/` + open Chrome extensions page |
@@ -99,7 +99,7 @@ Then in Chrome: `chrome://extensions` → Enable **Developer mode** → **Load u
99
99
  **Step 3** — Verify:
100
100
 
101
101
  ```bash
102
- bctl ping
102
+ bctl ensure-ready
103
103
  # {"success": true, "data": {"server": true, "extension": true}}
104
104
  ```
105
105
 
@@ -190,7 +190,10 @@ All `<sel>` arguments accept CSS selectors **or** element refs from `snapshot` (
190
190
 
191
191
  | Command | Description |
192
192
  |---------|-------------|
193
+ | `bctl ensure-ready` | Ensure server + extension are ready (auto-starts server, auto-launches Chrome if needed) |
193
194
  | `bctl ping` | Check server & extension status |
195
+ | `bctl capabilities` | Show actions supported by the connected extension |
196
+ | `bctl self-test` | Run generic end-to-end smoke tests for core skill actions |
194
197
  | `bctl serve` | Start server in foreground |
195
198
  | `bctl stop` | Stop server |
196
199
  | `bctl setup` | Install extension to `~/.browser-ctl/extension/` + open Chrome extensions page |
@@ -14,11 +14,20 @@ Control Chrome via CLI. All commands return JSON to stdout.
14
14
 
15
15
  ## Always Start With
16
16
 
17
+ ```bash
18
+ bctl ensure-ready
19
+ ```
20
+
21
+ `ensure-ready` auto-starts the local bridge server and will try to launch Chrome
22
+ if the extension is not connected yet.
23
+
24
+ Fallback diagnostics:
25
+
17
26
  ```bash
18
27
  bctl ping
19
28
  ```
20
29
 
21
- If extension is not connected, tell the user to check Chrome and the extension.
30
+ If it still shows `"extension": false`, tell the user to check Chrome and the extension.
22
31
 
23
32
  ## Core Principle: Text-First Page Perception
24
33
 
@@ -58,6 +67,8 @@ bctl uncheck <sel> [-i N] [-t text] Uncheck checkbox
58
67
  bctl scroll <dir|sel> [n] Scroll: up/down/top/bottom/<selector> [pixels]
59
68
  bctl select-option <sel> <val> [--text] Select dropdown option (alias: sopt)
60
69
  bctl drag <src> [target] [--dx N --dy N] Drag element to target or by offset
70
+ bctl set-field <sel> <value> [--no-clear] [--text] Generic field setter
71
+ bctl submit-and-assert [sel] [--assert-selector CSS] [--assert-url EXP] [--mode ...] [--timeout s]
61
72
  ```
62
73
 
63
74
  ### Query
@@ -71,6 +82,8 @@ bctl count <sel> Count matching elements
71
82
  bctl status Current page URL and title
72
83
  bctl is-visible <sel> Check if element is visible (returns rect)
73
84
  bctl get-value <sel> Get form element value (input/select/textarea)
85
+ bctl assert-url <exp> [--mode equals|includes|regex] Assert current URL
86
+ bctl assert-field-value <sel> <exp> [--mode ...] [--by-text] Assert field value/text
74
87
  ```
75
88
 
76
89
  ### JavaScript
@@ -117,7 +130,10 @@ browser call, reducing overhead by ~90%.
117
130
 
118
131
  ### Server
119
132
  ```
133
+ bctl ensure-ready Ensure server + extension are ready (auto-launch Chrome)
120
134
  bctl ping Check server and extension status
135
+ bctl capabilities Show extension-supported actions
136
+ bctl self-test Run generic end-to-end smoke checks
121
137
  bctl serve Start server (foreground)
122
138
  bctl stop Stop server
123
139
  ```
@@ -147,19 +163,13 @@ Use `-t` to filter by visible text — ideal for SPAs where class names are dyna
147
163
  bctl click "button" -t "Submit" # click button containing "Submit"
148
164
  ```
149
165
 
150
- ### SPA Video Sites (Tencent Video, Bilibili, etc.)
151
- `bctl click` intercepts `window.open()` calls from SPA frameworks and opens the
152
- target URL via `chrome.tabs.create`. Just click like a normal user:
166
+ ### Generic Submit + Assertion Flow
167
+ Prefer assertions after every important state change:
153
168
  ```bash
154
- bctl go "https://v.qq.com" && bctl wait 2
155
- bctl type "input" "西游记" && bctl press Enter && bctl wait 3
156
- bctl click ".root.list-item .poster-view" -i 0 # opens video in new tab
157
- ```
158
-
159
- Fallback — extract content ID and navigate directly:
160
- ```bash
161
- bctl attr ".root.list-item [dt-eid='poster']" "dt-params" | grep -o 'cid=[^&]*'
162
- bctl go "https://v.qq.com/x/cover/<cid>.html"
169
+ bctl click "button" -t "Open settings"
170
+ bctl set-field "input[name='displayName']" "Alice"
171
+ bctl submit-and-assert "button[type='submit']" --assert-selector ".toast-success" --timeout 8
172
+ bctl assert-field-value "input[name='displayName']" "Alice"
163
173
  ```
164
174
 
165
175
  ### Waiting Strategy
@@ -169,13 +179,13 @@ bctl go "https://v.qq.com/x/cover/<cid>.html"
169
179
  - Pure sleep (`bctl wait N`) runs locally in Python — no extension round-trip, so it
170
180
  never times out even on heavy pages.
171
181
 
172
- ### Heavy SPA Pages (YouTube, Gmail, etc.)
173
- Heavy SPA pages can cause the extension service worker to become unresponsive during
182
+ ### Heavy SPA Pages
183
+ Heavy pages can cause the extension service worker to become unresponsive during
174
184
  page load. To avoid timeouts:
175
185
  - **Don't chain `bctl wait` with navigation via `&&`** — if the page is loading, the
176
186
  wait command may timeout because the extension is busy. Instead, run them separately:
177
187
  ```bash
178
- bctl go "https://www.youtube.com"
188
+ bctl go "https://example.com"
179
189
  bctl wait 3
180
190
  bctl status
181
191
  ```
@@ -245,10 +255,22 @@ and returns text/href/id/class/aria-label automatically.
245
255
  7. **Prefer `bctl go` over `bctl new-tab`** for simple navigation — fewer failure modes.
246
256
  8. **Never use `eval` to set input values or click buttons on SPA sites** — use `type`/`input-text`/`click`.
247
257
  9. **Verify after complex UI interactions** — `snapshot` or `text` to confirm state changed.
258
+ 10. **Use assertion primitives** — `assert-url`, `assert-field-value`, `submit-and-assert`.
259
+
260
+ ## Universal Interaction Template
261
+
262
+ Use this sequence on any site:
263
+
264
+ 1. `bctl ensure-ready`
265
+ 2. `bctl status` and `bctl snapshot`
266
+ 3. Prefer `eN` refs from snapshot when possible
267
+ 4. Execute (`click`/`set-field`/`type`)
268
+ 5. Assert (`assert-url` / `assert-field-value` / `count`)
269
+ 6. Retry only when error is retriable; otherwise refresh snapshot and re-select
248
270
 
249
271
  ## Known Limitations
250
272
 
251
- - `eval` blocked by Trusted Types on some sites (Gemini, YouTube) — use `attr`/`select` instead
273
+ - `eval` may be blocked by Trusted Types/CSP on some pages — use `attr`/`select` instead
252
274
  - `eval` with `input.value = ...` or `.click()` bypasses SPA framework state — use `type`/`click` instead
253
275
  - `screenshot` captures visible viewport only — scroll for full-page capture
254
276
  - Without `-i`, `click` always hits the FIRST match — use `count` to check first
@@ -62,6 +62,7 @@ CONTENT_SCRIPT_OPS = frozenset({
62
62
  "text", "html", "attr", "select", "count", "snapshot",
63
63
  "is-visible", "get-value",
64
64
  "scroll", "select-option", "drag", "wait",
65
+ "assert-url", "assert-field-value", "set-field", "submit-and-assert",
65
66
  })
66
67
 
67
68
 
@@ -175,6 +176,29 @@ def build_parser() -> argparse.ArgumentParser:
175
176
  prog="bctl",
176
177
  description="Control your browser from the command line",
177
178
  )
179
+ parser.add_argument(
180
+ "--session",
181
+ default=None,
182
+ help="Execution session id (default: default)",
183
+ )
184
+ parser.add_argument(
185
+ "--tab",
186
+ type=int,
187
+ default=None,
188
+ help="Target tab id for command execution",
189
+ )
190
+ parser.add_argument(
191
+ "--window",
192
+ type=int,
193
+ default=None,
194
+ help="Target window id (uses active tab in that window)",
195
+ )
196
+ parser.add_argument(
197
+ "--world",
198
+ choices=["ISOLATED", "MAIN"],
199
+ default=None,
200
+ help="Script execution world override for DOM operations",
201
+ )
178
202
  sub = parser.add_subparsers(dest="command", help="Available commands")
179
203
 
180
204
  # -- Navigation --
@@ -261,6 +285,28 @@ def build_parser() -> argparse.ArgumentParser:
261
285
  p.add_argument("selector", help="CSS selector or element ref")
262
286
  p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
263
287
 
288
+ p = sub.add_parser("assert-url", help="Assert current URL matches expectation")
289
+ p.add_argument("expected", help="Expected URL value/pattern")
290
+ p.add_argument(
291
+ "--mode",
292
+ choices=["equals", "includes", "regex"],
293
+ default="includes",
294
+ help="Assertion mode (default: includes)",
295
+ )
296
+
297
+ p = sub.add_parser("assert-field-value", help="Assert form field value/text")
298
+ p.add_argument("selector", help="CSS selector or element ref")
299
+ p.add_argument("expected", help="Expected field value/text")
300
+ p.add_argument(
301
+ "--mode",
302
+ choices=["equals", "includes", "regex"],
303
+ default="equals",
304
+ help="Assertion mode (default: equals)",
305
+ )
306
+ p.add_argument("--by-text", action="store_true", help="Assert selected option text instead of value")
307
+ p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
308
+ p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
309
+
264
310
  # -- JavaScript --
265
311
  p = sub.add_parser("eval", help="Execute JavaScript in page context")
266
312
  p.add_argument("code", help="JavaScript code to execute")
@@ -297,6 +343,27 @@ def build_parser() -> argparse.ArgumentParser:
297
343
  p.add_argument("value", help="Option value or text to select")
298
344
  p.add_argument("--text", action="store_true", help="Match by visible text instead of value")
299
345
 
346
+ p = sub.add_parser("set-field", help="Set field value in a generic way")
347
+ p.add_argument("selector", help="CSS selector or element ref")
348
+ p.add_argument("value", help="Field value to set")
349
+ p.add_argument("--no-clear", action="store_true", help="Do not clear before setting value")
350
+ p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
351
+ p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
352
+
353
+ p = sub.add_parser("submit-and-assert", help="Submit and assert URL/selector outcome")
354
+ p.add_argument("selector", nargs="?", default=None, help="Submit target selector (form/button)")
355
+ p.add_argument("--assert-selector", default=None, help="Selector that should appear after submit")
356
+ p.add_argument("--assert-url", default=None, help="URL value/pattern expected after submit")
357
+ p.add_argument(
358
+ "--mode",
359
+ choices=["equals", "includes", "regex"],
360
+ default="includes",
361
+ help="URL assertion mode (default: includes)",
362
+ )
363
+ p.add_argument("--timeout", type=float, default=5, help="Max wait seconds for assertion")
364
+ p.add_argument("-i", "--index", type=int, default=None, help="Nth matching element (0-based)")
365
+ p.add_argument("-t", "--text", default=None, help="Filter by visible text content")
366
+
300
367
  # -- File upload --
301
368
  p = sub.add_parser("upload", help="Upload file(s) to file input")
302
369
  p.add_argument("selector", help="CSS selector for file input")
@@ -322,8 +389,15 @@ def build_parser() -> argparse.ArgumentParser:
322
389
  # -- Server management --
323
390
  sub.add_parser("serve", help="Start bridge server (foreground)")
324
391
  sub.add_parser("ping", help="Check server and extension status")
392
+ sub.add_parser("capabilities", help="Show extension-supported actions")
393
+ p = sub.add_parser("ensure-ready", help="Ensure server + extension are ready (auto-launch Chrome)")
394
+ p.add_argument("--timeout", type=float, default=20, help="Max seconds to wait for extension connection")
395
+ p.add_argument("--no-launch", action="store_true", help="Do not auto-launch Chrome")
325
396
  sub.add_parser("stop", help="Stop bridge server")
326
397
 
398
+ p = sub.add_parser("self-test", help="Run generic skill smoke tests")
399
+ p.add_argument("--timeout", type=float, default=20, help="ensure-ready timeout in seconds")
400
+
327
401
  # -- Batch / Pipe --
328
402
  p = sub.add_parser("pipe", help="Read commands from stdin (one per line, JSONL output)")
329
403
  p.add_argument("--continue-on-error", action="store_true", help="Don't stop on first error")
@@ -422,6 +496,101 @@ def handle_serve(args):
422
496
  os.execvp(sys.executable, [sys.executable, "-m", "browser_ctl.server", "--port", str(DEFAULT_PORT)])
423
497
 
424
498
 
499
+ def handle_ensure_ready(args):
500
+ """Ensure bridge server and extension are connected."""
501
+ result = _client().ensure_ready(timeout=args.timeout, launch_browser=not args.no_launch)
502
+ print(json.dumps(result, ensure_ascii=False))
503
+ if not result.get("success"):
504
+ sys.exit(1)
505
+
506
+
507
+ def handle_capabilities(_args):
508
+ """Query extension capabilities."""
509
+ _client().ensure_server_optimistic()
510
+ result = _client().send_raw("capabilities", {})
511
+ print(json.dumps(result, ensure_ascii=False))
512
+ if not result.get("success"):
513
+ sys.exit(1)
514
+
515
+
516
+ def handle_self_test(args):
517
+ """Run generic, site-agnostic smoke tests."""
518
+ def fail(msg: str):
519
+ print(json.dumps({"success": False, "error": msg}, ensure_ascii=False))
520
+ sys.exit(1)
521
+
522
+ ready = _client().ensure_ready(timeout=args.timeout, launch_browser=True)
523
+ if not ready.get("success"):
524
+ print(json.dumps(ready, ensure_ascii=False))
525
+ sys.exit(1)
526
+
527
+ caps = _client().send_raw("capabilities", {})
528
+ if not caps.get("success"):
529
+ err = str(caps.get("error", ""))
530
+ if "Unknown action: capabilities" in err:
531
+ fail("Connected extension is outdated. Please run `bctl setup` and reload extension from ~/.browser-ctl/extension.")
532
+ fail(f"capabilities check failed: {err}")
533
+
534
+ actions = set(caps.get("data", {}).get("actions", []))
535
+ required = {"assert-url", "assert-field-value", "set-field", "submit-and-assert"}
536
+ missing = sorted(required - actions)
537
+ if missing:
538
+ fail(f"Extension missing required actions: {missing}. Reload extension from ~/.browser-ctl/extension.")
539
+
540
+ html = (
541
+ "<html><body>"
542
+ "<h1 id='title'>Skill Smoke</h1>"
543
+ "<input id='name' />"
544
+ "<button id='save' onclick=\"document.getElementById('out').textContent=document.getElementById('name').value\">Save</button>"
545
+ "<div id='out'></div>"
546
+ "<form id='f' onsubmit=\"event.preventDefault(); location.hash='submitted';\">"
547
+ "<input id='email' />"
548
+ "<button id='submit' type='submit'>Submit</button>"
549
+ "</form>"
550
+ "</body></html>"
551
+ )
552
+ import urllib.parse
553
+ url = "https://example.com"
554
+ status_result = _client().send_raw("status", {})
555
+ if not status_result.get("success"):
556
+ fail(f"self-test failed at status: {status_result.get('error')}")
557
+ tab_id = status_result.get("data", {}).get("id")
558
+ if tab_id is None:
559
+ fail("self-test failed: status returned no tab id")
560
+
561
+ steps = [
562
+ ("navigate", {"url": url}),
563
+ ("assert-url", {"expected": "example.com", "mode": "includes"}),
564
+ ("eval", {"code": f"document.body.innerHTML = {json.dumps(html)}; 'ok'"}),
565
+ ("snapshot", {"interactive": True}),
566
+ ("set-field", {"selector": "#name", "value": "Alice", "clear": True}),
567
+ ("assert-field-value", {"selector": "#name", "expected": "Alice", "mode": "equals"}),
568
+ ("click", {"selector": "#save"}),
569
+ ("text", {"selector": "#out"}),
570
+ ("submit-and-assert", {"selector": "#submit", "assertUrl": "#submitted", "mode": "includes", "timeout": 5}),
571
+ ("assert-url", {"expected": "#submitted", "mode": "includes"}),
572
+ ]
573
+
574
+ for action, params in steps:
575
+ p = dict(params)
576
+ p["tabId"] = tab_id
577
+ result = _client().send_raw(action, p)
578
+ if not result.get("success"):
579
+ fail(f"self-test failed at {action}: {result.get('error')}")
580
+ if action == "text":
581
+ text = str(result.get("data", {}).get("text", "")).strip()
582
+ if text != "Alice":
583
+ fail(f"self-test failed: expected #out text 'Alice', got '{text}'")
584
+
585
+ print(json.dumps({
586
+ "success": True,
587
+ "data": {
588
+ "selfTest": "passed",
589
+ "checks": [s[0] for s in steps],
590
+ },
591
+ }, ensure_ascii=False))
592
+
593
+
425
594
  def _get_extension_source_dir() -> str | None:
426
595
  """Locate the extension source directory.
427
596
 
@@ -593,6 +762,8 @@ _ALIASES = {
593
762
  "dl": "download",
594
763
  "sopt": "select-option",
595
764
  "snap": "snapshot",
765
+ "ready": "ensure-ready",
766
+ "caps": "capabilities",
596
767
  }
597
768
 
598
769
 
@@ -645,6 +816,17 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
645
816
  params = {"selector": args.selector, "index": args.index}
646
817
  elif cmd == "get-value":
647
818
  params = {"selector": args.selector, "index": args.index}
819
+ elif cmd == "assert-url":
820
+ params = {"expected": args.expected, "mode": args.mode}
821
+ elif cmd == "assert-field-value":
822
+ params = {
823
+ "selector": args.selector,
824
+ "expected": args.expected,
825
+ "mode": args.mode,
826
+ "byText": args.by_text,
827
+ "index": args.index,
828
+ "text": args.text,
829
+ }
648
830
  elif cmd == "eval":
649
831
  params = {"code": args.code}
650
832
  elif cmd == "tab":
@@ -657,6 +839,24 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
657
839
  params = {"target": args.target, "amount": args.amount}
658
840
  elif cmd == "select-option":
659
841
  params = {"selector": args.selector, "value": args.value, "byText": args.text}
842
+ elif cmd == "set-field":
843
+ params = {
844
+ "selector": args.selector,
845
+ "value": args.value,
846
+ "clear": not args.no_clear,
847
+ "index": args.index,
848
+ "text": args.text,
849
+ }
850
+ elif cmd == "submit-and-assert":
851
+ params = {
852
+ "selector": args.selector,
853
+ "assertSelector": args.assert_selector,
854
+ "assertUrl": args.assert_url,
855
+ "mode": args.mode,
856
+ "timeout": args.timeout,
857
+ "index": args.index,
858
+ "text": args.text,
859
+ }
660
860
  elif cmd == "upload":
661
861
  files = [os.path.abspath(f) for f in args.files]
662
862
  params = {"selector": args.selector, "files": files}
@@ -670,6 +870,16 @@ def args_to_action_params(cmd: str, args) -> tuple[str, dict]:
670
870
  params = {"seconds": seconds}
671
871
  except ValueError:
672
872
  params = {"selector": args.target, "timeout": args.timeout}
873
+
874
+ # Optional execution context (applies to all server-side actions)
875
+ if getattr(args, "session", None):
876
+ params["sessionId"] = args.session
877
+ if getattr(args, "tab", None) is not None:
878
+ params["tabId"] = args.tab
879
+ if getattr(args, "window", None) is not None:
880
+ params["windowId"] = args.window
881
+ if getattr(args, "world", None):
882
+ params["world"] = args.world
673
883
  return cmd, params
674
884
 
675
885
 
@@ -713,6 +923,15 @@ def main():
713
923
  if cmd == "serve":
714
924
  handle_serve(args)
715
925
  return
926
+ if cmd == "capabilities":
927
+ handle_capabilities(args)
928
+ return
929
+ if cmd == "ensure-ready":
930
+ handle_ensure_ready(args)
931
+ return
932
+ if cmd == "self-test":
933
+ handle_self_test(args)
934
+ return
716
935
  if cmd == "stop":
717
936
  _client().stop_server()
718
937
  return
@@ -9,8 +9,12 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import os
12
+ import platform
13
+ import shutil
12
14
  import socket
15
+ import subprocess
13
16
  import sys
17
+ import time
14
18
 
15
19
  DEFAULT_PORT = 19876
16
20
  _HOST = "127.0.0.1"
@@ -192,3 +196,119 @@ def ensure_server_optimistic() -> None:
192
196
  result = send_raw("ping", {})
193
197
  if not result.get("success") and "Cannot connect" in result.get("error", ""):
194
198
  start_server()
199
+
200
+
201
+ def _launch_chrome() -> tuple[bool, str]:
202
+ """Try to launch Chrome/Chromium and return (started, method)."""
203
+ system = platform.system()
204
+
205
+ if system == "Darwin":
206
+ try:
207
+ subprocess.Popen(
208
+ ["open", "-a", "Google Chrome"],
209
+ stdout=subprocess.DEVNULL,
210
+ stderr=subprocess.DEVNULL,
211
+ )
212
+ return True, "open -a Google Chrome"
213
+ except Exception:
214
+ return False, "open -a Google Chrome"
215
+
216
+ if system == "Windows":
217
+ candidates = [
218
+ "chrome",
219
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
220
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
221
+ ]
222
+ for cmd in candidates:
223
+ try:
224
+ subprocess.Popen(
225
+ [cmd],
226
+ stdout=subprocess.DEVNULL,
227
+ stderr=subprocess.DEVNULL,
228
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
229
+ )
230
+ return True, cmd
231
+ except Exception:
232
+ continue
233
+ return False, "chrome"
234
+
235
+ # Linux / other unix-like: try common Chrome/Chromium binaries
236
+ for cmd in ("google-chrome", "chromium", "chromium-browser"):
237
+ if shutil.which(cmd) is None:
238
+ continue
239
+ try:
240
+ subprocess.Popen(
241
+ [cmd],
242
+ stdout=subprocess.DEVNULL,
243
+ stderr=subprocess.DEVNULL,
244
+ )
245
+ return True, cmd
246
+ except Exception:
247
+ continue
248
+ return False, "google-chrome/chromium"
249
+
250
+
251
+ def ensure_ready(timeout: float = 20.0, launch_browser: bool = True) -> dict:
252
+ """Ensure bridge server + extension are ready.
253
+
254
+ Flow:
255
+ 1) Start server if needed
256
+ 2) Check ping
257
+ 3) If extension disconnected and launch enabled, attempt to launch Chrome
258
+ 4) Poll until timeout for extension connection
259
+ """
260
+ ensure_server_optimistic()
261
+ result = send_raw("ping", {})
262
+ if result.get("success") and result.get("data", {}).get("extension"):
263
+ return {
264
+ "success": True,
265
+ "data": {
266
+ "server": True,
267
+ "extension": True,
268
+ "launchedBrowser": False,
269
+ "waitedSeconds": 0.0,
270
+ },
271
+ }
272
+
273
+ if not launch_browser:
274
+ return result
275
+
276
+ started, method = _launch_chrome()
277
+ if not started:
278
+ return {
279
+ "success": False,
280
+ "error": (
281
+ "Chrome extension not connected and failed to launch browser. "
282
+ f"Tried: {method}. Run `bctl setup` and ensure extension is loaded."
283
+ ),
284
+ }
285
+
286
+ deadline = time.time() + max(timeout, 1.0)
287
+ last = result
288
+ while time.time() < deadline:
289
+ time.sleep(0.5)
290
+ last = send_raw("ping", {})
291
+ if last.get("success") and last.get("data", {}).get("extension"):
292
+ return {
293
+ "success": True,
294
+ "data": {
295
+ "server": True,
296
+ "extension": True,
297
+ "launchedBrowser": True,
298
+ "launchMethod": method,
299
+ "waitedSeconds": round(max(0.0, timeout - max(0.0, deadline - time.time())), 1),
300
+ },
301
+ }
302
+
303
+ return {
304
+ "success": False,
305
+ "error": (
306
+ "Chrome launched but extension is still not connected. "
307
+ "Open chrome://extensions and ensure Browser-Ctl extension is loaded."
308
+ ),
309
+ "data": {
310
+ "launchedBrowser": True,
311
+ "launchMethod": method,
312
+ "lastPing": last,
313
+ },
314
+ }