repld-tool 0.0.1__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.
repld/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
repld/bridge.py ADDED
@@ -0,0 +1,70 @@
1
+ """Stdio MCP ↔ unix-socket bridge.
2
+
3
+ Dumb bidirectional byte-pipe. Does not parse MCP. Reads the kernel's socket
4
+ path from ./.pyrepl.lock, connects, then:
5
+
6
+ stdin → socket (thread 1)
7
+ socket → stdout (thread 2)
8
+
9
+ Exits on EOF from either side. One bridge = one MCP client session.
10
+ """
11
+
12
+ import socket
13
+ import sys
14
+ import threading
15
+ from pathlib import Path
16
+
17
+ from .ipc import connect_to_kernel
18
+
19
+ LOCK_PATH = Path.cwd() / ".pyrepl.lock"
20
+
21
+
22
+ def _err(msg: str) -> None:
23
+ print(f"repld bridge: {msg}", file=sys.stderr, flush=True)
24
+
25
+
26
+ def run_bridge(argv: list[str]) -> int:
27
+ result = connect_to_kernel(LOCK_PATH)
28
+ if isinstance(result, str):
29
+ _err(result)
30
+ return 1
31
+ sock, _lock = result
32
+
33
+ stop = threading.Event()
34
+
35
+ def stdin_to_sock() -> None:
36
+ try:
37
+ for line in sys.stdin:
38
+ if not line.endswith("\n"):
39
+ line = line + "\n"
40
+ sock.sendall(line.encode("utf-8"))
41
+ except (BrokenPipeError, OSError):
42
+ stop.set()
43
+ finally:
44
+ # Half-close write side so the kernel sees EOF and drains/closes.
45
+ # DO NOT set stop here: in-flight responses may still be inbound.
46
+ try:
47
+ sock.shutdown(socket.SHUT_WR)
48
+ except OSError:
49
+ pass
50
+
51
+ def sock_to_stdout() -> None:
52
+ try:
53
+ rfile = sock.makefile("r", encoding="utf-8")
54
+ for line in rfile:
55
+ sys.stdout.write(line)
56
+ sys.stdout.flush()
57
+ except (BrokenPipeError, OSError):
58
+ pass
59
+ finally:
60
+ # Socket-side EOF drives shutdown.
61
+ stop.set()
62
+
63
+ threading.Thread(target=stdin_to_sock, daemon=True, name="bridge-stdin").start()
64
+ threading.Thread(target=sock_to_stdout, daemon=True, name="bridge-stdout").start()
65
+ stop.wait()
66
+ try:
67
+ sock.close()
68
+ except OSError:
69
+ pass
70
+ return 0
@@ -0,0 +1,428 @@
1
+ """repld.browser — CDP integration for repld.
2
+
3
+ PUBLIC API:
4
+ - LazyBrowser: Descriptor injected into __main__; lazy-bootstraps on first access.
5
+ - Browser: Manages BrowserSession, watch patterns, and Tab resolution.
6
+
7
+ Usage in kernel:
8
+ setattr(__main__, "browser", LazyBrowser(loop))
9
+
10
+ Then in user code:
11
+ tab = await browser.get("*github.com*") # find one tab by glob
12
+ tab = await browser.get("9222:887d3d") # find one tab by target ID
13
+ await browser.watch("*github.com*") # watch all matching
14
+ await tab.js("document.title")
15
+ """
16
+
17
+ import asyncio
18
+ import fnmatch
19
+ import logging
20
+ import os
21
+ import re
22
+ from typing import Any
23
+
24
+ from ..events import BrowserTabAttached, BrowserTabDetached, emit
25
+ from .session import WORKER_TYPES
26
+ from .tab import Rows, Tab
27
+
28
+ __all__ = ["Browser", "LazyBrowser"]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ _TARGET_ID_RE = re.compile(r"^\d+:[0-9a-f]{6}$")
34
+
35
+
36
+ def _is_target_id(s: str) -> bool:
37
+ """True if s looks like a short target ID (e.g. '9222:a81998')."""
38
+ return bool(_TARGET_ID_RE.match(s))
39
+
40
+
41
+ def make_target(port: int, chrome_id: str) -> str:
42
+ """Create short target ID from port and Chrome target ID.
43
+
44
+ Format: "{port}:{6-char-lowercase-hex}"
45
+ Example: make_target(9222, "887D3D7FA9473DCF...") -> "9222:887d3d"
46
+ """
47
+ return f"{port}:{chrome_id[:6].lower()}"
48
+
49
+
50
+ class Browser:
51
+ """Manages the BrowserSession + watch patterns + Tab resolution.
52
+
53
+ Injected into __main__ by the kernel after lazy initialization.
54
+ """
55
+
56
+ def __init__(
57
+ self, loop: asyncio.AbstractEventLoop, port: int | None = None
58
+ ) -> None:
59
+ from .session import BrowserSession
60
+
61
+ self.port = port or int(os.environ.get("REPLD_CHROME_PORT", "9222"))
62
+ self._session: BrowserSession = BrowserSession(self.port)
63
+ self._loop: asyncio.AbstractEventLoop = loop
64
+ self._connected: bool = False
65
+
66
+ async def _ensure_connected(self) -> None:
67
+ if not self._connected:
68
+ await self._session.connect()
69
+ self._session._on_target_created = self._on_target_created
70
+ self._session._on_target_destroyed = self._on_target_destroyed
71
+ self._connected = True
72
+ logger.debug("BrowserSession connected on port %s", self.port)
73
+ elif not self._session._is_connected():
74
+ await self._session._reconnect()
75
+
76
+ def _on_target_created(self, target_info: dict, target_id: str) -> None:
77
+ """Called when a new tab is auto-attached."""
78
+ url = target_info.get("url", "")
79
+ title = target_info.get("title", "")
80
+ emit(BrowserTabAttached(target_id, url, title))
81
+
82
+ def _on_target_destroyed(self, target_id: str) -> None:
83
+ """Called when a tab is destroyed."""
84
+ emit(BrowserTabDetached(target_id))
85
+
86
+ # ------------------------------------------------------------------
87
+ # Public API
88
+ # ------------------------------------------------------------------
89
+
90
+ async def get(
91
+ self,
92
+ target: str,
93
+ *,
94
+ timeout: float | None = None,
95
+ fresh: bool = False,
96
+ ) -> Tab:
97
+ """Find one tab by URL glob or target ID. Attach on demand.
98
+
99
+ **Glob** (e.g. ``"*github.com*"``): searches pages and iframes,
100
+ skips workers. ``timeout`` polls until a match appears. ``fresh``
101
+ skips tabs that already matched at call time.
102
+
103
+ **Target ID** (e.g. ``"9222:a81998"``): resolves any type including
104
+ workers. Attaches if not already attached. ``timeout``/``fresh``
105
+ are ignored.
106
+ """
107
+ if _is_target_id(target):
108
+ return await self._get_by_id(target)
109
+ return await self._get_by_glob(target, timeout=timeout, fresh=fresh)
110
+
111
+ async def _get_by_id(self, target: str) -> Tab:
112
+ """Resolve a target ID, attaching on demand if needed."""
113
+ # Fast path: already attached
114
+ _, prefix = target.split(":", 1)
115
+ for cdp in self._session._sessions.values():
116
+ chrome_id = cdp.target_info.get("targetId", "")
117
+ if chrome_id[:6].lower() == prefix:
118
+ return Tab(cdp, chrome_id, self.port)
119
+
120
+ # Slow path: find in all targets and attach
121
+ await self._ensure_connected()
122
+ for t in await self._session.list_targets():
123
+ tid = t.get("targetId", "")
124
+ if tid and tid[:6].lower() == prefix:
125
+ cdp = await self._session.attach(tid)
126
+ if cdp is not None:
127
+ return Tab(cdp, tid, self.port)
128
+
129
+ attached = [
130
+ make_target(self.port, cdp.target_info.get("targetId", ""))
131
+ for cdp in self._session._sessions.values()
132
+ ]
133
+ raise RuntimeError(f"No tab '{target}'. Attached: {attached}")
134
+
135
+ async def _get_by_glob(
136
+ self,
137
+ pattern: str,
138
+ *,
139
+ timeout: float | None = None,
140
+ fresh: bool = False,
141
+ ) -> Tab:
142
+ """Find one tab matching a URL glob. Skips workers."""
143
+ # Snapshot existing matches so fresh=True can exclude them.
144
+ exclude: set[str] = set()
145
+ if fresh:
146
+ for cdp in self._session._sessions.values():
147
+ url = cdp.target_info.get("url", "")
148
+ if fnmatch.fnmatch(url, pattern):
149
+ exclude.add(cdp.target_info.get("targetId", ""))
150
+ await self._ensure_connected()
151
+ for t in await self._session.list_targets():
152
+ url = t.get("url", "")
153
+ tid = t.get("targetId", "")
154
+ if fnmatch.fnmatch(url, pattern) and tid:
155
+ exclude.add(tid)
156
+
157
+ deadline = (
158
+ asyncio.get_running_loop().time() + timeout if timeout is not None else None
159
+ )
160
+ while True:
161
+ # 1. Check already-attached tabs (skip workers)
162
+ for cdp in self._session._sessions.values():
163
+ if cdp.target_info.get("type", "") in WORKER_TYPES:
164
+ continue
165
+ url = cdp.target_info.get("url", "")
166
+ tid = cdp.target_info.get("targetId", "")
167
+ if fnmatch.fnmatch(url, pattern) and tid not in exclude:
168
+ return Tab(cdp, tid, self.port)
169
+
170
+ # 2. Search all Chrome targets, attach first match (skip workers)
171
+ await self._ensure_connected()
172
+ for t in await self._session.list_targets():
173
+ if t.get("type", "") in WORKER_TYPES:
174
+ continue
175
+ url = t.get("url", "")
176
+ tid = t.get("targetId", "")
177
+ if fnmatch.fnmatch(url, pattern) and tid and tid not in exclude:
178
+ cdp = await self._session.attach(tid)
179
+ if cdp is not None:
180
+ return Tab(cdp, tid, self.port)
181
+
182
+ if deadline is None or asyncio.get_running_loop().time() >= deadline:
183
+ break
184
+ await asyncio.sleep(0.3)
185
+
186
+ raise RuntimeError(f"No tab matching '{pattern}'")
187
+
188
+ def _resolve_attached(self, target: str) -> Tab:
189
+ """Sync lookup of an already-attached tab by short target ID.
190
+
191
+ Used by clear() and open() where the tab is guaranteed attached.
192
+ """
193
+ if ":" not in target:
194
+ raise RuntimeError(
195
+ f"Invalid target ID '{target}'. Expected format: '9222:a1b2c3'"
196
+ )
197
+ _, prefix = target.split(":", 1)
198
+ for cdp in self._session._sessions.values():
199
+ chrome_id = cdp.target_info.get("targetId", "")
200
+ if chrome_id[:6].lower() == prefix:
201
+ return Tab(cdp, chrome_id, self.port)
202
+
203
+ attached = [
204
+ make_target(self.port, cdp.target_info.get("targetId", ""))
205
+ for cdp in self._session._sessions.values()
206
+ ]
207
+ raise RuntimeError(f"No attached tab '{target}'. Attached: {attached}")
208
+
209
+ async def watch(self, pattern: str) -> str:
210
+ """Register a URL glob pattern and attach currently-matching tabs.
211
+
212
+ Future tabs matching the pattern auto-attach. Workers are skipped.
213
+ Returns a summary string.
214
+ """
215
+ await self._ensure_connected()
216
+
217
+ # Add pattern (registers it in _watched_patterns)
218
+ self._session.add_pattern(pattern)
219
+
220
+ # Attach any targets that match the pattern and aren't already attached
221
+ newly_attached: list[str] = []
222
+ targets = await self._session.list_targets()
223
+ for t in targets:
224
+ if t.get("type", "") in WORKER_TYPES:
225
+ continue
226
+ tid = t.get("targetId", "")
227
+ url = t.get("url", "")
228
+ if fnmatch.fnmatch(url, pattern) and tid:
229
+ # Check if already attached
230
+ already = any(
231
+ cdp.target_info.get("targetId") == tid
232
+ for cdp in self._session._sessions.values()
233
+ )
234
+ if not already:
235
+ try:
236
+ await self._session.attach(tid)
237
+ newly_attached.append(tid)
238
+ self._session._watched_patterns.setdefault(pattern, set()).add(
239
+ tid
240
+ )
241
+ except Exception as exc:
242
+ logger.debug("Attach %s: %s", tid, exc)
243
+
244
+ total = len(self._session._sessions)
245
+ return (
246
+ f"Attached {len(newly_attached)} new tab(s) for pattern '{pattern}'. "
247
+ f"Total attached: {total}."
248
+ )
249
+
250
+ async def open(self, url: str) -> "Tab":
251
+ """Create a new tab and attach to it.
252
+
253
+ Target.createTarget → attach → return Tab.
254
+ """
255
+ await self._ensure_connected()
256
+ result = await self._session.execute("Target.createTarget", {"url": url})
257
+ tid = result["targetId"]
258
+ await self._session.attach(tid)
259
+ return self._resolve_attached(make_target(self.port, tid))
260
+
261
+ async def detach(self, pattern: str | None = None) -> str:
262
+ """Detach tabs by pattern; detach all if pattern is None."""
263
+ if not self._connected:
264
+ return "No browser connection."
265
+
266
+ if pattern is None:
267
+ # Detach everything
268
+ session_ids = list(self._session._sessions.keys())
269
+ for sid in session_ids:
270
+ try:
271
+ await self._session.detach(sid)
272
+ except Exception as exc:
273
+ logger.debug("Detach %s: %s", sid, exc)
274
+ self._session._watched_patterns.clear()
275
+ return f"Detached {len(session_ids)} tab(s). All patterns cleared."
276
+
277
+ # Detach sessions matching this pattern
278
+ to_detach: list[str] = []
279
+ for sid, cdp in list(self._session._sessions.items()):
280
+ url = cdp.target_info.get("url", "")
281
+ if fnmatch.fnmatch(url, pattern):
282
+ to_detach.append(sid)
283
+
284
+ for sid in to_detach:
285
+ try:
286
+ await self._session.detach(sid)
287
+ except Exception as exc:
288
+ logger.debug("Detach %s: %s", sid, exc)
289
+
290
+ # Remove pattern
291
+ self._session._watched_patterns.pop(pattern, None)
292
+ return f"Detached {len(to_detach)} tab(s) for pattern '{pattern}'."
293
+
294
+ @property
295
+ def tabs(self) -> Rows:
296
+ """List currently attached Tab objects."""
297
+ return Rows(
298
+ Tab(cdp, cdp.target_info.get("targetId", ""), self.port)
299
+ for cdp in self._session._sessions.values()
300
+ )
301
+
302
+ async def pages(self) -> list[dict]:
303
+ """List all Chrome targets (attached or not)."""
304
+ await self._ensure_connected()
305
+ return await self._session.list_targets()
306
+
307
+ @property
308
+ def patterns(self) -> list[str]:
309
+ """List active watch patterns."""
310
+ return list(self._session._watched_patterns.keys())
311
+
312
+ def clear(self, target: str | None = None) -> str:
313
+ """Clear captured events. Specify target for one tab, or None for all."""
314
+ if target is not None:
315
+ tab = self._resolve_attached(target)
316
+ tab.clear()
317
+ return f"Cleared events for {target}."
318
+ count = 0
319
+ for cdp in self._session._sessions.values():
320
+ cdp.clear_events()
321
+ count += 1
322
+ return f"Cleared events for {count} tab(s)."
323
+
324
+ async def disconnect(self) -> None:
325
+ """Disconnect from Chrome."""
326
+ if self._connected:
327
+ try:
328
+ await self._session.disconnect()
329
+ except Exception:
330
+ pass
331
+ self._connected = False
332
+
333
+ def format_tabs_nested(self) -> str:
334
+ """Format attached tabs as nested text showing target hierarchy."""
335
+ entries: list[dict] = []
336
+ for cdp in self._session._sessions.values():
337
+ info = cdp.target_info
338
+ entries.append(
339
+ {
340
+ "target": make_target(self.port, info.get("targetId", "")),
341
+ "type": info.get("type", "unknown"),
342
+ "url": info.get("url", ""),
343
+ "title": info.get("title", ""),
344
+ "parent_frame_id": info.get("parentFrameId", ""),
345
+ "opener_id": info.get("openerId", ""),
346
+ }
347
+ )
348
+
349
+ # Build parent lookup: full chrome ID → short target ID
350
+ id_to_short: dict[str, str] = {}
351
+ for cdp in self._session._sessions.values():
352
+ info = cdp.target_info
353
+ full_id = info.get("targetId", "")
354
+ id_to_short[full_id] = make_target(self.port, full_id)
355
+
356
+ # Separate top-level vs children
357
+ children: dict[str, list[dict]] = {}
358
+ top_level: list[dict] = []
359
+
360
+ for e in entries:
361
+ parent_id = e["parent_frame_id"] or e["opener_id"]
362
+ parent_short = id_to_short.get(parent_id)
363
+ if parent_short:
364
+ children.setdefault(parent_short, []).append(e)
365
+ else:
366
+ top_level.append(e)
367
+
368
+ # Format output
369
+ lines: list[str] = []
370
+ for e in top_level:
371
+ lines.append(f"{e['target']} {e['type']} {e['url']}")
372
+ for child in children.get(e["target"], []):
373
+ lines.append(f" {child['target']} {child['type']} {child['url']}")
374
+
375
+ # Orphaned children (parent not attached)
376
+ shown = {e["target"] for e in top_level}
377
+ for parent_short, kids in children.items():
378
+ if parent_short not in shown:
379
+ for child in kids:
380
+ lines.append(
381
+ f"{child['target']} {child['type']} → {parent_short} {child['url']}"
382
+ )
383
+
384
+ return "\n".join(lines) if lines else "(no attached tabs)"
385
+
386
+ def help(self) -> None:
387
+ """Print the Python API reference for the browser object."""
388
+ from ..help import _TOPICS
389
+
390
+ print(_TOPICS["browser"])
391
+
392
+ def __repr__(self) -> str:
393
+ n = len(self._session._sessions) if self._connected else 0
394
+ return f"<Browser port={self.port} tabs={n} patterns={self.patterns}>"
395
+
396
+
397
+ class LazyBrowser:
398
+ """Lazy descriptor injected into __main__.
399
+
400
+ On first attribute access, bootstraps the real Browser object and
401
+ replaces itself in __main__.__dict__.
402
+ """
403
+
404
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
405
+ self._loop = loop
406
+ self._real: Browser | None = None
407
+
408
+ def _bootstrap(self) -> Browser:
409
+ if self._real is None:
410
+ self._real = Browser(self._loop)
411
+ return self._real
412
+
413
+ def help(self) -> None:
414
+ """Print the Python API reference (no Chrome connection needed)."""
415
+ from ..help import _TOPICS
416
+
417
+ print(_TOPICS["browser"])
418
+
419
+ def __getattr__(self, name: str) -> Any:
420
+ return getattr(self._bootstrap(), name)
421
+
422
+ def __repr__(self) -> str:
423
+ if self._real is not None:
424
+ return repr(self._real)
425
+ return "<Browser (lazy — call browser.watch() to connect)>"
426
+
427
+ def __reduce__(self): # type: ignore[override]
428
+ raise TypeError("LazyBrowser is not serializable")