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
browserwright/cli.py ADDED
@@ -0,0 +1,741 @@
1
+ """Top-level ``browserwright`` CLI dispatch.
2
+
3
+ Subcommands:
4
+ session new | reset | end | list | prune (P2: explicit session creation)
5
+ whoami --session=ID
6
+ task <site>/<name> [--arg=val ...] NOT IN v0.1 ENTRY: minimal stub
7
+ install
8
+ doctor
9
+ list-tasks [--site SITE]
10
+ index rebuild
11
+ memory show [--site SITE | --global]
12
+ version
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from . import __version__
24
+
25
+
26
+ HELP = """browserwright — Layer 2 of the browser stack.
27
+
28
+ Usage:
29
+ browserwright -s <session-id> -e 'page.goto("https://example.com"); print(page.title())'
30
+ browserwright -s <session-id> -f script.py
31
+ browserwright -s <session-id> --code-stdin < script.py
32
+
33
+ browserwright session new --backend=<extension|rdp> --name=SESSION_LABEL [--create | --attach=PORT]
34
+ browserwright session reset <id>
35
+ browserwright session end --session=ID
36
+ browserwright session list [--json]
37
+ browserwright session prune [--idle=SECONDS]
38
+ browserwright whoami --session=ID
39
+ browserwright userscript {push|list|remove|toggle|logs} ...
40
+
41
+ browserwright task <site>/<name> [--key=value ...] [--isolated]
42
+ browserwright list-tasks [--site SITE] [--query Q] [--json]
43
+
44
+ browserwright sub add <git-url> [--name NAME]
45
+ browserwright sub list [--json]
46
+ browserwright sub update [--name NAME]
47
+ browserwright sub remove --name NAME
48
+ browserwright release {install-local|status|list|activate} ...
49
+
50
+ browserwright install
51
+ browserwright doctor [--json]
52
+ browserwright index rebuild
53
+ browserwright memory show [--site SITE | --global]
54
+ browserwright memory forget --pattern PAT (--site SITE | --global) [--yes]
55
+ browserwright memory replace --pattern PAT --with 'TEXT' (--site SITE | --global) [--yes]
56
+
57
+ browserwright version [--json | check]
58
+ browserwright --print-skill (alias: print-skill)
59
+ """
60
+
61
+
62
+ def _coerce(value: str) -> object:
63
+ # try JSON first so callers can pass numbers/lists/etc.
64
+ try:
65
+ return json.loads(value)
66
+ except (TypeError, ValueError):
67
+ return value
68
+
69
+
70
+ def _parse_kv_args(args: list[str]) -> dict:
71
+ out: dict[str, object] = {}
72
+ i, n = 0, len(args)
73
+ while i < n:
74
+ a = args[i]
75
+ if not a.startswith("--"):
76
+ i += 1
77
+ continue
78
+ key, eq, value = a[2:].partition("=")
79
+ if eq:
80
+ out[key] = _coerce(value)
81
+ elif i + 1 < n and not args[i + 1].startswith("--"):
82
+ # space form: `--query "hacker news"` (the form --help advertises).
83
+ # Consume the next token as the value unless it's another flag.
84
+ out[key] = _coerce(args[i + 1])
85
+ i += 1
86
+ else:
87
+ out[key] = True
88
+ i += 1
89
+ return out
90
+
91
+
92
+ def _parse_execute_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
93
+ """Parse playwriter-style execution flags.
94
+
95
+ Supports both short and long forms:
96
+ browserwright -s 1 -e 'print(snapshot())'
97
+ browserwright --session=1 --execute='print(snapshot())'
98
+ """
99
+ session_id: Optional[str] = None
100
+ code: Optional[str] = None
101
+ code_file: Optional[str] = None
102
+ code_stdin = False
103
+ i, n = 0, len(args)
104
+ while i < n:
105
+ a = args[i]
106
+ if a in {"-s", "--session"}:
107
+ if i + 1 >= n:
108
+ return None, None, f"{a} requires a value"
109
+ session_id = args[i + 1]
110
+ i += 2
111
+ continue
112
+ if a.startswith("--session="):
113
+ session_id = a.split("=", 1)[1]
114
+ i += 1
115
+ continue
116
+ if a in {"-e", "--execute"}:
117
+ if i + 1 >= n:
118
+ return None, None, f"{a} requires a value"
119
+ if code is not None or code_file is not None or code_stdin:
120
+ return None, None, "pass only one of -e, -f, or --code-stdin"
121
+ value = args[i + 1]
122
+ if value == "-":
123
+ code_stdin = True
124
+ else:
125
+ code = value
126
+ i += 2
127
+ continue
128
+ if a.startswith("--execute="):
129
+ if code is not None or code_file is not None or code_stdin:
130
+ return None, None, "pass only one of -e, -f, or --code-stdin"
131
+ value = a.split("=", 1)[1]
132
+ if value == "-":
133
+ code_stdin = True
134
+ else:
135
+ code = value
136
+ i += 1
137
+ continue
138
+ if a in {"-f", "--code-file"}:
139
+ if i + 1 >= n:
140
+ return None, None, f"{a} requires a value"
141
+ if code is not None or code_file is not None or code_stdin:
142
+ return None, None, "pass only one of -e, -f, or --code-stdin"
143
+ code_file = args[i + 1]
144
+ i += 2
145
+ continue
146
+ if a.startswith("--code-file="):
147
+ if code is not None or code_file is not None or code_stdin:
148
+ return None, None, "pass only one of -e, -f, or --code-stdin"
149
+ code_file = a.split("=", 1)[1]
150
+ i += 1
151
+ continue
152
+ if a == "--code-stdin":
153
+ if code is not None or code_file is not None or code_stdin:
154
+ return None, None, "pass only one of -e, -f, or --code-stdin"
155
+ code_stdin = True
156
+ i += 1
157
+ continue
158
+ return None, None, f"unknown execute argument: {a!r}"
159
+ if not session_id:
160
+ return None, None, "missing session id: pass -s <id>"
161
+ if code_file is not None:
162
+ try:
163
+ code = Path(code_file).read_text()
164
+ except OSError as e:
165
+ return None, None, f"cannot read code file {code_file!r}: {e}"
166
+ elif code_stdin:
167
+ code = sys.stdin.read()
168
+ if code is None:
169
+ return None, None, "missing code: pass -e '<python>', -f <path>, or --code-stdin"
170
+ return session_id, code, None
171
+
172
+
173
+ def _cmd_execute(args: list[str]) -> int:
174
+ session_id, code, err = _parse_execute_args(args)
175
+ if err:
176
+ print(f"usage error: {err}", file=sys.stderr)
177
+ print("usage: browserwright -s <session-id> "
178
+ "(-e 'print(snapshot())' | -f script.py | --code-stdin)",
179
+ file=sys.stderr)
180
+ return 1
181
+ from .repl import inline
182
+ return inline.run_code(code or "", session_id=session_id or "")
183
+
184
+
185
+ def _cmd_task(args: list[str]) -> int:
186
+ if not args:
187
+ print("usage: browserwright task <site>/<name> [--key=val ...]", file=sys.stderr)
188
+ return 1
189
+ spec = args[0]
190
+ if "/" not in spec:
191
+ print("task spec must be <site>/<name>", file=sys.stderr)
192
+ return 1
193
+ site, name = spec.split("/", 1)
194
+ kwargs = _parse_kv_args(args[1:])
195
+ # JSON-args envelope for Layer 3 callers.
196
+ js = kwargs.pop("json-args", None)
197
+ if js is not None:
198
+ if isinstance(js, str):
199
+ kwargs.update(json.loads(js))
200
+ elif isinstance(js, dict):
201
+ kwargs.update(js)
202
+ from .task_runner import run_task
203
+ try:
204
+ result = run_task(site, name, **kwargs)
205
+ except FileNotFoundError as e:
206
+ print(f"task not found: {e}", file=sys.stderr)
207
+ return 1
208
+ except Exception as e: # noqa: BLE001
209
+ print(f"task crashed: {e!r}", file=sys.stderr)
210
+ return 3
211
+ if "--json-output" in args or kwargs.get("json_output"):
212
+ sys.stdout.write(json.dumps(result, default=str))
213
+ else:
214
+ sys.stdout.write(repr(result))
215
+ sys.stdout.write("\n")
216
+ return 0
217
+
218
+
219
+ def _cmd_doctor(args: list[str]) -> int:
220
+ """A4: a ``{status, message, fix}`` check table.
221
+
222
+ ``doctor --json`` emits the machine form; default prints human-readable.
223
+ Every ``fail`` check carries a non-empty ``fix`` (enforced in
224
+ ``doctor_checks``). Exits nonzero (CI-style) if any check fails.
225
+ """
226
+ from .health import doctor_checks
227
+
228
+ info = doctor_checks()
229
+ info["skill_version"] = __version__
230
+ checks = info.get("checks", [])
231
+ any_fail = any(c.get("status") == "fail" for c in checks)
232
+
233
+ if "--json" in args:
234
+ sys.stdout.write(json.dumps(info, indent=2, default=str) + "\n")
235
+ return 1 if any_fail else 0
236
+
237
+ print(f"browserwright {__version__}")
238
+ print()
239
+ glyph = {"pass": "✓", "warn": "!", "fail": "✗"}
240
+ for c in checks:
241
+ status = c.get("status", "?")
242
+ mark = glyph.get(status, "?")
243
+ name = c.get("name", "?")
244
+ print(f" {mark} {name:14s} {c.get('message', '')}")
245
+ # Always surface the recovery action for non-pass checks so the
246
+ # human/agent reading the output has a next step.
247
+ if status != "pass" and c.get("fix"):
248
+ print(f" fix: {c['fix']}")
249
+ print()
250
+ if any_fail:
251
+ print("doctor: FAIL — address the fixes above.")
252
+ return 1
253
+ print("doctor: ok")
254
+ return 0
255
+
256
+
257
+ def _cmd_install(_: list[str]) -> int:
258
+ from . import install
259
+ return install.run()
260
+
261
+
262
+ def _cmd_list_tasks(args: list[str]) -> int:
263
+ kwargs = _parse_kv_args(args)
264
+ from .discovery import list_tasks
265
+ tasks = list_tasks(site=kwargs.get("site"), query=kwargs.get("query"))
266
+ if kwargs.get("json"):
267
+ sys.stdout.write(json.dumps(tasks, default=str) + "\n")
268
+ return 0
269
+ if not tasks:
270
+ print("(no tasks found)")
271
+ return 0
272
+ for t in tasks:
273
+ print(f" {t['site']}/{t['name']} — {t.get('desc','')}")
274
+ return 0
275
+
276
+
277
+ def _cmd_index(args: list[str]) -> int:
278
+ if args and args[0] == "rebuild":
279
+ from .discovery import rebuild_index
280
+ out = rebuild_index()
281
+ sys.stdout.write(json.dumps({"sites": len(out.get("sites", []))}, default=str) + "\n")
282
+ return 0
283
+ print("usage: browserwright index rebuild", file=sys.stderr)
284
+ return 1
285
+
286
+
287
+ def _cmd_sub(args: list[str]) -> int:
288
+ """``browserwright sub {add|list|update|remove} ...``."""
289
+ if not args:
290
+ print("usage: browserwright sub {add|list|update|remove} ...", file=sys.stderr)
291
+ return 1
292
+ sub = args[0]
293
+ rest = args[1:]
294
+ from . import subscriptions
295
+
296
+ if sub == "add":
297
+ if not rest or rest[0].startswith("--"):
298
+ print("usage: browserwright sub add <git-url> [--name NAME]", file=sys.stderr)
299
+ return 1
300
+ url = rest[0]
301
+ kw = _parse_kv_args(rest[1:])
302
+ try:
303
+ r = subscriptions.add(url, name=kw.get("name"))
304
+ except subscriptions.SubscriptionError as e:
305
+ print(f"sub add failed: {e}", file=sys.stderr)
306
+ return 1
307
+ if kw.get("json"):
308
+ sys.stdout.write(json.dumps(r, default=str) + "\n")
309
+ else:
310
+ print(f"{r['status']}: {r['name']} → {r['path']}")
311
+ return 0
312
+
313
+ if sub == "list":
314
+ kw = _parse_kv_args(rest)
315
+ rows = subscriptions.list_all()
316
+ if kw.get("json"):
317
+ sys.stdout.write(json.dumps(rows, indent=2, default=str) + "\n")
318
+ return 0
319
+ if not rows:
320
+ print("(no subscriptions)")
321
+ return 0
322
+ for r in rows:
323
+ tag = "" if r["exists"] else " [MISSING]"
324
+ print(f" {r['name']:24s} {r['url']}{tag}")
325
+ return 0
326
+
327
+ if sub == "update":
328
+ kw = _parse_kv_args(rest)
329
+ names = [kw["name"]] if kw.get("name") else None
330
+ try:
331
+ results = subscriptions.update(names)
332
+ except subscriptions.SubscriptionError as e:
333
+ print(f"sub update failed: {e}", file=sys.stderr)
334
+ return 1
335
+ for r in results:
336
+ print(f" {r['name']:24s} {r['status']}: {r.get('detail','')}")
337
+ return 0 if all(r["status"] in ("updated", "missing") for r in results) else 1
338
+
339
+ if sub == "remove":
340
+ kw = _parse_kv_args(rest)
341
+ name = kw.get("name")
342
+ if not name:
343
+ print("usage: browserwright sub remove --name NAME", file=sys.stderr)
344
+ return 1
345
+ try:
346
+ subscriptions.remove(name)
347
+ except subscriptions.SubscriptionError as e:
348
+ print(f"sub remove failed: {e}", file=sys.stderr)
349
+ return 1
350
+ print(f"removed {name}")
351
+ return 0
352
+
353
+ print(f"unknown sub subcommand: {sub}", file=sys.stderr)
354
+ return 1
355
+
356
+
357
+ def _cmd_memory(args: list[str]) -> int:
358
+ if not args:
359
+ print("usage: browserwright memory {show|forget|replace} ...", file=sys.stderr)
360
+ return 1
361
+ sub = args[0]
362
+ rest = args[1:]
363
+ kwargs = _parse_kv_args(rest)
364
+ from .memory import global_memory, site_memory
365
+
366
+ if sub == "show":
367
+ if kwargs.get("global"):
368
+ out = global_memory().read()
369
+ sys.stdout.write(json.dumps(out, indent=2, default=str) + "\n")
370
+ return 0
371
+ site = kwargs.get("site")
372
+ if not site:
373
+ print("specify --site=SITE or --global", file=sys.stderr)
374
+ return 1
375
+ sys.stdout.write(json.dumps(site_memory(site).read(),
376
+ indent=2, default=str) + "\n")
377
+ return 0
378
+
379
+ if sub == "forget":
380
+ pattern = kwargs.get("pattern")
381
+ if not pattern:
382
+ print("usage: memory forget --pattern=PAT (--site=SITE | --global) [--yes]",
383
+ file=sys.stderr)
384
+ return 1
385
+ target_global = bool(kwargs.get("global"))
386
+ site = kwargs.get("site")
387
+ if not target_global and not site:
388
+ print("specify --site=SITE or --global", file=sys.stderr)
389
+ return 1
390
+ mem = global_memory() if target_global else site_memory(site)
391
+ matches = mem.forget(pattern, confirm=True)
392
+ if not matches:
393
+ print("(no matching bullets)")
394
+ return 0
395
+ print(f"would remove {len(matches)} line(s):")
396
+ for ln in matches:
397
+ print(f" {ln}")
398
+ if not kwargs.get("yes"):
399
+ print("\nrun again with --yes to confirm.")
400
+ return 0
401
+ removed = mem.forget(pattern, confirm=False)
402
+ print(f"removed {len(removed)} line(s).")
403
+ return 0
404
+
405
+ if sub == "replace":
406
+ pattern = kwargs.get("pattern")
407
+ replacement = kwargs.get("with")
408
+ if not pattern or not replacement:
409
+ print("usage: memory replace --pattern=PAT --with='new line' "
410
+ "(--site=SITE | --global) [--yes]", file=sys.stderr)
411
+ return 1
412
+ target_global = bool(kwargs.get("global"))
413
+ site = kwargs.get("site")
414
+ if not target_global and not site:
415
+ print("specify --site=SITE or --global", file=sys.stderr)
416
+ return 1
417
+ mem = global_memory() if target_global else site_memory(site)
418
+ matches = mem.forget(pattern, confirm=True)
419
+ if not matches:
420
+ print("(no matching bullets)")
421
+ return 0
422
+ print(f"would remove {len(matches)} line(s) and append: - {replacement}")
423
+ for ln in matches:
424
+ print(f" {ln}")
425
+ if not kwargs.get("yes"):
426
+ print("\nrun again with --yes to confirm.")
427
+ return 0
428
+ mem.forget(pattern, confirm=False)
429
+ mem.append(replacement)
430
+ print("replaced.")
431
+ return 0
432
+
433
+ print(f"unknown memory subcommand: {sub}", file=sys.stderr)
434
+ return 1
435
+
436
+
437
+ def _cmd_session(args: list[str]) -> int:
438
+ """``browserwright session {new|reset|end|list|prune} ...`` (P2)."""
439
+ from . import session_create
440
+ from . import session_registry as reg
441
+
442
+ if not args:
443
+ print("usage: browserwright session {new|reset|end|list|prune} ...", file=sys.stderr)
444
+ return 1
445
+ sub = args[0]
446
+ kw = _parse_kv_args(args[1:])
447
+
448
+ if sub == "new":
449
+ backend = kw.get("backend")
450
+ if backend not in ("extension", "rdp"):
451
+ print("usage: browserwright session new --backend=<extension|rdp> "
452
+ "--name=SESSION_LABEL [--create | --attach=PORT]", file=sys.stderr)
453
+ print("--name is a short task-specific session label. Extension sessions "
454
+ "use it as the Chrome tab group title; RDP sessions use it only "
455
+ "to label the isolated browser session.",
456
+ file=sys.stderr)
457
+ return 1
458
+ try:
459
+ sid = session_create.new(
460
+ backend=backend, create=bool(kw.get("create")),
461
+ attach=kw.get("attach"), name=kw.get("name"),
462
+ )
463
+ except ValueError as e:
464
+ print(str(e), file=sys.stderr)
465
+ return 1
466
+ print(sid) # token-frugal: bare id
467
+ return 0
468
+
469
+ if sub == "end":
470
+ from .errors import NoSession
471
+ from .session_ctx import resolve_session
472
+ try:
473
+ rec = resolve_session(kw.get("session"))
474
+ except NoSession as e:
475
+ print(str(e), file=sys.stderr)
476
+ return e.exit_code
477
+ print(session_create.end(rec))
478
+ return 0
479
+
480
+ if sub == "reset":
481
+ from .errors import NoSession
482
+ from .session_ctx import resolve_session
483
+ raw_sid = args[1] if len(args) > 1 and not args[1].startswith("--") else kw.get("session")
484
+ try:
485
+ rec = resolve_session(raw_sid)
486
+ except NoSession as e:
487
+ print(str(e), file=sys.stderr)
488
+ return e.exit_code
489
+ print(session_create.reset_executor(rec))
490
+ return 0
491
+
492
+ if sub == "list":
493
+ rows = reg.list_all()
494
+ if kw.get("json"):
495
+ sys.stdout.write(json.dumps(rows, indent=2, default=str) + "\n")
496
+ return 0
497
+ if not rows:
498
+ print("(no sessions)")
499
+ return 0
500
+ for r in rows:
501
+ print(f" {r['id']:>3} {r['backend']:9s} {r['owner']:6s} "
502
+ f"{(r.get('name') or '-'):16s}")
503
+ return 0
504
+
505
+ if sub == "prune":
506
+ idle = kw.get("idle", 3600)
507
+ pruned = session_create.reap(idle_seconds=float(idle))
508
+ print(f"pruned {len(pruned)} idle session(s).")
509
+ return 0
510
+
511
+ print(f"unknown session subcommand: {sub}", file=sys.stderr)
512
+ return 1
513
+
514
+
515
+ def _cmd_userscript(args: list[str]) -> int:
516
+ # ``--verify`` is a browserwright-level convenience on ``push``: after a
517
+ # successful push, reload the live tab and screenshot it so the agent sees
518
+ # the effect in one step instead of the manual push→reload→screenshot
519
+ # ritual. It is NOT a daemon flag, so strip it before delegating.
520
+ verify = "--verify" in args
521
+ fwd = [a for a in args if a != "--verify"]
522
+
523
+ result = subprocess.run(["browserwright-daemon", "userscript", *fwd])
524
+ if result.returncode != 0:
525
+ # Push failed — don't reload/screenshot a stale state. Surface the
526
+ # push failure so the agent fixes the script first.
527
+ return result.returncode
528
+
529
+ if verify and fwd and fwd[0] in ("push", "install"):
530
+ # Push succeeded; reload the live tab (reload() waits for load) and
531
+ # screenshot so the agent sees the effect in one step. This is a
532
+ # convenience: if there's no drivable session/tab (e.g. run outside a
533
+ # session), report that the push still SUCCEEDED rather than letting an
534
+ # opaque reload error look like a push failure.
535
+ try:
536
+ # These are internal driving helpers (no longer on the agent
537
+ # EXPORTS surface — Phase C PR3); the userscript --verify
538
+ # convenience still uses them directly from the primitive modules.
539
+ from .primitives.inspect import capture_screenshot
540
+ from .primitives.page import reload
541
+
542
+ reload()
543
+ print(capture_screenshot())
544
+ except Exception as e:
545
+ print(f"pushed OK — --verify skipped (no drivable tab): {e}",
546
+ file=sys.stderr)
547
+
548
+ return result.returncode
549
+
550
+
551
+ def _cmd_whoami(args: list[str]) -> int:
552
+ """``browserwright whoami --session=ID`` — the ledger view of a session.
553
+
554
+ Live-browser fields (group/tab count/sample URL) are filled by a daemon
555
+ round-trip in Phase 5/6; for now we print only ledger-known fields.
556
+ """
557
+ from .errors import NoSession
558
+ from .session_ctx import resolve_session
559
+
560
+ kw = _parse_kv_args(args)
561
+ try:
562
+ rec = resolve_session(kw.get("session"))
563
+ except NoSession as e:
564
+ print(str(e), file=sys.stderr)
565
+ return e.exit_code
566
+ view = {k: rec.get(k) for k in ("id", "backend", "owner", "name")}
567
+ sys.stdout.write(json.dumps(view, default=str) + "\n")
568
+ return 0
569
+
570
+
571
+ def _cmd_print_skill(_: list[str]) -> int:
572
+ """D1: emit the agent-facing skill doc assembled from the running code.
573
+
574
+ The version stamp and primitive surface are generated at runtime from
575
+ ``browserwright.__version__`` and ``browserwright.EXPORTS`` respectively,
576
+ so the printed instructions can never silently drift from the installed
577
+ binary.
578
+ """
579
+ from . import skill_doc
580
+
581
+ sys.stdout.write(skill_doc.render())
582
+ return 0
583
+
584
+
585
+ def _cmd_version(args: list[str]) -> int:
586
+ from .version import version_info
587
+
588
+ info = version_info()
589
+ if args and args[0] == "check":
590
+ if "--json" in args:
591
+ sys.stdout.write(json.dumps(info, sort_keys=True) + "\n")
592
+ elif info["ok"]:
593
+ print(f"browserwright {info['version']} (versions ok)")
594
+ else:
595
+ for issue in info["issues"]:
596
+ print(f"{issue['code']}: {issue['message']}", file=sys.stderr)
597
+ return 0 if info["ok"] else 1
598
+ if args and args[0] == "--json":
599
+ sys.stdout.write(json.dumps(info, sort_keys=True) + "\n")
600
+ return 0
601
+ print(__version__)
602
+ return 0
603
+
604
+
605
+ def _cmd_release(args: list[str]) -> int:
606
+ if not args:
607
+ print(
608
+ "usage: browserwright release {install-local|status|list|activate} ...",
609
+ file=sys.stderr,
610
+ )
611
+ return 1
612
+ from . import release_install
613
+
614
+ sub = args[0]
615
+ rest = args[1:]
616
+ kw = _parse_kv_args(rest)
617
+ try:
618
+ if sub == "install-local":
619
+ info = release_install.install_local(
620
+ force=bool(kw.get("force")),
621
+ activate_release=not bool(kw.get("no-activate")),
622
+ )
623
+ if kw.get("restart-daemon") and info["actions"].get("restart_daemon"):
624
+ subprocess.run(["browserwright-daemon", "restart"], check=False)
625
+ if kw.get("json"):
626
+ sys.stdout.write(json.dumps(info, indent=2, sort_keys=True) + "\n")
627
+ else:
628
+ print(f"installed browserwright {info['version']} -> {info['path']}")
629
+ if info.get("activated"):
630
+ print("activated global entry points")
631
+ if info["actions"].get("restart_daemon"):
632
+ print("next: restart daemon (`browserwright-daemon restart`)")
633
+ if info["actions"].get("reload_chrome_extension"):
634
+ print(
635
+ "next: reload Chrome unpacked extension from "
636
+ f"{info.get('chrome_extension_sync', {}).get('path') or info['path'] + '/chrome-extension'}"
637
+ )
638
+ return 0
639
+
640
+ if sub == "status":
641
+ info = release_install.status()
642
+ if kw.get("json"):
643
+ sys.stdout.write(json.dumps(info, indent=2, sort_keys=True) + "\n")
644
+ return 0
645
+ version = info.get("installed_version") or "(none)"
646
+ print(f"installed release: {version}")
647
+ daemon = info.get("daemon") or {}
648
+ daemon_version = daemon.get("version") or "(not running)"
649
+ suffix = " restart required" if daemon.get("restart_required") else ""
650
+ print(f"running daemon: {daemon_version}{suffix}")
651
+ ok = all(row.get("ok") for row in info.get("skill", []))
652
+ print(f"skill install: {'copied ok' if ok else 'needs reinstall'}")
653
+ return 0
654
+
655
+ if sub == "list":
656
+ rows = release_install.list_releases()
657
+ if kw.get("json"):
658
+ sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True) + "\n")
659
+ return 0
660
+ if not rows:
661
+ print("(no releases installed)")
662
+ return 0
663
+ for row in rows:
664
+ mark = "*" if row.get("active") else " "
665
+ print(f"{mark} {row.get('version')} {row.get('path')}")
666
+ return 0
667
+
668
+ if sub == "activate":
669
+ if not rest or rest[0].startswith("--"):
670
+ print("usage: browserwright release activate <version>", file=sys.stderr)
671
+ return 1
672
+ info = release_install.activate(rest[0])
673
+ if kw.get("json"):
674
+ sys.stdout.write(json.dumps(info, indent=2, sort_keys=True) + "\n")
675
+ else:
676
+ print(f"activated browserwright {info['version']} -> {info['path']}")
677
+ print("next: restart daemon and reload Chrome extension if that release differs")
678
+ return 0
679
+ except release_install.ReleaseError as e:
680
+ print(f"release error: {e}", file=sys.stderr)
681
+ return 1
682
+
683
+ print(f"unknown release subcommand: {sub}", file=sys.stderr)
684
+ return 1
685
+
686
+
687
+ def main(argv: Optional[list[str]] = None) -> None:
688
+ argv = list(sys.argv[1:] if argv is None else argv)
689
+
690
+ # `--print-skill` is a flag (leading dash) but is a real command, not help;
691
+ # intercept it before the help check below.
692
+ if argv and argv[0] in {"--print-skill", "print-skill"}:
693
+ sys.exit(_cmd_print_skill(argv[1:]))
694
+
695
+ if not argv and not sys.stdin.isatty():
696
+ print("usage: browserwright -s <session-id> -e 'print(snapshot())'",
697
+ file=sys.stderr)
698
+ sys.exit(1)
699
+
700
+ if not argv or argv[0] in {"-h", "--help"}:
701
+ sys.stdout.write(HELP)
702
+ sys.exit(0 if argv else 1)
703
+
704
+ if argv and (
705
+ argv[0] in {"-s", "--session", "-e", "--execute"}
706
+ or argv[0].startswith("--session=")
707
+ or argv[0].startswith("--execute=")
708
+ ):
709
+ sys.exit(_cmd_execute(argv))
710
+
711
+ cmd = argv[0]
712
+ rest = argv[1:]
713
+
714
+ if cmd in {"--version", "version"}:
715
+ sys.exit(_cmd_version(rest))
716
+ if cmd == "task":
717
+ sys.exit(_cmd_task(rest))
718
+ if cmd == "doctor":
719
+ sys.exit(_cmd_doctor(rest))
720
+ if cmd == "install":
721
+ sys.exit(_cmd_install(rest))
722
+ if cmd == "list-tasks":
723
+ sys.exit(_cmd_list_tasks(rest))
724
+ if cmd == "index":
725
+ sys.exit(_cmd_index(rest))
726
+ if cmd == "memory":
727
+ sys.exit(_cmd_memory(rest))
728
+ if cmd == "sub":
729
+ sys.exit(_cmd_sub(rest))
730
+ if cmd == "release":
731
+ sys.exit(_cmd_release(rest))
732
+ if cmd == "session":
733
+ sys.exit(_cmd_session(rest))
734
+ if cmd == "whoami":
735
+ sys.exit(_cmd_whoami(rest))
736
+ if cmd == "userscript":
737
+ sys.exit(_cmd_userscript(rest))
738
+
739
+ print(f"unknown command: {cmd!r}", file=sys.stderr)
740
+ print(HELP, file=sys.stderr)
741
+ sys.exit(1)