real-browser-cli 0.14.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 (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from browser_cli.commands import client_from_ctx, handle_errors
11
+ from browser_cli.constants import CONFIG_DIR
12
+
13
+ console = Console()
14
+ WORKSPACES_PATH = CONFIG_DIR / "workspaces.json"
15
+
16
+ def _load() -> dict:
17
+ try:
18
+ return json.loads(WORKSPACES_PATH.read_text(encoding="utf-8"))
19
+ except FileNotFoundError:
20
+ return {}
21
+
22
+ def _save(data: dict) -> None:
23
+ WORKSPACES_PATH.parent.mkdir(parents=True, exist_ok=True)
24
+ WORKSPACES_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
25
+
26
+ @click.group("workspace")
27
+ def workspace_group():
28
+ """Named browser workspaces built on top of sessions."""
29
+
30
+ @workspace_group.command("save")
31
+ @click.argument("name")
32
+ @click.option("--session", "session_name", default=None, help="Session name to save/use (default: workspace name)")
33
+ @click.option("--profile", default=None, help="Performance profile to remember")
34
+ @handle_errors
35
+ def workspace_save(name, session_name, profile):
36
+ session_name = session_name or name
37
+ result = client_from_ctx().session.save(session_name)
38
+ data = _load()
39
+ data[name] = {"session": session_name, "profile": profile}
40
+ _save(data)
41
+ console.print(f"[green]Workspace '{name}' saved[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
42
+
43
+ @workspace_group.command("load")
44
+ @click.argument("name")
45
+ @click.option("--lazy", is_flag=True, help="Lazy-restore tabs")
46
+ @click.option("--eager-tabs", type=int, default=10, show_default=True)
47
+ @handle_errors
48
+ def workspace_load(name, lazy, eager_tabs):
49
+ data = _load()
50
+ ws = data.get(name)
51
+ if not ws:
52
+ raise click.ClickException(f"Workspace '{name}' not found")
53
+ client = client_from_ctx()
54
+ if ws.get("profile"):
55
+ client.perf.set_profile(ws["profile"])
56
+ result = client.session.load(ws["session"], lazy=lazy, eager_tabs=eager_tabs)
57
+ console.print(f"[green]Workspace '{name}' loaded[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)")
58
+
59
+ @workspace_group.command("switch")
60
+ @click.argument("name")
61
+ @click.option("--lazy", is_flag=True)
62
+ @handle_errors
63
+ def workspace_switch(name, lazy):
64
+ """Load a workspace. Alias for workspace load."""
65
+ ctx = click.get_current_context()
66
+ ctx.invoke(workspace_load, name=name, lazy=lazy, eager_tabs=10)
67
+
68
+ @workspace_group.command("list")
69
+ def workspace_list():
70
+ """List configured workspaces."""
71
+ data = _load()
72
+ if not data:
73
+ console.print("[yellow]No workspaces[/yellow]")
74
+ return
75
+ table = Table(show_header=True, header_style="bold cyan")
76
+ table.add_column("Name")
77
+ table.add_column("Session")
78
+ table.add_column("Profile")
79
+ for name, ws in sorted(data.items()):
80
+ table.add_row(name, ws.get("session", ""), ws.get("profile") or "")
81
+ console.print(table)
82
+
83
+ @workspace_group.command("remove")
84
+ @click.argument("name")
85
+ def workspace_remove(name):
86
+ data = _load()
87
+ if name not in data:
88
+ raise click.ClickException(f"Workspace '{name}' not found")
89
+ del data[name]
90
+ _save(data)
91
+ console.print(f"[green]Workspace '{name}' removed[/green]")
@@ -0,0 +1,4 @@
1
+ from browser_cli.compat.commands import adapt_request, adapt_response
2
+ from browser_cli.compat.auth import adapt_auth
3
+
4
+ __all__ = ["adapt_auth", "adapt_request", "adapt_response"]
@@ -0,0 +1,44 @@
1
+ """
2
+ Auth-field normalizers — applied to the raw incoming message *before* the
3
+ auth check runs. Protocol fields (pubkey, sig, …) are still present here.
4
+
5
+ Add one entry per breaking auth-field change:
6
+ ("X.Y.Z", transformer_fn)
7
+
8
+ Entries must stay in ascending version order.
9
+ """
10
+ from __future__ import annotations
11
+ from typing import Callable
12
+ from browser_cli.version_manager import parse_version
13
+
14
+
15
+ # ── v0.9.3 ────────────────────────────────────────────────────────────────────
16
+
17
+ def _auth_0_9_3(msg: dict) -> dict:
18
+ """pubkey validation tightened to lowercase hex; normalize for older clients."""
19
+ changed: dict = {}
20
+ pk = msg.get("pubkey")
21
+ if isinstance(pk, str) and pk:
22
+ changed["pubkey"] = pk.lower()
23
+ if msg.get("command") == "browser-cli.auth.trust":
24
+ args = msg.get("args") or {}
25
+ trust_pk = args.get("pubkey")
26
+ if isinstance(trust_pk, str) and trust_pk:
27
+ changed["args"] = {**args, "pubkey": trust_pk.lower()}
28
+ return {**msg, **changed} if changed else msg
29
+
30
+
31
+ # ── registry ──────────────────────────────────────────────────────────────────
32
+
33
+ _AUTH_COMPAT: list[tuple[str, Callable[[dict], dict]]] = [
34
+ ("0.9.3", _auth_0_9_3),
35
+ ]
36
+
37
+
38
+ def adapt_auth(msg: dict, client_version: str) -> dict:
39
+ """Apply all auth normalizers needed to bring msg up to the current format."""
40
+ cv = parse_version(client_version)
41
+ for version, fn in _AUTH_COMPAT:
42
+ if cv < parse_version(version):
43
+ msg = fn(msg)
44
+ return msg
@@ -0,0 +1,43 @@
1
+ """
2
+ Command-format shims — applied to clean_msg (protocol fields already stripped)
3
+ before forwarding to the native host, and to responses before sending back.
4
+
5
+ Add one entry per breaking command-format change:
6
+ ("X.Y.Z", request_fn, response_fn)
7
+
8
+ - request_fn(msg: dict) -> dict or None
9
+ - response_fn(resp: bytes, command: str) -> bytes or None
10
+
11
+ Entries must stay in ascending version order.
12
+ adapt_request walks forward (oldest first); adapt_response walks backward.
13
+
14
+ Current baseline: 0.9.3 — no command-format shims needed yet.
15
+ """
16
+ from __future__ import annotations
17
+ from typing import Callable
18
+ from browser_cli.version_manager import parse_version
19
+
20
+
21
+ # ── registry ──────────────────────────────────────────────────────────────────
22
+
23
+ _COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
24
+ # ("1.0.0", _req_1_0_0, _resp_1_0_0),
25
+ ]
26
+
27
+
28
+ def adapt_request(msg: dict, client_version: str) -> dict:
29
+ """Upgrade a client message to the current browser command format."""
30
+ cv = parse_version(client_version)
31
+ for version, req_fn, _ in _COMPAT:
32
+ if cv < parse_version(version) and req_fn is not None:
33
+ msg = req_fn(msg)
34
+ return msg
35
+
36
+
37
+ def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
38
+ """Downgrade a native-host response to the format the client expects."""
39
+ cv = parse_version(client_version)
40
+ for version, _, resp_fn in reversed(_COMPAT):
41
+ if cv < parse_version(version) and resp_fn is not None:
42
+ resp = resp_fn(resp, command)
43
+ return resp
@@ -0,0 +1,95 @@
1
+ """Project-wide constants for browser-cli.
2
+
3
+ Only static values live here. Runtime-derived state (e.g. installed version,
4
+ open sockets, locks) stays in the owning module.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ APP_NAME = "browser-cli"
12
+ RUNTIME_DIRNAME = ".browser_cli"
13
+ DEFAULT_ALIAS = "default"
14
+
15
+ NATIVE_HOST_NAME = "com.browsercli.host"
16
+ EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
17
+ WEBSTORE_EXTENSION_ID = "hekaebjhbhhdbmakimmaklbblbmccahp"
18
+ ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
19
+ SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"]
20
+
21
+ PROTOCOL_MIN_CLIENT = "0.9.0"
22
+ MAX_MSG_BYTES = 32 * 1024 * 1024
23
+ DEFAULT_REMOTE_PORT = 443
24
+ DEFAULT_PAGE_SIZE = 100
25
+ DEFAULT_TRANSPORT_THRESHOLD = 512
26
+
27
+ NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
28
+ GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
29
+
30
+ PAGEABLE_COMMANDS = {
31
+ "tabs.list",
32
+ "tabs.filter",
33
+ "tabs.query",
34
+ "group.list",
35
+ "group.tabs",
36
+ "group.query",
37
+ "windows.list",
38
+ "dom.query",
39
+ "dom.text",
40
+ "dom.attr",
41
+ "extract.links",
42
+ "extract.images",
43
+ "extract.json",
44
+ "session.list",
45
+ }
46
+
47
+ NATIVE_HOST_DIRS = {
48
+ "chrome": {
49
+ "linux": [Path.home() / ".config/google-chrome/NativeMessagingHosts"],
50
+ "darwin": [Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts"],
51
+ },
52
+ "chromium": {
53
+ "linux": [Path.home() / ".config/chromium/NativeMessagingHosts"],
54
+ "darwin": [Path.home() / "Library/Application Support/Chromium/NativeMessagingHosts"],
55
+ },
56
+ "brave": {
57
+ "linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
58
+ "darwin": [Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
59
+ },
60
+ "edge": {
61
+ "linux": [Path.home() / ".config/microsoft-edge/NativeMessagingHosts"],
62
+ "darwin": [Path.home() / "Library/Application Support/Microsoft Edge/NativeMessagingHosts"],
63
+ },
64
+ "vivaldi": {
65
+ "linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
66
+ "darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
67
+ },
68
+ }
69
+
70
+ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
71
+ "chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
72
+ "chromium": [r"Software\Chromium\NativeMessagingHosts"],
73
+ "brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
74
+ "edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
75
+ "vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
76
+ }
77
+
78
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
79
+ DEFAULT_KEY_PATH = CONFIG_DIR / "client.key.pem"
80
+ DEFAULT_AUTHORIZED_KEYS_PATH = CONFIG_DIR / "authorized_keys"
81
+
82
+ SSH_AGENTC_REQUEST_IDENTITIES = 11
83
+ SSH_AGENT_IDENTITIES_ANSWER = 12
84
+ SSH_AGENTC_SIGN_REQUEST = 13
85
+ SSH_AGENT_SIGN_RESPONSE = 14
86
+
87
+ PQ_KEX_ALG = "ML-KEM-768"
88
+ PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
89
+
90
+ SER_JSON = 0
91
+ SER_MSGPACK = 1
92
+ COMP_NONE = 0
93
+ COMP_ZLIB = 1
94
+ COMP_GZIP = 2
95
+ COMP_ZSTD = 3
@@ -0,0 +1,55 @@
1
+ """Remote endpoint string handling — parsing, normalization, display.
2
+
3
+ Pure helpers (no sockets, no I/O) for turning user-facing ``host[:port]``
4
+ strings into the canonical forms the rest of the client uses, and back into the
5
+ short forms shown to humans.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from browser_cli.constants import DEFAULT_REMOTE_PORT
12
+ from browser_cli.errors import BrowserNotConnected
13
+
14
+ def _looks_like_domain(host: str) -> bool:
15
+ """True if host looks like a domain name rather than an IP address or localhost."""
16
+ if host in {"localhost", "127.0.0.1", "::1"}:
17
+ return False
18
+ if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
19
+ return False
20
+ return '.' in host and any(c.isalpha() for c in host)
21
+
22
+ def _normalize_endpoint(endpoint: str) -> str:
23
+ """Strip :443 from domain-like endpoints so they are stored without the default port."""
24
+ if not endpoint:
25
+ return endpoint
26
+ host, sep, port = endpoint.rpartition(":")
27
+ if sep and port == "443" and _looks_like_domain(host):
28
+ return host
29
+ return endpoint
30
+
31
+ def _resolve_connect_endpoint(endpoint: str) -> str:
32
+ """Return host:port for TCP connection; domain without port defaults to :443."""
33
+ _, sep, _ = endpoint.rpartition(":")
34
+ if not sep:
35
+ if _looks_like_domain(endpoint):
36
+ return f"{endpoint}:{DEFAULT_REMOTE_PORT}"
37
+ raise BrowserNotConnected(
38
+ f"Invalid remote endpoint '{endpoint}': expected host:port"
39
+ )
40
+ return endpoint
41
+
42
+ def display_browser_name(profile_name: str, sock_path: str) -> str:
43
+ from pathlib import Path
44
+
45
+ if profile_name != "default":
46
+ return profile_name
47
+ return Path(sock_path).stem or profile_name
48
+
49
+ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
50
+ host, sep, port = endpoint.rpartition(":")
51
+ if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
52
+ display_endpoint = host
53
+ else:
54
+ display_endpoint = endpoint # normalized domain (no port) or non-default port
55
+ return f"{display_endpoint}:{display_name or profile_name}"
browser_cli/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared exception types for the browser-cli client stack.
2
+
3
+ Kept dependency-free so the transport, endpoint, and client modules can import
4
+ it without creating an import cycle.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ class BrowserNotConnected(Exception):
9
+ """Raised when the native host socket is not available."""
browser_cli/framing.py ADDED
@@ -0,0 +1,83 @@
1
+ """Length-prefixed byte framing used by browser-cli transports.
2
+
3
+ Frame format is shared by local IPC and remote TCP: 4-byte little-endian payload
4
+ length followed by raw payload bytes. Native Messaging stdio has the same length
5
+ prefix but JSON helpers stay in ``browser_cli.native.host`` because they operate
6
+ on file streams, not sockets/asyncio streams.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import struct
12
+ from typing import Protocol
13
+
14
+ from browser_cli.version_manager import MAX_MSG_BYTES
15
+
16
+ class RecvSocket(Protocol):
17
+ def recv(self, n: int) -> bytes: ...
18
+
19
+ class SendSocket(Protocol):
20
+ def sendall(self, data: bytes) -> None: ...
21
+
22
+ def frame(data: bytes) -> bytes:
23
+ """Return *data* with the browser-cli 4-byte little-endian length prefix."""
24
+ return struct.pack("<I", len(data)) + data
25
+
26
+ def _message_length(raw_len: bytes, *, label: str) -> int:
27
+ msg_len = struct.unpack("<I", raw_len)[0]
28
+ if msg_len > MAX_MSG_BYTES:
29
+ raise ConnectionError(f"{label} too large ({msg_len} bytes)")
30
+ return msg_len
31
+
32
+ def recv_exact(sock: RecvSocket, n: int, *, allow_eof: bool = False) -> bytes | None:
33
+ """Read exactly *n* bytes from a blocking socket-like object.
34
+
35
+ Returns ``None`` on EOF when ``allow_eof=True``; otherwise raises
36
+ ``ConnectionError``.
37
+ """
38
+ buf = b""
39
+ while len(buf) < n:
40
+ chunk = sock.recv(n - len(buf))
41
+ if not chunk:
42
+ if allow_eof:
43
+ return None
44
+ raise ConnectionError("Socket closed before full message received")
45
+ buf += chunk
46
+ return buf
47
+
48
+ def recv_frame(sock: RecvSocket, *, allow_eof: bool = False, label: str = "Message") -> bytes | None:
49
+ """Read one framed payload from a blocking socket-like object."""
50
+ raw_len = recv_exact(sock, 4, allow_eof=allow_eof)
51
+ if raw_len is None:
52
+ return None
53
+ return recv_exact(sock, _message_length(raw_len, label=label), allow_eof=allow_eof)
54
+
55
+ def send_frame(sock: SendSocket, data: bytes) -> None:
56
+ """Send one framed payload through a blocking socket-like object."""
57
+ sock.sendall(frame(data))
58
+
59
+ async def async_recv_exact(reader: asyncio.StreamReader, n: int, *, allow_eof: bool = False) -> bytes | None:
60
+ """Read exactly *n* bytes from an asyncio StreamReader."""
61
+ try:
62
+ return await reader.readexactly(n)
63
+ except asyncio.IncompleteReadError as exc:
64
+ if allow_eof:
65
+ return None
66
+ raise ConnectionError("Socket closed before full message received") from exc
67
+
68
+ async def async_recv_frame(
69
+ reader: asyncio.StreamReader,
70
+ *,
71
+ allow_eof: bool = False,
72
+ label: str = "Message",
73
+ ) -> bytes | None:
74
+ """Read one framed payload from an asyncio StreamReader."""
75
+ raw_len = await async_recv_exact(reader, 4, allow_eof=allow_eof)
76
+ if raw_len is None:
77
+ return None
78
+ return await async_recv_exact(reader, _message_length(raw_len, label=label), allow_eof=allow_eof)
79
+
80
+ async def async_send_frame(writer: asyncio.StreamWriter, data: bytes) -> None:
81
+ """Send one framed payload through an asyncio StreamWriter."""
82
+ writer.write(frame(data))
83
+ await writer.drain()
@@ -0,0 +1,64 @@
1
+ """Local IPC transport for browser-cli client commands."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import socket
6
+ from collections.abc import Callable
7
+ from multiprocessing.connection import Client as PipeClient
8
+
9
+ from browser_cli.framing import async_recv_exact as framing_async_recv_exact
10
+ from browser_cli.framing import async_recv_frame, async_send_frame, frame
11
+ from browser_cli.platform import is_windows
12
+ from browser_cli.remote.transport import _recv_all
13
+
14
+ async def async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes | None:
15
+ try:
16
+ return await framing_async_recv_exact(reader, n, allow_eof=True)
17
+ except ConnectionError:
18
+ return None
19
+
20
+ async def async_recv_all(reader: asyncio.StreamReader) -> bytes | None:
21
+ try:
22
+ return await async_recv_frame(reader, allow_eof=True)
23
+ except ConnectionError:
24
+ return None
25
+
26
+ async def async_send_all(writer: asyncio.StreamWriter, data: bytes) -> None:
27
+ await async_send_frame(writer, data)
28
+
29
+ def send_local_sync(profile: str | None, payload: bytes, resolve_socket: Callable[[str | None], str]) -> bytes | None:
30
+ sock_path = resolve_socket(profile)
31
+ if is_windows():
32
+ with PipeClient(sock_path, family="AF_PIPE") as conn:
33
+ conn.send_bytes(payload)
34
+ return conn.recv_bytes()
35
+
36
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
37
+ sock.connect(sock_path)
38
+ sock.sendall(frame(payload))
39
+ return _recv_all(sock)
40
+
41
+ async def send_local_async(profile: str | None, payload: bytes, resolve_socket: Callable[[str | None], str]) -> bytes | None:
42
+ sock_path = await asyncio.to_thread(resolve_socket, profile)
43
+ if is_windows():
44
+ return await _send_windows_pipe_async(sock_path, payload)
45
+ return await _send_local_unix_async(sock_path, payload)
46
+
47
+ async def _send_local_unix_async(sock_path: str, payload: bytes) -> bytes | None:
48
+ reader, writer = await asyncio.open_unix_connection(sock_path)
49
+ try:
50
+ await async_send_all(writer, payload)
51
+ return await async_recv_all(reader)
52
+ finally:
53
+ writer.close()
54
+ try:
55
+ await writer.wait_closed()
56
+ except Exception:
57
+ pass
58
+
59
+ async def _send_windows_pipe_async(sock_path: str, payload: bytes) -> bytes:
60
+ def _roundtrip():
61
+ with PipeClient(sock_path, family="AF_PIPE") as conn:
62
+ conn.send_bytes(payload)
63
+ return conn.recv_bytes()
64
+ return await asyncio.to_thread(_roundtrip)
@@ -0,0 +1,8 @@
1
+ """Markdown rendering and HTML-to-Markdown conversion helpers."""
2
+ from browser_cli.markdown.render import (
3
+ _clean_markdown_output,
4
+ _convert_html_to_markdown,
5
+ render_markdown,
6
+ )
7
+
8
+ __all__ = ["_clean_markdown_output", "_convert_html_to_markdown", "render_markdown"]