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,143 @@
1
+ """Site + memory primitives (spec §A.2 second half).
2
+
3
+ These are the calls agents reach for to record per-site knowledge. The heavy
4
+ lifting lives in ``memory/`` — this module is mostly a thin shim that picks the
5
+ right host.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Any, Optional
11
+ from urllib.parse import urlparse
12
+
13
+ from ..errors import NeedsUserConfirm
14
+ from ..memory import global_memory, site_memory
15
+ from ..memory.site_mem import RedactionRejected, bootstrap_site as _bootstrap, host_stem
16
+ from ..session import current_session
17
+
18
+
19
+ def _resolve_host(host_or_url: Optional[str]) -> str:
20
+ """Pick the host stem to use.
21
+
22
+ Priority:
23
+ 1. Explicit ``host_or_url`` argument.
24
+ 2. ``current_session().current_target_id`` → URL → host.
25
+ 3. Raise — we never silently write to "default" memory.
26
+ """
27
+ if host_or_url:
28
+ return host_stem(host_or_url)
29
+ sess = current_session()
30
+ if sess.current_target_id:
31
+ # Look it up via the tab list (cheap, single CDP call).
32
+ from .page import list_tabs
33
+ for t in list_tabs():
34
+ if t["targetId"] == sess.current_target_id and t.get("url"):
35
+ return host_stem(t["url"])
36
+ raise ValueError("remember(): no host given and no current tab to infer from")
37
+
38
+
39
+ def bootstrap_site(host: str, aliases: Optional[list[str]] = None) -> str:
40
+ """Lazy-create ``site-skills/<stem>/`` with the canonical layout. US2."""
41
+ d = _bootstrap(host, aliases=aliases)
42
+ return str(d)
43
+
44
+
45
+ def remember(host: Optional[str], text: str, *, section: str = "Notes") -> str:
46
+ """Append a line to site memory (auto-lazy-creates the site dir).
47
+
48
+ ``host`` accepts a URL, a hostname, or ``None`` to mean "the current tab".
49
+
50
+ Refuses to write if redaction tripwires fire (high entropy, Bearer tokens,
51
+ absolute user paths, etc.) — surfaces the reasons on stderr.
52
+ """
53
+ stem = _resolve_host(host)
54
+ mem = site_memory(stem)
55
+ try:
56
+ path = mem.append(text, section=section)
57
+ except RedactionRejected as e:
58
+ print(
59
+ f"[browserwright] remember() refused — redaction tripwires: {e.reasons}",
60
+ file=sys.stderr,
61
+ )
62
+ return ""
63
+ return str(path)
64
+
65
+
66
+ def remember_global(text: str, *, section: str = "Notes") -> str:
67
+ """Append a free-form line to ``~/.browserwright/global.md``."""
68
+ global_memory().append(text, section=section)
69
+ return str(global_memory().path)
70
+
71
+
72
+ def remember_preference(key: str, value: Any, *, confirm: bool = True) -> dict:
73
+ """Structured global preference write (spec §C.3 type D, US4).
74
+
75
+ First call (``confirm=True``) raises ``NeedsUserConfirm``: the agent must
76
+ surface a dialog to the user. After assent the agent re-calls with
77
+ ``confirm=False`` and the new value lands in ``global.md`` frontmatter.
78
+
79
+ **Dotted-key semantics** (v0.3.1 — Bug 4 from the AI E2E run):
80
+ ``key`` is interpreted as a YAML frontmatter *path*, not a literal flat
81
+ key. ``"daemon.preferred_backend"`` writes to ``frontmatter.daemon
82
+ .preferred_backend`` — i.e. a nested mapping under ``daemon:``. This
83
+ matches the install-wizard layout (``global.md`` keeps a ``daemon:``
84
+ block with ``preferred_backend`` / ``notes`` siblings) and lets the
85
+ agent group related preferences together.
86
+
87
+ To write a flat top-level key, simply omit the dots
88
+ (``remember_preference("dark_mode", True)`` → ``frontmatter.dark_mode``).
89
+
90
+ **Caveat — silent overwrite on type mismatch** (REVIEW.md F-9):
91
+ writing a dotted key whose root segment already holds a scalar
92
+ silently replaces the scalar with a dict. e.g. if
93
+ ``frontmatter.daemon`` was previously a string and the agent calls
94
+ ``remember_preference("daemon.preferred_backend", "rdp")``, the
95
+ string is destroyed and replaced with ``{preferred_backend: "rdp"}``.
96
+ No diagnostic is emitted. Avoid this by never mixing scalar and
97
+ dotted writes under the same root key; v0.6 will surface a
98
+ ``NeedsUserConfirm`` warning when the type would change.
99
+
100
+ The companion reader ``memory_read()`` / ``browserwright memory show
101
+ --global`` returns the full frontmatter tree, so you can verify the
102
+ write took the expected shape.
103
+
104
+ Example::
105
+
106
+ # First call asks the user.
107
+ remember_preference("daemon.preferred_backend", "extension")
108
+ # → NeedsUserConfirm raised; agent dialogs the user
109
+
110
+ # After the user agrees:
111
+ remember_preference("daemon.preferred_backend", "extension",
112
+ confirm=False)
113
+ # → global.md frontmatter gains:
114
+ # daemon:
115
+ # preferred_backend: extension
116
+ # set_by_user_at: <ts>
117
+ """
118
+ if confirm:
119
+ raise NeedsUserConfirm(
120
+ what=f"set {key} = {value!r}",
121
+ proposal={"key": key, "value": value},
122
+ )
123
+ return global_memory().set_preference(key, value, confirm=False)
124
+
125
+
126
+ def memory_read(site: Optional[str] = None) -> dict:
127
+ """Bundle of all memory the agent might want to read.
128
+
129
+ ``site=None`` → returns ``{"global": ..., "current_site": ...}`` with
130
+ the current tab's site memory if attached.
131
+ """
132
+ out: dict[str, Any] = {"global": global_memory().read()}
133
+ if site is None:
134
+ sess = current_session()
135
+ if sess.current_target_id:
136
+ try:
137
+ stem = _resolve_host(None)
138
+ site = stem
139
+ except ValueError:
140
+ site = None
141
+ if site:
142
+ out["current_site"] = {"site": host_stem(site), "data": site_memory(site).read()}
143
+ return out
@@ -0,0 +1,466 @@
1
+ """Install browserwright as immutable local releases.
2
+
3
+ The development checkout may be broken at any time. Global agent entry points
4
+ therefore point at a copied release directory, never at the checkout.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .version import package_version
20
+
21
+
22
+ ROOT_ENV = "BROWSERWRIGHT_RELEASE_ROOT"
23
+ LOCAL_BIN_ENV = "BROWSERWRIGHT_LOCAL_BIN"
24
+ SKILL_TARGETS_ENV = "BROWSERWRIGHT_SKILL_TARGETS"
25
+ CHROME_EXTENSION_TARGET_ENV = "BROWSERWRIGHT_CHROME_EXTENSION_TARGET"
26
+
27
+
28
+ class ReleaseError(RuntimeError):
29
+ pass
30
+
31
+
32
+ def data_root() -> Path:
33
+ override = os.environ.get(ROOT_ENV)
34
+ if override:
35
+ return Path(override).expanduser()
36
+ return Path.home() / ".local" / "share" / "browserwright"
37
+
38
+
39
+ def releases_dir() -> Path:
40
+ return data_root() / "releases"
41
+
42
+
43
+ def release_dir(version: str) -> Path:
44
+ return releases_dir() / version
45
+
46
+
47
+ def local_bin_dir() -> Path:
48
+ override = os.environ.get(LOCAL_BIN_ENV)
49
+ if override:
50
+ return Path(override).expanduser()
51
+ return Path.home() / ".local" / "bin"
52
+
53
+
54
+ def skill_targets() -> list[Path]:
55
+ override = os.environ.get(SKILL_TARGETS_ENV)
56
+ if override:
57
+ return [Path(p).expanduser() for p in override.split(os.pathsep) if p]
58
+ return [
59
+ Path.home() / ".claude" / "skills" / "browserwright",
60
+ Path.home() / ".agents" / "skills" / "browserwright",
61
+ Path.home() / ".pi" / "agent" / "skills" / "browserwright",
62
+ ]
63
+
64
+
65
+ def chrome_extension_target() -> Path | None:
66
+ override = os.environ.get(CHROME_EXTENSION_TARGET_ENV)
67
+ if override:
68
+ return Path(override).expanduser()
69
+ if sys.platform == "darwin":
70
+ return (
71
+ Path.home()
72
+ / "Library"
73
+ / "Mobile Documents"
74
+ / "com~apple~CloudDocs"
75
+ / "etc"
76
+ / "chrome-extension"
77
+ / "browserwright"
78
+ )
79
+ return None
80
+
81
+
82
+ def repo_root() -> Path:
83
+ here = Path(__file__).resolve()
84
+ for parent in here.parents:
85
+ if (parent / "pyproject.toml").is_file():
86
+ return parent
87
+ raise ReleaseError("cannot locate repo root from installed package")
88
+
89
+
90
+ def _file_hash(path: Path) -> bytes:
91
+ h = hashlib.sha256()
92
+ h.update(path.name.encode("utf-8"))
93
+ h.update(b"\0")
94
+ h.update(path.read_bytes())
95
+ return h.digest()
96
+
97
+
98
+ def hash_paths(paths: list[Path]) -> str:
99
+ h = hashlib.sha256()
100
+ for root in sorted(paths, key=lambda p: p.as_posix()):
101
+ if not root.exists():
102
+ continue
103
+ if root.is_file():
104
+ h.update(root.as_posix().encode("utf-8"))
105
+ h.update(b"\0")
106
+ h.update(_file_hash(root))
107
+ continue
108
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
109
+ if "__pycache__" in path.parts or path.name.endswith((".pyc", ".pyo")):
110
+ continue
111
+ rel = path.relative_to(root).as_posix()
112
+ h.update(root.name.encode("utf-8"))
113
+ h.update(b"/")
114
+ h.update(rel.encode("utf-8"))
115
+ h.update(b"\0")
116
+ h.update(_file_hash(path))
117
+ return h.hexdigest()
118
+
119
+
120
+ def hash_directory_contents(root: Path) -> str:
121
+ h = hashlib.sha256()
122
+ if not root.exists():
123
+ return h.hexdigest()
124
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
125
+ if "__pycache__" in path.parts or path.name.endswith((".pyc", ".pyo")):
126
+ continue
127
+ rel = path.relative_to(root).as_posix()
128
+ h.update(rel.encode("utf-8"))
129
+ h.update(b"\0")
130
+ h.update(_file_hash(path))
131
+ return h.hexdigest()
132
+
133
+
134
+ def component_hashes(root: Path | None = None) -> dict[str, str]:
135
+ root = repo_root() if root is None else root
136
+ return {
137
+ "python": hash_paths([
138
+ root / "pyproject.toml",
139
+ root / "uv.lock",
140
+ root / "src" / "browserwright",
141
+ ]),
142
+ "skill": hash_paths([root / "skill"]),
143
+ "chrome_extension": hash_paths([root / "chrome-extension"]),
144
+ }
145
+
146
+
147
+ def git_info(root: Path | None = None) -> dict[str, Any]:
148
+ root = repo_root() if root is None else root
149
+
150
+ def run(args: list[str]) -> str | None:
151
+ try:
152
+ proc = subprocess.run(
153
+ ["git", *args],
154
+ cwd=root,
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=5,
158
+ )
159
+ except (FileNotFoundError, subprocess.TimeoutExpired):
160
+ return None
161
+ if proc.returncode != 0:
162
+ return None
163
+ return proc.stdout.strip()
164
+
165
+ status = run(["status", "--porcelain"])
166
+ return {
167
+ "commit": run(["rev-parse", "HEAD"]),
168
+ "dirty": bool(status),
169
+ }
170
+
171
+
172
+ def read_release_metadata(version: str) -> dict[str, Any] | None:
173
+ path = release_dir(version) / "release.json"
174
+ try:
175
+ return json.loads(path.read_text(encoding="utf-8"))
176
+ except (FileNotFoundError, ValueError, OSError):
177
+ return None
178
+
179
+
180
+ def list_releases() -> list[dict[str, Any]]:
181
+ out: list[dict[str, Any]] = []
182
+ base = releases_dir()
183
+ if not base.exists():
184
+ return out
185
+ for entry in sorted(base.iterdir(), key=lambda p: p.name):
186
+ if not entry.is_dir():
187
+ continue
188
+ meta = read_release_metadata(entry.name) or {"version": entry.name}
189
+ meta["path"] = str(entry)
190
+ meta["active"] = entry.resolve() == active_release_dir()
191
+ out.append(meta)
192
+ return out
193
+
194
+
195
+ def _run(cmd: list[str], *, cwd: Path | None = None) -> None:
196
+ try:
197
+ proc = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
198
+ except FileNotFoundError as e:
199
+ raise ReleaseError(f"command not found: {cmd[0]}") from e
200
+ if proc.returncode != 0:
201
+ detail = (proc.stderr or proc.stdout or "").strip()
202
+ suffix = f": {detail}" if detail else ""
203
+ raise ReleaseError(f"command failed ({proc.returncode}): {' '.join(cmd)}{suffix}")
204
+
205
+
206
+ def _copytree(src: Path, dst: Path) -> None:
207
+ if not src.is_dir():
208
+ raise ReleaseError(f"required directory missing: {src}")
209
+ shutil.copytree(
210
+ src,
211
+ dst,
212
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
213
+ )
214
+
215
+
216
+ def _copytree_replace(src: Path, dst: Path) -> None:
217
+ if not src.is_dir():
218
+ raise ReleaseError(f"required directory missing: {src}")
219
+ dst.parent.mkdir(parents=True, exist_ok=True)
220
+ tmp = dst.with_name(f".{dst.name}.tmp-{os.getpid()}")
221
+ shutil.rmtree(tmp, ignore_errors=True)
222
+ if tmp.exists() or tmp.is_symlink():
223
+ tmp.unlink()
224
+ _copytree(src, tmp)
225
+ if dst.exists() and not dst.is_symlink():
226
+ shutil.rmtree(dst)
227
+ elif dst.exists() or dst.is_symlink():
228
+ dst.unlink()
229
+ os.replace(tmp, dst)
230
+
231
+
232
+ def sync_chrome_extension(version: str) -> dict[str, Any] | None:
233
+ """Copy the active release extension into the stable Chrome load path."""
234
+ target = chrome_extension_target()
235
+ if target is None:
236
+ return None
237
+ src = release_dir(version) / "chrome-extension"
238
+ if not src.is_dir():
239
+ raise ReleaseError(f"release chrome-extension missing: {src}")
240
+ if target.exists() and target.is_symlink():
241
+ target.unlink()
242
+ elif target.exists() and not target.is_dir():
243
+ raise ReleaseError(f"chrome extension target is not a directory: {target}")
244
+ target.parent.mkdir(parents=True, exist_ok=True)
245
+ tmp = target.with_name(f".{target.name}.tmp-{os.getpid()}")
246
+ shutil.rmtree(tmp, ignore_errors=True)
247
+ shutil.copytree(
248
+ src,
249
+ tmp,
250
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
251
+ )
252
+ if target.exists():
253
+ shutil.rmtree(target)
254
+ os.replace(tmp, target)
255
+ return {"path": str(target), "source": str(src)}
256
+
257
+
258
+ def _venv_python(venv: Path) -> Path:
259
+ if sys.platform == "win32":
260
+ return venv / "Scripts" / "python.exe"
261
+ return venv / "bin" / "python"
262
+
263
+
264
+ def _build_wheel(root: Path, target: Path) -> Path:
265
+ dist = target / "dist"
266
+ dist.mkdir(parents=True, exist_ok=True)
267
+ _run(["uv", "build", "--wheel", "--out-dir", str(dist)], cwd=root)
268
+ wheels = sorted(dist.glob("browserwright-*.whl"))
269
+ if not wheels:
270
+ raise ReleaseError("uv build did not produce a browserwright wheel")
271
+ return wheels[-1]
272
+
273
+
274
+ def _install_wheel(root: Path, target: Path) -> None:
275
+ wheels = sorted((target / "dist").glob("browserwright-*.whl"))
276
+ if not wheels:
277
+ raise ReleaseError("release dist does not contain a browserwright wheel")
278
+ venv = target / ".venv"
279
+ _run(["uv", "venv", str(venv)], cwd=root)
280
+ py = _venv_python(venv)
281
+ _run(["uv", "pip", "install", "--python", str(py), str(wheels[-1])], cwd=root)
282
+
283
+
284
+ def _atomic_symlink(target: Path, link: Path) -> None:
285
+ link.parent.mkdir(parents=True, exist_ok=True)
286
+ tmp = link.with_name(f".{link.name}.tmp-{os.getpid()}")
287
+ try:
288
+ tmp.unlink()
289
+ except FileNotFoundError:
290
+ pass
291
+ os.symlink(str(target), str(tmp))
292
+ os.replace(tmp, link)
293
+
294
+
295
+ def active_release_dir() -> Path | None:
296
+ link = local_bin_dir() / "browserwright"
297
+ if not link.is_symlink():
298
+ return None
299
+ try:
300
+ target = link.resolve()
301
+ except OSError:
302
+ return None
303
+ for parent in target.parents:
304
+ if parent.parent == releases_dir():
305
+ return parent
306
+ return None
307
+
308
+
309
+ def active_version() -> str | None:
310
+ active = active_release_dir()
311
+ return active.name if active else None
312
+
313
+
314
+ def activate(version: str) -> dict[str, Any]:
315
+ target = release_dir(version)
316
+ if not target.is_dir():
317
+ raise ReleaseError(f"release not installed: {version}")
318
+ bin_dir = local_bin_dir()
319
+ _atomic_symlink(target / ".venv" / "bin" / "browserwright", bin_dir / "browserwright")
320
+ _atomic_symlink(
321
+ target / ".venv" / "bin" / "browserwright-daemon",
322
+ bin_dir / "browserwright-daemon",
323
+ )
324
+ for skill in skill_targets():
325
+ _copytree_replace(target / "skill", skill)
326
+ return {"version": version, "path": str(target)}
327
+
328
+
329
+ def install_local(*, force: bool = False, activate_release: bool = True) -> dict[str, Any]:
330
+ root = repo_root()
331
+ version = package_version()
332
+ final = release_dir(version)
333
+ previous_version = active_version()
334
+ previous_meta = read_release_metadata(previous_version) if previous_version else None
335
+ if final.exists():
336
+ if not force:
337
+ raise ReleaseError(f"release {version} already exists; use --force to replace it")
338
+ releases_dir().mkdir(parents=True, exist_ok=True)
339
+ tmp = Path(tempfile.mkdtemp(prefix=f".{version}.", dir=str(releases_dir())))
340
+ backup: Path | None = None
341
+ try:
342
+ _copytree(root / "skill", tmp / "skill")
343
+ _copytree(root / "chrome-extension", tmp / "chrome-extension")
344
+ _build_wheel(root, tmp)
345
+ hashes = component_hashes(root)
346
+ meta = {
347
+ "schema_version": 1,
348
+ "version": version,
349
+ "created_at": datetime.now(timezone.utc).isoformat(),
350
+ "source": {
351
+ "repo": str(root),
352
+ **git_info(root),
353
+ },
354
+ "components": hashes,
355
+ }
356
+ (tmp / "release.json").write_text(
357
+ json.dumps(meta, indent=2, sort_keys=True) + "\n",
358
+ encoding="utf-8",
359
+ )
360
+ if final.exists():
361
+ backup = final.with_name(f".{final.name}.backup-{os.getpid()}")
362
+ shutil.rmtree(backup, ignore_errors=True)
363
+ os.replace(final, backup)
364
+ os.replace(tmp, final)
365
+ _install_wheel(root, final)
366
+ if backup is not None:
367
+ shutil.rmtree(backup, ignore_errors=True)
368
+ except Exception:
369
+ shutil.rmtree(tmp, ignore_errors=True)
370
+ if backup is not None:
371
+ shutil.rmtree(final, ignore_errors=True)
372
+ if backup.exists():
373
+ os.replace(backup, final)
374
+ elif not final.exists() or not (final / ".venv").exists():
375
+ shutil.rmtree(final, ignore_errors=True)
376
+ raise
377
+
378
+ activated = activate(version) if activate_release else None
379
+ extension_sync = sync_chrome_extension(version)
380
+ changes = compare_components(previous_meta, meta)
381
+ return {
382
+ "ok": True,
383
+ "version": version,
384
+ "path": str(final),
385
+ "previous_version": previous_version,
386
+ "activated": activated is not None,
387
+ "chrome_extension_sync": extension_sync,
388
+ "changes": changes,
389
+ "actions": required_actions(changes),
390
+ }
391
+
392
+
393
+ def compare_components(
394
+ previous_meta: dict[str, Any] | None,
395
+ current_meta: dict[str, Any],
396
+ ) -> dict[str, bool]:
397
+ prev = (previous_meta or {}).get("components") or {}
398
+ cur = current_meta.get("components") or {}
399
+ return {name: prev.get(name) != digest for name, digest in cur.items()}
400
+
401
+
402
+ def required_actions(changes: dict[str, bool]) -> dict[str, bool]:
403
+ return {
404
+ "restart_daemon": bool(changes.get("python")),
405
+ "reload_chrome_extension": bool(changes.get("chrome_extension")),
406
+ "skill_relinked": bool(changes.get("skill")),
407
+ }
408
+
409
+
410
+ def _daemon_status() -> dict[str, Any]:
411
+ try:
412
+ proc = subprocess.run(
413
+ ["browserwright-daemon", "status", "--json"],
414
+ capture_output=True,
415
+ text=True,
416
+ timeout=5,
417
+ )
418
+ except (FileNotFoundError, subprocess.TimeoutExpired):
419
+ return {"alive": False, "version": None, "error": "status unavailable"}
420
+ try:
421
+ data = json.loads(proc.stdout or "{}")
422
+ except ValueError:
423
+ data = {}
424
+ data.setdefault("alive", proc.returncode == 0)
425
+ data.setdefault("version", None)
426
+ return data
427
+
428
+
429
+ def status() -> dict[str, Any]:
430
+ active = active_release_dir()
431
+ version = active.name if active else None
432
+ daemon = _daemon_status()
433
+ active_skill = active / "skill" if active else None
434
+ expected_skill_hash = (
435
+ hash_directory_contents(active_skill) if active_skill is not None else None
436
+ )
437
+ skill = [
438
+ {
439
+ "path": str(path),
440
+ "target": str(path.resolve()) if path.is_symlink() else None,
441
+ "ok": (
442
+ path.is_dir()
443
+ and not path.is_symlink()
444
+ and expected_skill_hash is not None
445
+ and hash_directory_contents(path) == expected_skill_hash
446
+ ),
447
+ }
448
+ for path in skill_targets()
449
+ ]
450
+ return {
451
+ "schema_version": 1,
452
+ "installed_version": version,
453
+ "installed_path": str(active) if active else None,
454
+ "chrome_extension_target": str(chrome_extension_target())
455
+ if chrome_extension_target()
456
+ else None,
457
+ "daemon": {
458
+ "alive": bool(daemon.get("alive")),
459
+ "version": daemon.get("version"),
460
+ "restart_required": bool(
461
+ daemon.get("alive") and version and daemon.get("version") != version
462
+ ),
463
+ },
464
+ "skill": skill,
465
+ "releases": list_releases(),
466
+ }
@@ -0,0 +1,6 @@
1
+ """REPL entry point: inline code execution (in-process).
2
+
3
+ The cross-process REPL daemon (server/client/_proto) was removed in P3 — it
4
+ was the silent cross-talk vector. Only the in-process inline runtime remains.
5
+ """
6
+ from . import inline # noqa: F401