browserwright 0.6.2__py3-none-any.whl

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 (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1437 @@
1
+ """argparse + subcommand dispatch.
2
+
3
+ Spec §5: Skill talks via subprocess and parses stdout/stderr/exit codes.
4
+ Stdout discipline (every subcommand):
5
+ - Success: one well-defined payload line, no decoration.
6
+ - Failure: stderr gets 1-3 human lines; stdout stays empty.
7
+
8
+ Exit codes (§5.1):
9
+ 0 success
10
+ 1 user error (bad CLI args, unknown backend)
11
+ 2 backend(s) unavailable
12
+ 3 internal / unexpected error
13
+ 6 launch-chrome: Chrome binary not found
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import sys
22
+ from xml.sax.saxutils import escape as _xml_escape
23
+ from typing import NoReturn
24
+
25
+ from . import __version__
26
+ from .backends import names
27
+ from .config import load, Config
28
+ from .errors import ChromeBinaryNotFound, DaemonError, Unavailable, UserError
29
+
30
+
31
+ # ---- entrypoint ------------------------------------------------------------
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ parser = _build_parser()
36
+ args = parser.parse_args(argv)
37
+ handler = _DISPATCH.get(args.cmd)
38
+ if handler is None:
39
+ parser.print_help(sys.stderr)
40
+ return 1
41
+ try:
42
+ cfg = _cfg_from_args(args)
43
+ return handler(args, cfg)
44
+ except UserError as e:
45
+ print(f"error: {e}", file=sys.stderr)
46
+ return 1
47
+ except ChromeBinaryNotFound as e:
48
+ print(f"error: {e}", file=sys.stderr)
49
+ return 6
50
+ except Unavailable as e:
51
+ print(f"error: {e}", file=sys.stderr)
52
+ if e.attempts and getattr(args, "verbose", False):
53
+ for name, why in e.attempts.items():
54
+ print(f" {name}: {why}", file=sys.stderr)
55
+ return 2
56
+ except DaemonError as e:
57
+ # Catch-all daemon-internal failure that isn't UserError or Unavailable.
58
+ print(f"error: {e}", file=sys.stderr)
59
+ return 3
60
+ except KeyboardInterrupt:
61
+ return 130
62
+ except Exception as e: # truly unexpected
63
+ print(f"internal error: {type(e).__name__}: {e}", file=sys.stderr)
64
+ return 3
65
+
66
+
67
+ def _entry() -> NoReturn:
68
+ sys.exit(main())
69
+
70
+
71
+ # ---- parser ----------------------------------------------------------------
72
+
73
+
74
+ def _build_parser() -> argparse.ArgumentParser:
75
+ p = argparse.ArgumentParser(
76
+ prog="browserwright-daemon",
77
+ description=(
78
+ "Resolve a browser-level CDP WebSocket from any local Chrome. "
79
+ "v0.1 is Mode A only (one-shot CLI). Mode B socket arrives in v0.2."
80
+ ),
81
+ )
82
+ sub = p.add_subparsers(dest="cmd", metavar="<subcommand>")
83
+
84
+ # url
85
+ p_url = sub.add_parser("url", help="resolve a CDP ws URL and print it")
86
+ _add_common(p_url)
87
+ _add_port(p_url)
88
+ p_url.add_argument("--json", action="store_true", help="emit a JSON object instead of a bare URL")
89
+ p_url.add_argument("--mode-b-proxy", action="store_true",
90
+ help="instead of upstream ws, output the daemon socket endpoint (v0.2)")
91
+ _add_name(p_url)
92
+
93
+ # serve (v0.2)
94
+ p_serve = sub.add_parser("serve", help="run the long-lived Mode B daemon (v0.2)")
95
+ _add_common(p_serve)
96
+ _add_port(p_serve)
97
+ _add_name(p_serve)
98
+ # v0.5.3 Task #24: extension relay port override. Useful when default
99
+ # 19989 is occupied (e.g., a stale daemon process). playwriter sits on
100
+ # 19988, so the default no longer collides with it.
101
+ # Precedence: this flag > BD_EXTENSION_PORT > toml > 19989 default.
102
+ p_serve.add_argument(
103
+ "--extension-port", type=int, default=None, metavar="N",
104
+ help=("Bind the extension relay ws server on this port instead of "
105
+ "the default 19989. Only relevant to the shared extension relay. "
106
+ "Equivalent to BD_EXTENSION_PORT env or "
107
+ "[backends.extension].port in config.toml."))
108
+ # Playwright facade (Phase C: auto-enabled). Expose a Playwright-facing
109
+ # CDP ws+HTTP endpoint a real `chromium.connect_over_cdp` can connect to.
110
+ # ON by default (port 19990) — the skill layer's heredoc `page`/`context`
111
+ # depend on it. Pass an explicit port to override, or `--facade-port 0` to
112
+ # disable. Equivalent to BD_FACADE_PORT env or `facade_port` in config.toml.
113
+ p_serve.add_argument(
114
+ "--facade-port", type=int, default=None, metavar="N",
115
+ help=("bind the Playwright-facing CDP facade on this port "
116
+ "(default: ON at 19990; pass 0 to disable). Lets a real "
117
+ "Playwright client `connect_over_cdp(ws://127.0.0.1:N/cdp)` and "
118
+ "the skill heredoc `page`/`context` drive the resolved Chrome."))
119
+
120
+ # stop (v0.2)
121
+ p_stop = sub.add_parser("stop", help="stop the running daemon")
122
+ _add_name(p_stop)
123
+ p_stop.add_argument("--timeout", type=float, default=5.0,
124
+ help="seconds to wait for graceful shutdown before SIGKILL")
125
+
126
+ p_restart = sub.add_parser(
127
+ "restart",
128
+ help="restart the installed LaunchAgent daemon after an upgrade")
129
+ _add_name(p_restart)
130
+ p_restart.add_argument("--timeout", type=float, default=5.0,
131
+ help="seconds to wait for graceful unload/load")
132
+
133
+ # status (v0.2)
134
+ p_status = sub.add_parser("status", help="report the daemon's IPC endpoint + liveness")
135
+ _add_name(p_status)
136
+ p_status.add_argument("--json", action="store_true")
137
+
138
+ # disconnect (v0.2 §6.6)
139
+ p_disc = sub.add_parser("disconnect",
140
+ help="ask the running daemon to close its upstream ws (banner goes away)")
141
+ _add_name(p_disc)
142
+ p_disc.add_argument("--session", default=os.environ.get("BD_SESSION"),
143
+ help="browserwright session id (defaults to BD_SESSION)")
144
+ p_disc.add_argument("--reason", default="skill_disconnect",
145
+ help="reason string surfaced in upstreamClosed event")
146
+
147
+ # logs (v0.2)
148
+ p_logs = sub.add_parser("logs", help="print the daemon log file path or tail it")
149
+ _add_name(p_logs)
150
+ p_logs.add_argument("--follow", "-f", action="store_true", help="tail -f the log")
151
+
152
+ # doctor
153
+ p_doc = sub.add_parser("doctor", help="probe all backends (zero ws side effects by default)")
154
+ _add_common(p_doc)
155
+ p_doc.add_argument("--probe-ws", action="store_true",
156
+ help="opt-in: actually open a ws on each available backend (NOT IMPLEMENTED in v0.1)")
157
+ p_doc.add_argument("--json", action="store_true", help="emit JSON instead of pretty text")
158
+
159
+ # list-backends
160
+ p_lb = sub.add_parser("list-backends", help="enumerate backends statically (no probe)")
161
+ _add_common(p_lb)
162
+ p_lb.add_argument("--json", action="store_true")
163
+
164
+ # active-tab
165
+ p_at = sub.add_parser("active-tab", help="best-guess user-active tab (heuristic, opens ws)")
166
+ _add_common(p_at)
167
+ _add_port(p_at)
168
+ p_at.add_argument("--session", default=os.environ.get("BD_SESSION"),
169
+ help="browserwright session id (defaults to BD_SESSION)")
170
+ p_at.add_argument("--json", action="store_true")
171
+
172
+ # backend-info — what backend is the running daemon serving?
173
+ # Mode B Skill clients shell out to this to decide whether the current
174
+ # daemon matches their expected backend (refused-mismatch guard) and to
175
+ # branch primitives on backend-specific quirks (e.g. extension's "0
176
+ # attached tabs is actionable, not empty Chrome").
177
+ p_bi = sub.add_parser(
178
+ "backend-info",
179
+ help="report the running daemon's backend identity (Mode B identity probe)")
180
+ _add_name(p_bi)
181
+ p_bi.add_argument("--session", default=os.environ.get("BD_SESSION"),
182
+ help="browserwright session id (defaults to BD_SESSION)")
183
+ p_bi.add_argument("--json", action="store_true")
184
+
185
+ # attach-active (v0.5.4 — extension backend only)
186
+ p_aa = sub.add_parser(
187
+ "attach-active",
188
+ help="(extension backend) attach the focused-window active tab without a popup click")
189
+ _add_name(p_aa)
190
+ p_aa.add_argument("--session", default=os.environ.get("BD_SESSION"),
191
+ help="browserwright session id (defaults to BD_SESSION)")
192
+ p_aa.add_argument("--json", action="store_true")
193
+
194
+ # launch-chrome
195
+ p_lc = sub.add_parser("launch-chrome", help="launch a detached isolated-profile Chrome and print its ws URL")
196
+ p_lc.add_argument("--profile", default="isolated")
197
+ g = p_lc.add_mutually_exclusive_group()
198
+ g.add_argument("--persistent", action="store_true", default=True,
199
+ help="reuse a profile dir across launches (default)")
200
+ g.add_argument("--tmp", action="store_true", default=False,
201
+ help="allocate a fresh tmpdir per launch (caller cleans it up)")
202
+ p_lc.add_argument("--chrome-binary", help="absolute path to chrome / chromium / msedge / brave")
203
+ p_lc.add_argument("--port", type=int, default=None,
204
+ help="--remote-debugging-port=N; default 0 (OS-picked)")
205
+ # v0.5.3 F-18: `--detach` flag was reserved-in-v0.1 placeholder for a
206
+ # non-detach mode we never shipped + never needed (Chrome always
207
+ # detaches via `_spawn_kwargs()`). Removed to declutter the help text;
208
+ # if a future need arises, re-add with real behavior.
209
+ p_lc.add_argument("--timeout", type=float, default=30.0)
210
+ p_lc.add_argument("--json", action="store_true")
211
+ p_lc.add_argument("-v", "--verbose", action="store_true")
212
+ # v0.5 Task #11 expert escape: bypass the "refuse user-default profile"
213
+ # guard. STRONGLY discouraged — see chrome-popup-accumulation-bug memory.
214
+ p_lc.add_argument(
215
+ "--allow-default-profile", action="store_true", default=False,
216
+ help=(
217
+ "EXPERT ESCAPE HATCH: allow launch-chrome to target the user's "
218
+ "default Chrome profile. Doing so permanently taints the daily "
219
+ "Chrome with --remote-debugging-port; every ws upgrade triggers "
220
+ "a Chrome 'Allow remote debugging?' popup. Equivalent to env "
221
+ "BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1."))
222
+
223
+ # version
224
+ p_ver = sub.add_parser("version", help="print the installed version and exit")
225
+ p_ver.add_argument("--json", action="store_true")
226
+ p_ver.add_argument("action", nargs="?", choices=["check"])
227
+
228
+ # stats (v0.5 observability)
229
+ p_stats = sub.add_parser("stats", help="dump in-process metrics counters")
230
+ _add_name(p_stats)
231
+ p_stats.add_argument("--json", action="store_true",
232
+ help="emit as JSON (default: tab-separated)")
233
+
234
+ # open-background (Phase B — extension backend only)
235
+ p_ob = sub.add_parser(
236
+ "open-background",
237
+ help=("open a Chrome tab in the background (group=Agent by default), "
238
+ "attach chrome.debugger, print {sessionId,targetId,tabId,url,title,groupId}"),
239
+ )
240
+ _add_name(p_ob)
241
+ p_ob.add_argument("--url", required=True, help="URL to open in the background tab")
242
+ p_ob.add_argument("--group", default="Agent",
243
+ help="Chrome tab-group title to place the new tab in (default: Agent)")
244
+ p_ob.add_argument("--session", default=os.environ.get("BD_SESSION"),
245
+ help="browserwright session id (defaults to BD_SESSION)")
246
+ # Output is always JSON (spec §5.1 single-line discipline); no --json flag.
247
+
248
+ # close-tab (Phase B — extension backend only)
249
+ p_ct = sub.add_parser(
250
+ "close-tab",
251
+ help="close a tab by sessionId (persistent ws) or targetId (CLI)",
252
+ )
253
+ _add_name(p_ct)
254
+ p_ct.add_argument("--session", default=os.environ.get("BD_SESSION"),
255
+ help="browserwright session id (defaults to BD_SESSION)")
256
+ p_ct.add_argument("--session-id", default=None,
257
+ help="local sessionId from a persistent ws (Skill REPL)")
258
+ p_ct.add_argument("--target-id", default=None,
259
+ help="globally-addressable targetId (e.g. ext-tab-42); "
260
+ "use this from one-shot CLI calls since transient "
261
+ "ws can't share per-client session state")
262
+ # Output is always JSON (spec §5.1 single-line discipline); no --json flag.
263
+
264
+ # end-session (P5 — extension backend only)
265
+ p_es = sub.add_parser(
266
+ "end-session",
267
+ help="tear down a browserwright session's tabs: close owned, keep borrowed",
268
+ )
269
+ _add_name(p_es)
270
+ p_es.add_argument("--session", required=True,
271
+ help="the browserwright session id whose tabs to clean up")
272
+ p_es.add_argument("--group-id", default=None, type=int,
273
+ help="durable numeric tab-group id for session teardown: "
274
+ "when the daemon lost this session's in-memory "
275
+ "binding (reconnect / restart), close the tabs in "
276
+ "this group instead (names aren't unique, so the id "
277
+ "is the key)")
278
+ # Output is always JSON (single-line discipline); no --json flag.
279
+
280
+ # kill-executor (Phase B) — reap ONLY a session's resident executor, no
281
+ # browser teardown. Used by `session end` to avoid leaking an attach
282
+ # session's executor (the full end-session path is create-only).
283
+ p_ke = sub.add_parser(
284
+ "kill-executor",
285
+ help="reap a session's persistent executor subprocess (no browser teardown)",
286
+ )
287
+ _add_name(p_ke)
288
+ p_ke.add_argument("--session", required=True,
289
+ help="the browserwright session id whose executor to reap")
290
+
291
+ # userscript — resident extension userscripts
292
+ p_us = sub.add_parser("userscript", help="manage resident extension userscripts")
293
+ _add_name(p_us)
294
+ p_us.add_argument("--session", default=os.environ.get("BD_SESSION"),
295
+ help="browserwright session id (defaults to BD_SESSION)")
296
+ us_sub = p_us.add_subparsers(dest="userscript_cmd", metavar="<action>")
297
+ p_us_push = us_sub.add_parser("push", help="install or update a .user.js file")
298
+ p_us_push.add_argument("file", help=".user.js path, or - for stdin")
299
+ p_us_install = us_sub.add_parser("install", help="alias for push")
300
+ p_us_install.add_argument("file", help=".user.js path, or - for stdin")
301
+ p_us_list = us_sub.add_parser("list", help="list resident userscripts")
302
+ p_us_list.add_argument("--site", default=None, help="filter by matching site URL")
303
+ p_us_remove = us_sub.add_parser("remove", help="remove by identity or id")
304
+ p_us_remove.add_argument("key", help="identity or id")
305
+ p_us_toggle = us_sub.add_parser("toggle", help="enable or disable by identity or id")
306
+ p_us_toggle.add_argument("key", help="identity or id")
307
+ p_us_toggle.add_argument("--enabled", required=True, help="true or false")
308
+ p_us_logs = us_sub.add_parser("logs", help="print userscript injection logs")
309
+ p_us_logs.add_argument("--id", default=None, help="filter by userscript id")
310
+ p_us_logs.add_argument("--limit", type=int, default=50, help="max log rows")
311
+
312
+ # install / uninstall / list — long-running service (macOS LaunchAgent).
313
+ # The daemon was designed as a one-shot `serve` subprocess, but for the
314
+ # "zero manual ops after install" extension flow it needs to be a
315
+ # supervised background service: starts at login, restarts on crash,
316
+ # and is reachable on the same socket across reboots.
317
+ p_inst = sub.add_parser(
318
+ "install",
319
+ help=("register the single global daemon as a macOS LaunchAgent "
320
+ "(auto-start + KeepAlive)"))
321
+ _add_name(p_inst)
322
+ p_inst.add_argument("--backend", choices=names(), default=None,
323
+ help=argparse.SUPPRESS)
324
+ p_inst.add_argument("--extension-port", type=int, default=None, metavar="N",
325
+ help="override the relay ws port (default 19989)")
326
+ p_inst.add_argument("--force", action="store_true",
327
+ help="replace an existing LaunchAgent with the same name")
328
+
329
+ p_uninst = sub.add_parser(
330
+ "uninstall",
331
+ help="remove the LaunchAgent (stops auto-start)")
332
+ _add_name(p_uninst)
333
+
334
+ p_ls = sub.add_parser(
335
+ "list",
336
+ help="enumerate installed LaunchAgents + running daemon instances")
337
+ p_ls.add_argument("--json", action="store_true",
338
+ help="emit JSON instead of pretty text")
339
+
340
+ return p
341
+
342
+
343
+ def _add_common(sp: argparse.ArgumentParser) -> None:
344
+ sp.add_argument("--backend", choices=names(),
345
+ help=("pin backend for this command; for `serve`, this only "
346
+ "chooses the shared upstream while session backends "
347
+ "still route per session"))
348
+ sp.add_argument("--timeout", type=float, default=None,
349
+ help="per-backend timeout in seconds (default 5)")
350
+ sp.add_argument("--config", help="optional toml config path; otherwise reads BD_CONFIG")
351
+ sp.add_argument("-v", "--verbose", action="store_true")
352
+
353
+
354
+ def _add_port(sp: argparse.ArgumentParser) -> None:
355
+ sp.add_argument("--port", type=int, default=None,
356
+ help="rdp backend port (default 9222 / config-backends.rdp.port)")
357
+
358
+
359
+ def _add_name(sp: argparse.ArgumentParser) -> None:
360
+ """No-op. The `--name` / BD_NAME daemon-instance concept was removed: there
361
+ is exactly one global daemon on a fixed socket (docs/refactor-single-daemon.md).
362
+ Kept as a no-op so the (many) call sites need not all be deleted at once;
363
+ they carry no flag now."""
364
+ return None
365
+
366
+
367
+ # ---- shared config building ------------------------------------------------
368
+
369
+
370
+ def _cfg_from_args(args) -> Config:
371
+ return load(
372
+ cli_backend=getattr(args, "backend", None),
373
+ cli_timeout=getattr(args, "timeout", None),
374
+ cli_port=getattr(args, "port", None),
375
+ cli_chrome_binary=getattr(args, "chrome_binary", None),
376
+ cli_config_path=getattr(args, "config", None),
377
+ # v0.5.3 Task #24: serve-only flag; argparse Namespace shape varies
378
+ # per subcommand, so getattr-with-default keeps non-serve calls clean.
379
+ cli_extension_port=getattr(args, "extension_port", None),
380
+ cli_facade_port=getattr(args, "facade_port", None),
381
+ )
382
+
383
+
384
+ def _run(coro):
385
+ return asyncio.run(coro)
386
+
387
+
388
+ # ---- subcommand handlers ---------------------------------------------------
389
+
390
+
391
+ def _cmd_url(args, cfg: Config) -> int:
392
+ # --mode-b-proxy → output the daemon socket endpoint, not an upstream URL.
393
+ # (Spec §6.1: bare socket path on POSIX, host:port + token on Windows.)
394
+ if getattr(args, "mode_b_proxy", False):
395
+ from . import _ipc
396
+ ep = _ipc.endpoint_describe()
397
+ if args.json:
398
+ print(json.dumps(ep, sort_keys=True))
399
+ else:
400
+ if ep["transport"] == "unix":
401
+ print(ep["path"])
402
+ else:
403
+ # `host:port token=...` so a one-liner shell consumer can split.
404
+ token = ep["token"] or ""
405
+ if ep["port"] is None:
406
+ # Daemon not running. Exit 2 so Skill can react.
407
+ print("error: no daemon running (no port file)", file=sys.stderr)
408
+ return 2
409
+ print(f"{ep['host']}:{ep['port']} token={token}")
410
+ return 0
411
+
412
+ from .resolver import resolve
413
+
414
+ rr = _run(resolve(cfg))
415
+ if args.json:
416
+ print(json.dumps({
417
+ "schema_version": 1,
418
+ "ws_url": rr.ws_url,
419
+ "backend": rr.backend,
420
+ "extras": rr.extras,
421
+ }, sort_keys=True))
422
+ else:
423
+ # Bare URL — spec §5.1 stdout discipline: ONE line, no decoration.
424
+ print(rr.ws_url)
425
+ return 0
426
+
427
+
428
+ def _cmd_serve(args, cfg: Config) -> int:
429
+ """Run the long-lived Mode B daemon (§5 v0.2).
430
+
431
+ Phase 2 (docs/refactor-single-daemon.md): there is exactly ONE global
432
+ daemon and it serves BOTH backends simultaneously, routing per session. So
433
+ `serve` no longer requires an explicit backend — a missing backend defaults
434
+ to `extension`, which becomes the daemon's shared (real-browser) upstream
435
+ with the always-on relay. rdp sessions get their own per-session upstream
436
+ on top, dispatched by the ledger's immutable per-session backend. The old
437
+ "fail loud on missing backend (to avoid a silent rdp fallback)" guard is
438
+ gone: there is no single-backend lifetime to protect anymore.
439
+ """
440
+ from .server.listener import run_serve
441
+ return _run(run_serve(cfg))
442
+
443
+
444
+ def _cmd_stop(args, cfg: Config) -> int:
445
+ """Send SIGTERM to a running daemon, wait briefly, fall back to SIGKILL.
446
+
447
+ We do NOT trust the pid file alone — we ping first to verify it's our
448
+ daemon, then signal that pid. (Mirrors browser-harness `_ipc.identify`.)
449
+
450
+ PID-reuse guard: between the ping and the kill the daemon could die and the
451
+ OS recycle its pid for an unrelated process. We fingerprint the pid's
452
+ process start-time and re-verify it just before each signal; if it changed,
453
+ the pid was reused and we refuse to signal it. When the platform can't
454
+ report a start-time (``proc_start_time`` → None), we degrade to the old
455
+ behaviour rather than block the stop.
456
+ """
457
+ from . import _ipc
458
+ from . import platforms
459
+ import signal, time
460
+
461
+ pid = _ipc.ping_sync(timeout=1.0)
462
+ if pid is None:
463
+ # No live daemon. Still clean up stale files so the next `serve` can
464
+ # bind freshly without manual intervention.
465
+ _ipc.cleanup_endpoint()
466
+ print("no live daemon; cleaned up stale files", file=sys.stderr)
467
+ return 0
468
+
469
+ start0 = platforms.proc_start_time(pid)
470
+
471
+ def _same_process() -> bool:
472
+ """True if pid still names the daemon we pinged. Unknowable (None
473
+ start-time) counts as True so the guard never blocks a legit stop."""
474
+ if start0 is None:
475
+ return True
476
+ return platforms.proc_start_time(pid) == start0
477
+
478
+ if not _same_process():
479
+ _ipc.cleanup_endpoint()
480
+ print(f"daemon pid {pid} was recycled by another process; not "
481
+ f"signalling it", file=sys.stderr)
482
+ return 0
483
+ try:
484
+ os.kill(pid, signal.SIGTERM)
485
+ except ProcessLookupError:
486
+ _ipc.cleanup_endpoint()
487
+ return 0
488
+ # Wait for the daemon to exit gracefully.
489
+ deadline = time.monotonic() + args.timeout
490
+ while time.monotonic() < deadline:
491
+ if _ipc.ping_sync(timeout=0.3) is None:
492
+ return 0
493
+ time.sleep(0.1)
494
+ # Still alive — force, but only if the pid is still the same process.
495
+ if _same_process():
496
+ try:
497
+ os.kill(pid, signal.SIGKILL)
498
+ except ProcessLookupError:
499
+ pass
500
+ else:
501
+ print(f"daemon pid {pid} was recycled before SIGKILL; not signalling "
502
+ f"it", file=sys.stderr)
503
+ _ipc.cleanup_endpoint()
504
+ return 0
505
+
506
+
507
+ def _cmd_backend_info(args, cfg: Config) -> int:
508
+ """Probe the running daemon for its backend identity. Same shape as
509
+ `BrowserwrightDaemon.getBackendInfo`'s ws response so the mode_b_client
510
+ subprocess shim can parse it directly."""
511
+ import asyncio
512
+ return asyncio.run(_run_backend_info(args, cfg))
513
+
514
+
515
+ async def _run_backend_info(args, cfg: Config) -> int:
516
+ from . import _ipc
517
+ pid = await _ipc.ping_async(timeout=1.0)
518
+ if pid is None:
519
+ if args.json:
520
+ print(json.dumps({"running": False}, sort_keys=True))
521
+ else:
522
+ print("daemon not running", file=sys.stderr)
523
+ return 2
524
+ try:
525
+ params = {"bsSession": args.session} if args.session else {}
526
+ info = await _rpc_via_ws(
527
+ cfg, "BrowserwrightDaemon.getBackendInfo", params,
528
+ client_label="cli-backend-info", timeout=5.0,
529
+ browser_session=args.session,
530
+ )
531
+ except (Unavailable, DaemonError) as e:
532
+ print(f"error: {e}", file=sys.stderr)
533
+ return 3
534
+ # Surface as `backend` (alias of `name`) for callers like
535
+ # ModeBClient.get_backend_info that read either key.
536
+ payload = {
537
+ "running": True,
538
+ "name": info.get("name"),
539
+ "backend": info.get("name"),
540
+ "kind": info.get("kind"),
541
+ "schema_version": info.get("schema_version", 1),
542
+ }
543
+ print(json.dumps(payload, sort_keys=True))
544
+ return 0
545
+
546
+
547
+ def _cmd_stats(args, cfg: Config) -> int:
548
+ """v0.5: query the running daemon's in-process metrics via the
549
+ `BrowserwrightDaemon.stats` CDP-namespace method, print to stdout.
550
+
551
+ Connects to the daemon's unix socket as a normal client. Exits with
552
+ code 2 if the daemon isn't running (matching `status`).
553
+ """
554
+ import asyncio
555
+ return asyncio.run(_run_stats(args, cfg))
556
+
557
+
558
+ async def _run_stats(args, cfg: Config) -> int:
559
+ from . import _ipc
560
+ # We're inside `asyncio.run()` already (via _cmd_stats). Nested
561
+ # asyncio.run raises RuntimeError, so use the async ping directly —
562
+ # ping_sync's fallback returns None and we'd misreport "not running"
563
+ # even when a daemon was listening.
564
+ pid = await _ipc.ping_async(timeout=1.0)
565
+ if pid is None:
566
+ print("daemon not running", file=sys.stderr)
567
+ return 2
568
+
569
+ import websockets
570
+ sock_path = _ipc.sock_path()
571
+ if _ipc.IS_WINDOWS:
572
+ ep = _ipc.endpoint_describe()
573
+ ws_url = f"ws://127.0.0.1:{ep['port']}/?client=stats-cli&token={ep['token']}"
574
+ conn = await websockets.connect(ws_url, compression=None)
575
+ else:
576
+ conn = await websockets.unix_connect(
577
+ str(sock_path),
578
+ uri="ws://localhost/?client=stats-cli",
579
+ compression=None,
580
+ )
581
+ try:
582
+ await conn.send(json.dumps({"id": 1, "method": "BrowserwrightDaemon.stats"}))
583
+ # Drain until we see id=1.
584
+ for _ in range(20):
585
+ raw = await asyncio.wait_for(conn.recv(), timeout=3.0)
586
+ msg = json.loads(raw)
587
+ if msg.get("id") == 1 and "result" in msg:
588
+ snap = msg["result"]
589
+ if args.json:
590
+ print(json.dumps(snap, sort_keys=True))
591
+ else:
592
+ # Tab-separated key=value, one per line.
593
+ for k in sorted(snap.keys()):
594
+ print(f"{k}\t{snap[k]}")
595
+ return 0
596
+ print("daemon did not respond to BrowserwrightDaemon.stats", file=sys.stderr)
597
+ return 3
598
+ finally:
599
+ try:
600
+ await conn.close()
601
+ except Exception:
602
+ pass
603
+
604
+
605
+ def _cmd_status(args, cfg: Config) -> int:
606
+ """Report endpoint + liveness. JSON shape used by Skill for status pings."""
607
+ from . import _ipc
608
+ pid, version = _ipc.ping_status_sync(timeout=1.0)
609
+ ep = _ipc.endpoint_describe()
610
+ facade_ws, facade_port = _ipc.read_facade_file()
611
+ status = {
612
+ "schema_version": 1,
613
+ "alive": pid is not None,
614
+ "pid": pid,
615
+ "version": version,
616
+ "endpoint": ep,
617
+ # Playwright facade discovery (Phase C). None when the facade is
618
+ # disabled or the daemon predates auto-enable. The skill layer reads
619
+ # this to `connect_over_cdp` the heredoc `page`/`context`.
620
+ "facade": (
621
+ {"ws": facade_ws, "port": facade_port}
622
+ if (pid is not None and facade_ws) else None
623
+ ),
624
+ }
625
+ if args.json:
626
+ print(json.dumps(status, sort_keys=True))
627
+ else:
628
+ if pid is None:
629
+ print("daemon not running")
630
+ else:
631
+ print(f"daemon alive (pid {pid})")
632
+ if ep["transport"] == "unix":
633
+ print(f" socket: {ep['path']}")
634
+ else:
635
+ print(f" tcp: 127.0.0.1:{ep['port']} token={ep['token']}")
636
+ if facade_ws:
637
+ print(f" facade: {facade_ws}")
638
+ return 0 if pid is not None else 2
639
+
640
+
641
+ def _cmd_disconnect(args, cfg: Config) -> int:
642
+ """Open a transient ws to the daemon, fire BrowserwrightDaemon.disconnect, exit.
643
+
644
+ Equivalent to the RPC over an established connection — Skill can use either.
645
+ """
646
+ if not args.session:
647
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
648
+ return 2
649
+ return _run(_disconnect_via_ws(cfg, args.reason, args.session))
650
+
651
+
652
+ async def _disconnect_via_ws(cfg: Config, reason: str, session: str) -> int:
653
+ """Lightweight ws client that says BrowserwrightDaemon.disconnect and reads the
654
+ ack. We bypass cdp-use intentionally — we don't need framing, just one
655
+ request + one response."""
656
+ import websockets
657
+ from . import _ipc
658
+ from urllib.parse import quote
659
+
660
+ session_q = f"&session={quote(str(session), safe='')}"
661
+
662
+ if _ipc.IS_WINDOWS:
663
+ port, token = _ipc.read_port_file()
664
+ if port is None:
665
+ print("no daemon running", file=sys.stderr)
666
+ return 2
667
+ url = f"ws://127.0.0.1:{port}/?token={token}&client=cli-disconnect{session_q}"
668
+ try:
669
+ async with websockets.connect(url, compression=None) as ws:
670
+ await ws.send(json.dumps({
671
+ "id": 1, "method": "BrowserwrightDaemon.disconnect",
672
+ "params": {"reason": reason, "bsSession": session},
673
+ }))
674
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
675
+ except Exception as e:
676
+ print(f"disconnect failed: {e}", file=sys.stderr)
677
+ return 2
678
+ else:
679
+ path = _ipc.sock_path()
680
+ if not path.exists():
681
+ print("no daemon running", file=sys.stderr)
682
+ return 2
683
+ # `ws+unix:` URL scheme + path: websockets accepts unix= kwarg.
684
+ try:
685
+ async with websockets.unix_connect(
686
+ str(path), uri=f"ws://localhost/?client=cli-disconnect{session_q}",
687
+ compression=None,
688
+ ) as ws:
689
+ await ws.send(json.dumps({
690
+ "id": 1, "method": "BrowserwrightDaemon.disconnect",
691
+ "params": {"reason": reason, "bsSession": session},
692
+ }))
693
+ await asyncio.wait_for(ws.recv(), timeout=2.0)
694
+ except Exception as e:
695
+ print(f"disconnect failed: {e}", file=sys.stderr)
696
+ return 2
697
+ return 0
698
+
699
+
700
+ def _cmd_attach_active(args, cfg: Config) -> int:
701
+ """Ask the running daemon (extension backend) to attach the
702
+ currently-focused-window active tab. Prints the result as JSON or as
703
+ `targetId<TAB>url<TAB>title`. Exits 1 if the daemon errored.
704
+ """
705
+ if not args.session:
706
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
707
+ return 2
708
+ return _run(_attach_active_via_ws(cfg, args))
709
+
710
+
711
+ async def _attach_active_via_ws(cfg: Config, args) -> int:
712
+ import websockets
713
+ from . import _ipc
714
+ from urllib.parse import quote
715
+
716
+ session = getattr(args, "session", None)
717
+ session_q = f"&session={quote(str(session), safe='')}" if session else ""
718
+
719
+ if _ipc.IS_WINDOWS:
720
+ port, token = _ipc.read_port_file()
721
+ if port is None:
722
+ print("no daemon running", file=sys.stderr)
723
+ return 2
724
+ url = f"ws://127.0.0.1:{port}/?token={token}&client=cli-attach-active{session_q}"
725
+ try:
726
+ async with websockets.connect(url, compression=None) as ws:
727
+ return await _attach_active_roundtrip(ws, args)
728
+ except Exception as e:
729
+ print(f"attach-active failed: {e}", file=sys.stderr)
730
+ return 1
731
+ path = _ipc.sock_path()
732
+ if not path.exists():
733
+ print("no daemon running", file=sys.stderr)
734
+ return 2
735
+ try:
736
+ async with websockets.unix_connect(
737
+ str(path), uri=f"ws://localhost/?client=cli-attach-active{session_q}",
738
+ compression=None) as ws:
739
+ return await _attach_active_roundtrip(ws, args)
740
+ except Exception as e:
741
+ print(f"attach-active failed: {e}", file=sys.stderr)
742
+ return 1
743
+
744
+
745
+ async def _attach_active_roundtrip(ws, args) -> int:
746
+ params = {}
747
+ if getattr(args, "session", None):
748
+ params["bsSession"] = args.session
749
+ await ws.send(json.dumps({
750
+ "id": 1, "method": "BrowserwrightDaemon.attachActiveTab",
751
+ "params": params,
752
+ }))
753
+ # Drain until we see id=1 — lifecycle events (upstreamConnecting,
754
+ # upstreamReady) can arrive ahead of the response.
755
+ for _ in range(20):
756
+ raw = await asyncio.wait_for(ws.recv(), timeout=15.0)
757
+ msg = json.loads(raw)
758
+ if msg.get("id") != 1:
759
+ continue
760
+ if "error" in msg:
761
+ err = msg["error"] or {}
762
+ print(f"attach-active error: {err.get('message', str(err))}",
763
+ file=sys.stderr)
764
+ return 1
765
+ result = msg.get("result") or {}
766
+ if args.json:
767
+ print(json.dumps(result, sort_keys=True))
768
+ else:
769
+ print(f"{result.get('targetId')}\t{result.get('url', '')}\t"
770
+ f"{result.get('title', '')}")
771
+ return 0
772
+ print("daemon did not respond to BrowserwrightDaemon.attachActiveTab",
773
+ file=sys.stderr)
774
+ return 1
775
+
776
+
777
+ def _cmd_logs(args, cfg: Config) -> int:
778
+ """Print log file path, or tail -f it."""
779
+ from . import _ipc
780
+ log = _ipc.log_path()
781
+ if not args.follow:
782
+ print(log)
783
+ return 0
784
+ if not log.exists():
785
+ print(f"no log file at {log}", file=sys.stderr)
786
+ return 2
787
+ # tail -f — best to just exec tail rather than reimplement.
788
+ os.execvp("tail", ["tail", "-n", "+0", "-f", str(log)])
789
+ return 0 # unreachable
790
+
791
+
792
+ def _cmd_doctor(args, cfg: Config) -> int:
793
+ from .doctor import doctor
794
+
795
+ out = _run(doctor(cfg, backend=getattr(args, "backend", None),
796
+ probe_ws=getattr(args, "probe_ws", False)))
797
+ if args.json:
798
+ print(json.dumps(out, sort_keys=True))
799
+ else:
800
+ _pretty_doctor(out)
801
+ return 0
802
+
803
+
804
+ def _cmd_list_backends(args, cfg: Config) -> int:
805
+ from .doctor import list_backends
806
+
807
+ out = _run(list_backends(cfg))
808
+ if args.json:
809
+ print(json.dumps(out, sort_keys=True))
810
+ else:
811
+ for entry in out["backends"]:
812
+ print(f"{entry['name']:<12} kind={entry['kind']:<12} "
813
+ f"recommended_mode={entry['recommended_mode']} "
814
+ f"ux_cost={entry['ux_cost']}")
815
+ return 0
816
+
817
+
818
+ def _cmd_active_tab(args, cfg: Config) -> int:
819
+ if not args.session:
820
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
821
+ return 2
822
+ try:
823
+ info = _run(_rpc_via_ws(
824
+ cfg,
825
+ "BrowserwrightDaemon.getActiveTab",
826
+ {"bsSession": args.session},
827
+ client_label="cli-active-tab",
828
+ timeout=8.0,
829
+ browser_session=args.session,
830
+ ))
831
+ except Unavailable as e:
832
+ print(f"error: {e}", file=sys.stderr)
833
+ return 2
834
+ except DaemonError as e:
835
+ print(f"error: {e}", file=sys.stderr)
836
+ return 3
837
+ if not info or not info.get("targetId"):
838
+ if args.json:
839
+ print(json.dumps({
840
+ "schema_version": 1,
841
+ "targetId": None,
842
+ "accuracy": "unknown",
843
+ "since_seconds": None,
844
+ }, sort_keys=True))
845
+ else:
846
+ print("") # spec §5.4: empty line + exit 2 when no active tab
847
+ return 2
848
+ if args.json:
849
+ print(json.dumps({"schema_version": 1, **info}, sort_keys=True))
850
+ else:
851
+ # tab-separated: targetId\turl\ttitle\taccuracy
852
+ print(f"{info['targetId']}\t{info['url']}\t{info['title']}\t{info['accuracy']}")
853
+ return 0
854
+
855
+
856
+ def _cmd_launch_chrome(args, cfg: Config) -> int:
857
+ from .launch_chrome import launch_chrome
858
+
859
+ out = _run(launch_chrome(
860
+ cfg,
861
+ profile=args.profile,
862
+ persistent=not args.tmp, # --tmp wins over --persistent default
863
+ chrome_binary=args.chrome_binary,
864
+ port=args.port,
865
+ timeout=args.timeout,
866
+ allow_default_profile=args.allow_default_profile,
867
+ ))
868
+ if args.json:
869
+ print(json.dumps(out, sort_keys=True))
870
+ else:
871
+ # Bare URL, matching `url` discipline.
872
+ print(out["ws_url"])
873
+ return 0
874
+
875
+
876
+ def _cmd_version(args, cfg: Config) -> int:
877
+ from browserwright.version import version_info
878
+
879
+ info = version_info()
880
+ if getattr(args, "action", None) == "check":
881
+ if args.json:
882
+ print(json.dumps(info, sort_keys=True))
883
+ elif info["ok"]:
884
+ print(f"browserwright-daemon {__version__} (versions ok)")
885
+ else:
886
+ for issue in info["issues"]:
887
+ print(f"{issue['code']}: {issue['message']}", file=sys.stderr)
888
+ return 0 if info["ok"] else 1
889
+ if getattr(args, "json", False):
890
+ print(json.dumps(info, sort_keys=True))
891
+ return 0
892
+ print(f"browserwright-daemon {__version__}")
893
+ return 0
894
+
895
+
896
+ async def _rpc_via_ws(cfg: Config, method: str, params: dict,
897
+ *, client_label: str, timeout: float = 10.0,
898
+ browser_session: str | None = None) -> dict:
899
+ """Open a transient ws to the running daemon, send one BrowserwrightDaemon.*
900
+ RPC, read the response, close. Mirrors `_disconnect_via_ws` but returns
901
+ the parsed result (or raises with the daemon's error message).
902
+
903
+ Used by `open-background` and `close-tab` subcommands.
904
+ """
905
+ import websockets
906
+ from . import _ipc
907
+ from urllib.parse import quote
908
+
909
+ session_q = (
910
+ f"&session={quote(str(browser_session), safe='')}"
911
+ if browser_session else "")
912
+
913
+ async def _drain_until_response(ws) -> dict:
914
+ # Lifecycle events (upstreamConnecting/Ready) can arrive ahead of the
915
+ # actual id=1 response, especially when lazy-open is triggered by the
916
+ # RPC. Drain frames until we see ours. Mirrors `_attach_active_roundtrip`.
917
+ for _ in range(20):
918
+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
919
+ msg = json.loads(raw)
920
+ if msg.get("id") == 1:
921
+ return msg
922
+ raise DaemonError(f"{method} no id=1 response after 20 frames")
923
+
924
+ if _ipc.IS_WINDOWS:
925
+ port, token = _ipc.read_port_file()
926
+ if port is None:
927
+ raise Unavailable("no daemon running")
928
+ url = f"ws://127.0.0.1:{port}/?token={token}&client={client_label}{session_q}"
929
+ async with websockets.connect(url, compression=None) as ws:
930
+ await ws.send(json.dumps({
931
+ "id": 1, "method": method, "params": params,
932
+ }))
933
+ msg = await _drain_until_response(ws)
934
+ else:
935
+ path = _ipc.sock_path()
936
+ if not path.exists():
937
+ raise Unavailable("no daemon running")
938
+ async with websockets.unix_connect(
939
+ str(path),
940
+ uri=f"ws://localhost/?client={client_label}{session_q}",
941
+ compression=None,
942
+ ) as ws:
943
+ await ws.send(json.dumps({
944
+ "id": 1, "method": method, "params": params,
945
+ }))
946
+ msg = await _drain_until_response(ws)
947
+ if "error" in msg:
948
+ err = msg["error"] or {}
949
+ raise DaemonError(
950
+ f"{method} failed: {err.get('message', err)} (code={err.get('code')})"
951
+ )
952
+ result = msg.get("result")
953
+ if not isinstance(result, dict):
954
+ raise DaemonError(f"{method} returned non-dict result: {result!r}")
955
+ return result
956
+
957
+
958
+ async def _userscript_call_ws(cfg: Config, method: str, params: dict,
959
+ *, session: str, timeout: float = 5.0) -> dict:
960
+ params = {**params, "bsSession": session}
961
+ return await _rpc_via_ws(
962
+ cfg, method, params, client_label="cli-userscript", timeout=timeout,
963
+ browser_session=session)
964
+
965
+
966
+ def _cmd_userscript(args, cfg: Config | None = None) -> int:
967
+ if cfg is None:
968
+ cfg = load()
969
+ if isinstance(args, list):
970
+ parser = _build_parser()
971
+ ns = parser.parse_args(["userscript", *args])
972
+ else:
973
+ ns = args
974
+ action = getattr(ns, "userscript_cmd", None)
975
+ if not action:
976
+ print("usage: browserwright-daemon userscript {push,list,remove,toggle,logs} ...",
977
+ file=sys.stderr)
978
+ return 1
979
+ if not getattr(ns, "session", None):
980
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
981
+ return 2
982
+
983
+ try:
984
+ if action in {"push", "install"}:
985
+ from .userscripts import parse_userscript
986
+ if ns.file == "-":
987
+ text = sys.stdin.read()
988
+ else:
989
+ with open(ns.file, encoding="utf-8") as f:
990
+ text = f.read()
991
+ us = parse_userscript(text)
992
+ result = _run(_userscript_call_ws(
993
+ cfg, "BrowserwrightDaemon.userscript.install",
994
+ {"script": us.to_payload()}, session=ns.session))
995
+ sync = result.get("sync", {}) or {}
996
+ print(json.dumps({
997
+ "id": result.get("id", us.id),
998
+ "identity": result.get("identity", us.identity),
999
+ "warnings": us.warnings,
1000
+ "sync": sync,
1001
+ }, sort_keys=True))
1002
+ # Surface header warnings and (crucially) a failed sync to stderr so
1003
+ # a stored-but-not-injected script doesn't read as plain success.
1004
+ for w in us.warnings:
1005
+ print(f"warning: {w}", file=sys.stderr)
1006
+ if sync.get("ok") is False:
1007
+ reason = sync.get("reason")
1008
+ if reason:
1009
+ print(f"warning: stored but NOT active: {reason}", file=sys.stderr)
1010
+ if "userScripts API unavailable" in str(reason):
1011
+ print("hint: enable the extension's 'Allow user scripts' "
1012
+ "toggle at chrome://extensions (Chrome 138+), or "
1013
+ "developer mode on older Chrome.", file=sys.stderr)
1014
+ for f in sync.get("failed") or []:
1015
+ print(f"warning: registration failed for "
1016
+ f"{f.get('identity') or f.get('id')}: {f.get('error')}",
1017
+ file=sys.stderr)
1018
+ return 2
1019
+ return 0
1020
+
1021
+ if action == "list":
1022
+ params = {"site": ns.site} if ns.site else {}
1023
+ result = _run(_userscript_call_ws(
1024
+ cfg, "BrowserwrightDaemon.userscript.list", params,
1025
+ session=ns.session))
1026
+ elif action == "remove":
1027
+ result = _run(_userscript_call_ws(
1028
+ cfg, "BrowserwrightDaemon.userscript.remove", {"key": ns.key},
1029
+ session=ns.session))
1030
+ elif action == "toggle":
1031
+ enabled = str(ns.enabled).lower() in {"1", "true", "yes", "on"}
1032
+ result = _run(_userscript_call_ws(
1033
+ cfg, "BrowserwrightDaemon.userscript.toggle",
1034
+ {"key": ns.key, "enabled": enabled}, session=ns.session))
1035
+ elif action == "logs":
1036
+ # NB: the relay envelope reserves the "id" key for the RPC
1037
+ # request id (relay._request overwrites it), so the script-id
1038
+ # filter must travel under a non-colliding key.
1039
+ params = {"limit": ns.limit}
1040
+ if ns.id:
1041
+ params["scriptId"] = ns.id
1042
+ result = _run(_userscript_call_ws(
1043
+ cfg, "BrowserwrightDaemon.userscript.logs", params,
1044
+ session=ns.session))
1045
+ else:
1046
+ print(f"unknown userscript action: {action}", file=sys.stderr)
1047
+ return 1
1048
+ except UserError as e:
1049
+ print(f"error: {e}", file=sys.stderr)
1050
+ return 1
1051
+ except Unavailable as e:
1052
+ print(f"error: {e}", file=sys.stderr)
1053
+ return 2
1054
+ except DaemonError as e:
1055
+ print(f"error: {e}", file=sys.stderr)
1056
+ return 3
1057
+
1058
+ print(json.dumps(result, sort_keys=True))
1059
+ return 0
1060
+
1061
+
1062
+ def _cmd_open_background(args, cfg: Config) -> int:
1063
+ if not args.session:
1064
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
1065
+ return 2
1066
+ try:
1067
+ result = _run(_rpc_via_ws(
1068
+ cfg,
1069
+ "BrowserwrightDaemon.openBackgroundTab",
1070
+ {"url": args.url, "groupName": args.group, "bsSession": args.session},
1071
+ client_label="cli-open-background",
1072
+ timeout=15.0,
1073
+ browser_session=args.session,
1074
+ ))
1075
+ except Unavailable as e:
1076
+ print(f"error: {e}", file=sys.stderr)
1077
+ return 2
1078
+ except DaemonError as e:
1079
+ print(f"error: {e}", file=sys.stderr)
1080
+ return 3
1081
+ # Always emit JSON — single-line spec §5.1 discipline; the response is
1082
+ # structured so a tab-separated form would lose fields.
1083
+ print(json.dumps(result, sort_keys=True))
1084
+ return 0
1085
+
1086
+
1087
+ def _cmd_close_tab(args, cfg: Config) -> int:
1088
+ if not args.session:
1089
+ print("error: provide --session or set BD_SESSION", file=sys.stderr)
1090
+ return 2
1091
+ if not args.session_id and not args.target_id:
1092
+ print("error: provide --session-id or --target-id", file=sys.stderr)
1093
+ return 2
1094
+ params: dict = {"bsSession": args.session}
1095
+ if args.session_id:
1096
+ params["sessionId"] = args.session_id
1097
+ if args.target_id:
1098
+ params["targetId"] = args.target_id
1099
+ try:
1100
+ result = _run(_rpc_via_ws(
1101
+ cfg,
1102
+ "BrowserwrightDaemon.closeTab",
1103
+ params,
1104
+ client_label="cli-close-tab",
1105
+ timeout=10.0,
1106
+ browser_session=args.session,
1107
+ ))
1108
+ except Unavailable as e:
1109
+ print(f"error: {e}", file=sys.stderr)
1110
+ return 2
1111
+ except DaemonError as e:
1112
+ print(f"error: {e}", file=sys.stderr)
1113
+ return 3
1114
+ print(json.dumps(result, sort_keys=True))
1115
+ return 0
1116
+
1117
+
1118
+ def _cmd_end_session(args, cfg: Config) -> int:
1119
+ """P5: tear down a browserwright session's extension tabs (owned closed,
1120
+ borrowed kept). Prints the {closed, kept} JSON result."""
1121
+ es_params: dict = {"session": args.session}
1122
+ if getattr(args, "group_id", None) is not None:
1123
+ es_params["groupId"] = args.group_id
1124
+ try:
1125
+ result = _run(_rpc_via_ws(
1126
+ cfg,
1127
+ "BrowserwrightDaemon.endSession",
1128
+ es_params,
1129
+ client_label="cli-end-session",
1130
+ timeout=10.0,
1131
+ browser_session=args.session,
1132
+ ))
1133
+ except Unavailable as e:
1134
+ print(f"error: {e}", file=sys.stderr)
1135
+ return 2
1136
+ except DaemonError as e:
1137
+ print(f"error: {e}", file=sys.stderr)
1138
+ return 3
1139
+ print(json.dumps(result, sort_keys=True))
1140
+ return 0
1141
+
1142
+
1143
+ def _cmd_kill_executor(args, cfg: Config) -> int:
1144
+ """Phase B: reap a session's resident executor (no browser teardown).
1145
+
1146
+ Best-effort by design — a dead daemon / missing executor must not fail
1147
+ `session end`, so every failure mode maps to a clean exit code the caller
1148
+ tolerates. Prints the `{ok, killed}` JSON result on success."""
1149
+ try:
1150
+ result = _run(_rpc_via_ws(
1151
+ cfg,
1152
+ "BrowserwrightDaemon.killExecutor",
1153
+ {"session": args.session},
1154
+ client_label="cli-kill-executor",
1155
+ timeout=10.0,
1156
+ browser_session=args.session,
1157
+ ))
1158
+ except Unavailable as e:
1159
+ print(f"error: {e}", file=sys.stderr)
1160
+ return 2
1161
+ except DaemonError as e:
1162
+ print(f"error: {e}", file=sys.stderr)
1163
+ return 3
1164
+ print(json.dumps(result, sort_keys=True))
1165
+ return 0
1166
+
1167
+
1168
+ # ---- LaunchAgent service (macOS) ----------------------------------------
1169
+ #
1170
+ # Goal: the daemon is a long-running service, not a per-session subprocess.
1171
+ # macOS LaunchAgents are the right primitive — Linux/systemd-user support
1172
+ # is deferred (no users hitting it yet on this codebase).
1173
+
1174
+ # There is exactly one global daemon, so the LaunchAgent has one fixed label
1175
+ # and one plist (no per-instance name — BD_NAME was removed).
1176
+ _LAUNCHAGENT_LABEL = "com.browserwright-daemon"
1177
+
1178
+
1179
+ def _launchagent_dir() -> "Path":
1180
+ from pathlib import Path
1181
+ return Path.home() / "Library" / "LaunchAgents"
1182
+
1183
+
1184
+ def _launchagent_plist_path() -> "Path":
1185
+ return _launchagent_dir() / f"{_LAUNCHAGENT_LABEL}.plist"
1186
+
1187
+
1188
+ def _resolve_browserwright_daemon_bin() -> str:
1189
+ """Find the absolute path to the `browserwright-daemon` console script. The
1190
+ plist needs a fully-qualified path; LaunchAgents don't inherit the
1191
+ user's shell PATH."""
1192
+ import shutil
1193
+ path = shutil.which("browserwright-daemon")
1194
+ if path:
1195
+ return path
1196
+ # Fallback to "<sys.prefix>/bin/browserwright-daemon".
1197
+ from pathlib import Path
1198
+ candidate = Path(sys.prefix) / "bin" / "browserwright-daemon"
1199
+ if candidate.exists():
1200
+ return str(candidate)
1201
+ raise UserError(
1202
+ "browserwright-daemon binary not found on PATH; "
1203
+ "install it via pip/uv before running `browserwright-daemon install`"
1204
+ )
1205
+
1206
+
1207
+ def _build_plist(*, extension_port: int | None) -> str:
1208
+ """Emit the plist content. Kept inline (no XML lib) — the schema is
1209
+ fixed + tiny, and we avoid a dependency. Every interpolated value passes
1210
+ through ``xml.sax.saxutils.escape``. Pure: no side effects — the caller is
1211
+ responsible for creating the log dir.
1212
+ """
1213
+ bin_path = _resolve_browserwright_daemon_bin()
1214
+ args = [bin_path, "serve"]
1215
+ if extension_port is not None:
1216
+ args += ["--extension-port", str(extension_port)]
1217
+ log_dir = os.path.expanduser("~/.cache/browserwright-daemon/logs")
1218
+ stdout_path = f"{log_dir}/browserwright-daemon.stdout.log"
1219
+ stderr_path = f"{log_dir}/browserwright-daemon.stderr.log"
1220
+ label = _LAUNCHAGENT_LABEL
1221
+ # PATH carried over so any subprocess the daemon spawns (e.g. chrome)
1222
+ # is discoverable. /usr/local/bin + /opt/homebrew/bin cover both
1223
+ # Intel and Apple Silicon Homebrew layouts. Constant, no escape needed.
1224
+ env_path = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
1225
+ arg_xml = "\n ".join(
1226
+ f"<string>{_xml_escape(a)}</string>" for a in args
1227
+ )
1228
+ return (
1229
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
1230
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
1231
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
1232
+ '<plist version="1.0">\n'
1233
+ '<dict>\n'
1234
+ f' <key>Label</key><string>{_xml_escape(label)}</string>\n'
1235
+ ' <key>ProgramArguments</key>\n'
1236
+ ' <array>\n'
1237
+ f' {arg_xml}\n'
1238
+ ' </array>\n'
1239
+ ' <key>RunAtLoad</key><true/>\n'
1240
+ ' <key>KeepAlive</key>\n'
1241
+ ' <dict>\n'
1242
+ ' <key>SuccessfulExit</key><false/>\n'
1243
+ ' <key>Crashed</key><true/>\n'
1244
+ ' </dict>\n'
1245
+ ' <key>EnvironmentVariables</key>\n'
1246
+ ' <dict>\n'
1247
+ f' <key>PATH</key><string>{env_path}</string>\n'
1248
+ ' </dict>\n'
1249
+ f' <key>StandardOutPath</key><string>{_xml_escape(stdout_path)}</string>\n'
1250
+ f' <key>StandardErrorPath</key><string>{_xml_escape(stderr_path)}</string>\n'
1251
+ f' <key>WorkingDirectory</key><string>{_xml_escape(os.path.expanduser("~"))}</string>\n'
1252
+ '</dict>\n'
1253
+ '</plist>\n'
1254
+ )
1255
+
1256
+
1257
+ def _launchctl(*args: str) -> tuple[int, str, str]:
1258
+ import subprocess
1259
+ try:
1260
+ proc = subprocess.run(["launchctl", *args],
1261
+ capture_output=True, text=True, timeout=10)
1262
+ return proc.returncode, proc.stdout, proc.stderr
1263
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
1264
+ return -1, "", str(e)
1265
+
1266
+
1267
+ def _cmd_install(args, cfg: Config) -> int:
1268
+ if sys.platform != "darwin":
1269
+ print("error: `install` is macOS-only (LaunchAgent); "
1270
+ "for Linux run `browserwright-daemon serve` from a systemd-user "
1271
+ "unit yourself for now", file=sys.stderr)
1272
+ return 1
1273
+ if getattr(args, "backend", None):
1274
+ print("warning: `browserwright-daemon install --backend` is ignored; "
1275
+ "the LaunchAgent runs the single global daemon and session "
1276
+ "backends route per session", file=sys.stderr)
1277
+ label = _LAUNCHAGENT_LABEL
1278
+ plist_path = _launchagent_plist_path()
1279
+ if plist_path.exists() and not args.force:
1280
+ print(f"error: {plist_path} already exists. "
1281
+ f"Use --force to replace.", file=sys.stderr)
1282
+ return 1
1283
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
1284
+ # Create the log dir before launchctl tries to write to it. Kept out of
1285
+ # `_build_plist` so the generator stays pure / unit-testable (N-1).
1286
+ log_dir = os.path.expanduser("~/.cache/browserwright-daemon/logs")
1287
+ os.makedirs(log_dir, exist_ok=True)
1288
+ content = _build_plist(extension_port=args.extension_port)
1289
+ # If --force and the plist exists, unload the old one first so launchctl
1290
+ # picks up the new ProgramArguments cleanly.
1291
+ if plist_path.exists():
1292
+ _launchctl("unload", str(plist_path))
1293
+ plist_path.write_text(content)
1294
+ rc, _, err = _launchctl("load", "-w", str(plist_path))
1295
+ if rc != 0:
1296
+ # Rollback: remove the just-written plist so a re-run isn't blocked
1297
+ # by the "already exists" check (L-3).
1298
+ plist_path.unlink(missing_ok=True)
1299
+ print(f"error: launchctl load failed: {err.strip()}",
1300
+ file=sys.stderr)
1301
+ return 3
1302
+ print(json.dumps({
1303
+ "ok": True,
1304
+ "label": label,
1305
+ "plist": str(plist_path),
1306
+ "extension_port": args.extension_port,
1307
+ }, sort_keys=True))
1308
+ return 0
1309
+
1310
+
1311
+ def _cmd_uninstall(args, cfg: Config) -> int:
1312
+ if sys.platform != "darwin":
1313
+ print("error: `uninstall` is macOS-only", file=sys.stderr)
1314
+ return 1
1315
+ plist_path = _launchagent_plist_path()
1316
+ if not plist_path.exists():
1317
+ print(json.dumps({"ok": False,
1318
+ "reason": "no LaunchAgent installed"},
1319
+ sort_keys=True))
1320
+ return 0
1321
+ rc, _, err = _launchctl("unload", str(plist_path))
1322
+ # Even on unload failure (e.g. wasn't loaded), we still remove the plist.
1323
+ plist_path.unlink()
1324
+ payload = {"ok": True, "removed": str(plist_path)}
1325
+ if rc != 0 and err.strip():
1326
+ payload["unload_warning"] = err.strip()
1327
+ print(json.dumps(payload, sort_keys=True))
1328
+ return 0
1329
+
1330
+
1331
+ def _cmd_restart(args, cfg: Config) -> int:
1332
+ if sys.platform != "darwin":
1333
+ print("error: `restart` is macOS-only (LaunchAgent)", file=sys.stderr)
1334
+ return 1
1335
+ plist_path = _launchagent_plist_path()
1336
+ if not plist_path.exists():
1337
+ print(
1338
+ "error: no LaunchAgent installed; run `browserwright-daemon install` "
1339
+ "or restart your foreground `serve` process manually",
1340
+ file=sys.stderr,
1341
+ )
1342
+ return 2
1343
+ _launchctl("unload", str(plist_path))
1344
+ rc, _, err = _launchctl("load", "-w", str(plist_path))
1345
+ if rc != 0:
1346
+ print(f"error: launchctl load failed: {err.strip()}", file=sys.stderr)
1347
+ return 3
1348
+ print(json.dumps({"ok": True, "restarted": str(plist_path)}, sort_keys=True))
1349
+ return 0
1350
+
1351
+
1352
+ def _cmd_list(args, cfg: Config) -> int:
1353
+ """Report the single global daemon: whether it's installed as a
1354
+ LaunchAgent and whether it's running on the socket."""
1355
+ plist_path = _launchagent_plist_path()
1356
+ installed = plist_path.exists()
1357
+ service = "launchagent" if installed else "manual"
1358
+ running_pid = _ipc_ping()
1359
+ info = {
1360
+ "service": service,
1361
+ "plist": str(plist_path) if installed else None,
1362
+ "running": running_pid is not None,
1363
+ "pid": running_pid,
1364
+ }
1365
+ if args.json:
1366
+ print(json.dumps(info, sort_keys=True))
1367
+ return 0
1368
+ if not installed and running_pid is None:
1369
+ print("no daemon installed or running")
1370
+ return 0
1371
+ running = "yes" if info["running"] else "no"
1372
+ pid = str(running_pid) if running_pid else "-"
1373
+ print(f"{'SERVICE':<12} {'RUNNING':<8} {'PID':<8}")
1374
+ print(f"{service:<12} {running:<8} {pid:<8}")
1375
+ return 0
1376
+
1377
+
1378
+ def _ipc_ping() -> int | None:
1379
+ """Tiny wrapper around ipc.ping_sync that swallows everything."""
1380
+ try:
1381
+ from . import _ipc
1382
+ return _ipc.ping_sync(timeout=0.5)
1383
+ except Exception:
1384
+ return None
1385
+
1386
+
1387
+ _DISPATCH = {
1388
+ "url": _cmd_url,
1389
+ "doctor": _cmd_doctor,
1390
+ "list-backends": _cmd_list_backends,
1391
+ "active-tab": _cmd_active_tab,
1392
+ "launch-chrome": _cmd_launch_chrome,
1393
+ "version": _cmd_version,
1394
+ # v0.2
1395
+ "serve": _cmd_serve,
1396
+ "stop": _cmd_stop,
1397
+ "restart": _cmd_restart,
1398
+ "status": _cmd_status,
1399
+ "disconnect": _cmd_disconnect,
1400
+ "logs": _cmd_logs,
1401
+ # v0.5
1402
+ "stats": _cmd_stats,
1403
+ "backend-info": _cmd_backend_info,
1404
+ # v0.5.4 — extension backend
1405
+ "attach-active": _cmd_attach_active,
1406
+ "open-background": _cmd_open_background,
1407
+ "close-tab": _cmd_close_tab,
1408
+ "end-session": _cmd_end_session,
1409
+ "kill-executor": _cmd_kill_executor,
1410
+ "userscript": _cmd_userscript,
1411
+ # v0.5.5 — LaunchAgent service (macOS) so the daemon is long-running.
1412
+ "install": _cmd_install,
1413
+ "uninstall": _cmd_uninstall,
1414
+ "list": _cmd_list,
1415
+ }
1416
+
1417
+
1418
+ # ---- pretty print ----------------------------------------------------------
1419
+
1420
+
1421
+ def _pretty_doctor(out: dict) -> None:
1422
+ rec = out.get("recommended")
1423
+ print(f"recommended: {rec or '(none available)'}")
1424
+ print()
1425
+ for entry in out["backends"]:
1426
+ mark = "OK " if entry["available"] else "-- "
1427
+ print(f" {mark} {entry['name']:<12} ux_cost={entry['ux_cost']}")
1428
+ if entry["detail"]:
1429
+ print(f" detail: {entry['detail']}")
1430
+ if entry["ux_warning"]:
1431
+ print(f" warning: {entry['ux_warning']}")
1432
+ if entry["needs_user_action"]:
1433
+ print(f" next: {entry['needs_user_action']}")
1434
+
1435
+
1436
+ if __name__ == "__main__":
1437
+ _entry()