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
browser_cli/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env -S uv run
2
+ """
3
+ browser-cli — Control your running browser from the terminal.
4
+ """
5
+ import click
6
+ import os
7
+ import shutil
8
+ import re
9
+ from importlib.metadata import PackageNotFoundError, version as package_version
10
+ from pathlib import Path
11
+ from rich.console import Console
12
+
13
+ from browser_cli.commands.navigate import nav_group
14
+ from browser_cli.commands.tabs import tabs_group
15
+ from browser_cli.commands.groups import group_group
16
+ from browser_cli.commands.windows import windows_group
17
+ from browser_cli.commands.dom import dom_group
18
+ from browser_cli.commands.extract import extract_group
19
+ from browser_cli.commands.session import session_group
20
+ from browser_cli.commands.search import search_group
21
+ from browser_cli.commands.page import page_group
22
+ from browser_cli.commands.storage import storage_group
23
+ from browser_cli.commands.perf import perf_group
24
+ from browser_cli.commands.extension import extension_group
25
+ from browser_cli.commands.serve import cmd_serve
26
+ from browser_cli.commands.auth import auth_group
27
+ from browser_cli.commands.clients import clients_group
28
+ from browser_cli.commands.completion import cmd_completion
29
+ from browser_cli.commands.install import cmd_install
30
+ from browser_cli.commands.doctor import cmd_doctor
31
+ from browser_cli.commands.events import cmd_events
32
+ from browser_cli.commands.remote import remote_group
33
+ from browser_cli.commands.script import cmd_script
34
+ from browser_cli.commands.serve_http import cmd_serve_http
35
+ from browser_cli.commands.watch import watch_group
36
+ from browser_cli.commands.workspace import workspace_group
37
+ from browser_cli.commands.raw import cmd_command
38
+
39
+ console = Console()
40
+
41
+ # Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
42
+ # patch to use a wider limit so zsh completion descriptions aren't truncated.
43
+ def _patched_group_shell_complete(self, ctx, incomplete):
44
+ from click.shell_completion import CompletionItem
45
+ results = [
46
+ CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
47
+ for name, command in self.commands.items()
48
+ if not command.hidden and name.startswith(incomplete)
49
+ ]
50
+ results.extend(click.Command.shell_complete(self, ctx, incomplete))
51
+ return results
52
+
53
+ click.Group.shell_complete = _patched_group_shell_complete
54
+
55
+ def _project_version() -> str:
56
+ pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
57
+ try:
58
+ content = pyproject_path.read_text(encoding="utf-8")
59
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
60
+ if match:
61
+ return match.group(1)
62
+ except OSError:
63
+ pass
64
+
65
+ try:
66
+ return package_version("browser-cli")
67
+ except PackageNotFoundError:
68
+ return "unknown"
69
+
70
+ def _print_version(ctx, param, value):
71
+ if not value or ctx.resilient_parsing:
72
+ return
73
+ click.echo(_project_version())
74
+ ctx.exit()
75
+
76
+ @click.group()
77
+ @click.option(
78
+ "-V", "--version",
79
+ is_flag=True,
80
+ is_eager=True,
81
+ expose_value=False,
82
+ callback=_print_version,
83
+ help="Show the browser-cli version and exit.",
84
+ )
85
+ @click.option(
86
+ "--browser", default=None, metavar="ALIAS",
87
+ help="Browser profile alias to target (required when multiple browsers are active).",
88
+ )
89
+ @click.option(
90
+ "--remote", default=None, metavar="HOST[:PORT]",
91
+ help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
92
+ )
93
+ @click.option(
94
+ "--key", default=None, metavar="PATH",
95
+ help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
96
+ )
97
+ @click.pass_context
98
+ def main(ctx, browser, remote, key):
99
+ """Control your running browser from the terminal via a Chrome extension."""
100
+ ctx.ensure_object(dict)
101
+ ctx.obj["browser"] = browser
102
+ ctx.obj["browser_explicit"] = browser is not None
103
+ if browser:
104
+ os.environ["BROWSER_CLI_PROFILE"] = browser
105
+ ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
106
+ ctx.obj["remote"] = remote
107
+ ctx.obj["key"] = key
108
+ if remote:
109
+ os.environ["BROWSER_CLI_REMOTE"] = remote
110
+ ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
111
+ if key:
112
+ os.environ["BROWSER_CLI_KEY"] = key
113
+ ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
114
+
115
+ # ── Sub-command groups ─────────────────────────────────────────────────────────
116
+ main.add_command(auth_group)
117
+ main.add_command(nav_group)
118
+ main.add_command(tabs_group)
119
+ main.add_command(group_group)
120
+ main.add_command(windows_group)
121
+ main.add_command(dom_group)
122
+ main.add_command(extract_group)
123
+ main.add_command(session_group)
124
+ main.add_command(search_group)
125
+ main.add_command(page_group)
126
+ main.add_command(storage_group)
127
+ main.add_command(perf_group)
128
+ main.add_command(extension_group)
129
+ main.add_command(cmd_serve)
130
+ main.add_command(clients_group)
131
+ main.add_command(cmd_completion)
132
+ main.add_command(cmd_install)
133
+ main.add_command(cmd_doctor)
134
+ main.add_command(cmd_events)
135
+ main.add_command(remote_group)
136
+ main.add_command(cmd_script)
137
+ main.add_command(cmd_serve_http)
138
+ main.add_command(watch_group)
139
+ main.add_command(workspace_group)
140
+ main.add_command(cmd_command)
141
+
142
+ # ── native-host (hidden, called by Chrome via native messaging) ────────────────
143
+
144
+ @main.command("native-host", hidden=True)
145
+ def cmd_native_host():
146
+ """Native messaging host — called by Chrome, not for direct use."""
147
+ from browser_cli.native.host import main as _main
148
+ _main()
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,47 @@
1
+ """Client-side command routing and BrowserTarget helpers."""
2
+ from browser_cli.client.targets import REGISTRY_PATH, is_active_local_profile, resolve_socket
3
+ from browser_cli.client.core import (
4
+ BrowserNotConnected,
5
+ BrowserTarget,
6
+ _remote_browser_targets,
7
+ _send_remote,
8
+ _send_remote_async,
9
+ active_browser_targets,
10
+ remote_browser_targets,
11
+ remote_browser_targets_async,
12
+ remote_target_for_alias,
13
+ send_command,
14
+ send_command_async,
15
+ )
16
+ from browser_cli.endpoints import (
17
+ _looks_like_domain,
18
+ _normalize_endpoint,
19
+ _remote_display_name,
20
+ _resolve_connect_endpoint,
21
+ display_browser_name,
22
+ )
23
+ from browser_cli.remote.transport import _recv_all, _recv_exact
24
+
25
+ __all__ = [
26
+ "BrowserNotConnected",
27
+ "BrowserTarget",
28
+ "REGISTRY_PATH",
29
+ "is_active_local_profile",
30
+ "_looks_like_domain",
31
+ "_normalize_endpoint",
32
+ "_recv_all",
33
+ "_recv_exact",
34
+ "_remote_browser_targets",
35
+ "_remote_display_name",
36
+ "_resolve_connect_endpoint",
37
+ "resolve_socket",
38
+ "_send_remote",
39
+ "_send_remote_async",
40
+ "active_browser_targets",
41
+ "display_browser_name",
42
+ "remote_browser_targets",
43
+ "remote_browser_targets_async",
44
+ "remote_target_for_alias",
45
+ "send_command",
46
+ "send_command_async",
47
+ ]
@@ -0,0 +1,63 @@
1
+ """Remote client auth/key preparation for browser command messages."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from browser_cli.remote import registry as remote_registry
9
+ from browser_cli.constants import DEFAULT_KEY_PATH, NO_ROUTE_COMMANDS
10
+ from browser_cli.version_manager import USER_AGENT
11
+
12
+ def load_private_key(key_path: Path | str | None = None):
13
+ """Load an Ed25519 signing key from file or SSH agent spec."""
14
+ raw = str(key_path) if key_path is not None else os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
15
+
16
+ if raw == "agent" or raw.startswith("agent:"):
17
+ selector = raw[6:] or None
18
+ from browser_cli.auth import agent_find_key
19
+ return agent_find_key(selector)
20
+
21
+ path = Path(raw)
22
+ if not path.exists():
23
+ return None
24
+ try:
25
+ from browser_cli.auth import load_private_key as load_pem_key
26
+ return load_pem_key(path)
27
+ except Exception:
28
+ return None
29
+
30
+ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | None, remote_endpoint: str, key, auto_router) -> object:
31
+ """Mutate *msg* with remote auth/routing fields and return the signing key."""
32
+ from browser_cli import transport
33
+
34
+ msg["user_agent"] = USER_AGENT
35
+ msg["accept_encoding"] = transport.client_accept_encoding()
36
+ key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
37
+ private_key = load_private_key(key_spec)
38
+ if key is not None:
39
+ remote_registry.save_remote_key(remote_endpoint, str(key))
40
+
41
+ route_profile = requested_profile
42
+ if not route_profile and command not in NO_ROUTE_COMMANDS:
43
+ route_profile = auto_router(remote_endpoint, key=key_spec)
44
+ if route_profile:
45
+ msg["_route"] = route_profile
46
+ return private_key
47
+
48
+ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profile: str | None, remote_endpoint: str, key, auto_router) -> object:
49
+ from browser_cli import transport
50
+
51
+ msg["user_agent"] = USER_AGENT
52
+ msg["accept_encoding"] = transport.client_accept_encoding()
53
+ key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
54
+ private_key = await asyncio.to_thread(load_private_key, key_spec)
55
+ if key is not None:
56
+ await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
57
+
58
+ route_profile = requested_profile
59
+ if not route_profile and command not in NO_ROUTE_COMMANDS:
60
+ route_profile = await auto_router(remote_endpoint, key=key_spec)
61
+ if route_profile:
62
+ msg["_route"] = route_profile
63
+ return private_key
@@ -0,0 +1,200 @@
1
+ """
2
+ Local IPC client — sends commands to native host relay endpoint.
3
+ Used by both CLI and public Python API.
4
+
5
+ Profile selection order:
6
+ 1. Explicit `profile` argument to send_command()
7
+ 2. BROWSER_CLI_PROFILE environment variable
8
+ 3. First entry in runtime registry
9
+ 4. Otherwise, no browser can be resolved automatically
10
+ """
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ from browser_cli import local_transport
15
+ from browser_cli.client import auth, messages, targets as target_discovery
16
+ from browser_cli.client.targets import BrowserTarget
17
+ from browser_cli.remote import registry as remote_registry
18
+
19
+ from browser_cli.errors import BrowserNotConnected
20
+ from browser_cli.endpoints import _remote_display_name
21
+ from browser_cli.remote.transport import _send_remote, _send_remote_async
22
+
23
+ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
24
+ targets: list[BrowserTarget] = []
25
+ for item in items or []:
26
+ profile = str(item.get("profile") or "default")
27
+ display = str(item.get("displayName") or profile)
28
+ targets.append(
29
+ BrowserTarget(
30
+ profile=profile,
31
+ display_name=_remote_display_name(endpoint, profile, display),
32
+ socket_path="",
33
+ remote=endpoint,
34
+ )
35
+ )
36
+ return targets
37
+
38
+ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
39
+ """Return browser targets advertised by a single remote endpoint."""
40
+ kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
41
+ return _remote_target_items(
42
+ endpoint,
43
+ send_command("browser-cli.targets", remote=endpoint, key=key, **kwargs),
44
+ )
45
+
46
+ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
47
+ targets: list[BrowserTarget] = []
48
+ for endpoint in remote_registry.load_remotes():
49
+ try:
50
+ targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
51
+ except (BrowserNotConnected, RuntimeError):
52
+ continue
53
+ return targets
54
+
55
+ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
56
+ """Resolve a user-facing remote alias such as 'host:profile' to a target."""
57
+ if not alias:
58
+ return None
59
+ targets = _remote_browser_targets()
60
+ for target in targets:
61
+ endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
62
+ if alias in {target.display_name, endpoint_profile}:
63
+ return target
64
+
65
+ endpoint_matches = []
66
+ for target in targets:
67
+ if not target.remote:
68
+ continue
69
+ remote_host, sep, _remote_port = target.remote.rpartition(":")
70
+ if alias == target.remote or (sep and alias == remote_host):
71
+ endpoint_matches.append(target)
72
+ if len(endpoint_matches) == 1:
73
+ return endpoint_matches[0]
74
+ if len(endpoint_matches) > 1:
75
+ aliases = [target.profile for target in endpoint_matches]
76
+ endpoint = endpoint_matches[0].remote or alias
77
+ examples = "\n".join(
78
+ f" browser-cli --remote {endpoint} --browser {a} ..."
79
+ for a in aliases
80
+ )
81
+ display_aliases = [target.display_name for target in endpoint_matches]
82
+ shorthand_examples = "\n".join(
83
+ f" browser-cli --browser {a} ..."
84
+ for a in display_aliases
85
+ )
86
+ raise BrowserNotConnected(
87
+ f"Multiple remote browser instances are active on {alias}: {', '.join(aliases)}\n"
88
+ f"Use --browser <alias> with --remote to select one:\n{examples}\n"
89
+ f"Or use the full remote browser alias:\n{shorthand_examples}"
90
+ )
91
+ return None
92
+
93
+ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
94
+ targets = target_discovery.active_local_browser_targets()
95
+ if include_remotes:
96
+ targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
97
+ return targets
98
+
99
+ def _auto_route_remote(endpoint: str, key=None) -> str | None:
100
+ targets = remote_browser_targets(endpoint, key=key)
101
+ if len(targets) == 1:
102
+ return targets[0].profile
103
+ if len(targets) > 1:
104
+ aliases = [target.profile for target in targets]
105
+ examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
106
+ raise BrowserNotConnected(
107
+ f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
108
+ f"Use --browser <alias> to select one:\n{examples}"
109
+ )
110
+ return None
111
+
112
+ def send_command(
113
+ command: str,
114
+ args: dict | None = None,
115
+ profile: str | None = None,
116
+ remote: str | None = None,
117
+ key: "Path | None" = None,
118
+ *,
119
+ suppress_pq_warning: bool = False,
120
+ ) -> Any:
121
+ """Send a command to the browser and return the response data."""
122
+ requested_profile, remote_endpoint = messages.requested_target(profile, remote)
123
+ if not remote_endpoint and requested_profile and not target_discovery.is_active_local_profile(requested_profile):
124
+ if remote_alias_target := remote_target_for_alias(requested_profile):
125
+ remote_endpoint = remote_alias_target.remote
126
+ requested_profile = remote_alias_target.profile
127
+
128
+ msg = messages.base_message(command, args)
129
+ private_key = None
130
+ if remote_endpoint:
131
+ if suppress_pq_warning:
132
+ msg["_suppress_pq_warning"] = True
133
+ private_key = auth.add_remote_auth_fields(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote)
134
+
135
+ try:
136
+ payload = messages.encode_payload(msg)
137
+ response = (
138
+ _send_remote(remote_endpoint, msg, private_key)
139
+ if remote_endpoint
140
+ else local_transport.send_local_sync(requested_profile, payload, target_discovery.resolve_socket)
141
+ )
142
+ except (FileNotFoundError, ConnectionRefusedError, OSError):
143
+ raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
144
+
145
+ return messages.decode_response(response)
146
+
147
+ async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
148
+ """Async variant of :func:`remote_browser_targets`."""
149
+ return _remote_target_items(
150
+ endpoint,
151
+ await send_command_async("browser-cli.targets", remote=endpoint, key=key),
152
+ )
153
+
154
+ async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
155
+ targets = await remote_browser_targets_async(endpoint, key=key)
156
+ if len(targets) == 1:
157
+ return targets[0].profile
158
+ if len(targets) > 1:
159
+ aliases = [target.profile for target in targets]
160
+ examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
161
+ raise BrowserNotConnected(
162
+ f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
163
+ f"Use --browser <alias> to select one:\n{examples}"
164
+ )
165
+ return None
166
+
167
+ async def send_command_async(
168
+ command: str,
169
+ args: dict | None = None,
170
+ profile: str | None = None,
171
+ remote: str | None = None,
172
+ key: "Path | None" = None,
173
+ *,
174
+ suppress_pq_warning: bool = False,
175
+ ) -> Any:
176
+ """Async variant of :func:`send_command` with native async socket/TCP paths."""
177
+ requested_profile, remote_endpoint = messages.requested_target(profile, remote)
178
+ if not remote_endpoint and requested_profile and not await asyncio.to_thread(target_discovery.is_active_local_profile, requested_profile):
179
+ if remote_alias_target := await asyncio.to_thread(remote_target_for_alias, requested_profile):
180
+ remote_endpoint = remote_alias_target.remote
181
+ requested_profile = remote_alias_target.profile
182
+
183
+ msg = messages.base_message(command, args)
184
+ private_key = None
185
+ if remote_endpoint:
186
+ if suppress_pq_warning:
187
+ msg["_suppress_pq_warning"] = True
188
+ private_key = await auth.add_remote_auth_fields_async(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote_async)
189
+
190
+ try:
191
+ payload = messages.encode_payload(msg)
192
+ response = (
193
+ await _send_remote_async(remote_endpoint, msg, private_key)
194
+ if remote_endpoint
195
+ else await local_transport.send_local_async(requested_profile, payload, target_discovery.resolve_socket)
196
+ )
197
+ except (FileNotFoundError, ConnectionRefusedError, OSError):
198
+ raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile)
199
+
200
+ return messages.decode_response(response)
@@ -0,0 +1,45 @@
1
+ """Command message/response helpers shared by sync and async clients."""
2
+ import json
3
+ import os
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from browser_cli import transport
8
+ from browser_cli.endpoints import _normalize_endpoint
9
+ from browser_cli.errors import BrowserNotConnected
10
+
11
+ def base_message(command: str, args: dict | None) -> dict:
12
+ return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
13
+
14
+ def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
15
+ requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
16
+ remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
17
+ return requested_profile, _normalize_endpoint(remote_endpoint) if remote_endpoint else None
18
+
19
+ def encode_payload(msg: dict) -> bytes:
20
+ return json.dumps(msg).encode("utf-8")
21
+
22
+ def decode_response(response: bytes | None) -> Any:
23
+ if response is None:
24
+ raise ConnectionError("Connection closed before full response received")
25
+ result = transport.decode_response(response)
26
+ if not result.get("success", True):
27
+ raise RuntimeError(result.get("error", "unknown error from browser"))
28
+ return result.get("data")
29
+
30
+ def local_connection_error(profile: str | None) -> BrowserNotConnected:
31
+ profile_hint = f" (profile: {profile})" if profile else ""
32
+ return BrowserNotConnected(
33
+ f"Cannot connect to browser{profile_hint}.\n"
34
+ "Make sure:\n"
35
+ " 1. The browser-cli extension is installed and enabled\n"
36
+ " 2. The native host is registered: uv run browser-cli install <browser>\n"
37
+ " 3. Your browser is running\n"
38
+ " Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
39
+ )
40
+
41
+ def remote_connection_error(remote_endpoint: str) -> BrowserNotConnected:
42
+ return BrowserNotConnected(
43
+ f"Cannot connect to remote browser at {remote_endpoint}.\n"
44
+ "Make sure browser-cli serve is running on the remote host."
45
+ )
@@ -0,0 +1,95 @@
1
+ """Browser target discovery and local socket resolution."""
2
+ import os
3
+ import socket
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from browser_cli.endpoints import display_browser_name
8
+ from browser_cli.errors import BrowserNotConnected
9
+ from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
10
+ from browser_cli.registry import load_registry
11
+
12
+ REGISTRY_PATH = registry_path()
13
+
14
+ @dataclass(frozen=True)
15
+ class BrowserTarget:
16
+ profile: str
17
+ display_name: str
18
+ socket_path: str
19
+ remote: str | None = None
20
+
21
+ def is_reachable_unix_endpoint(endpoint: str) -> bool:
22
+ """Return True when a Unix socket path exists and accepts connections."""
23
+ path = Path(endpoint)
24
+ if not path.exists():
25
+ return False
26
+ try:
27
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
28
+ sock.settimeout(0.2)
29
+ sock.connect(endpoint)
30
+ return True
31
+ except OSError:
32
+ return False
33
+
34
+ def active_endpoints(reg: dict) -> dict:
35
+ """Return only entries whose endpoint appears reachable."""
36
+ if is_windows():
37
+ return dict(reg)
38
+ return {k: v for k, v in reg.items() if is_reachable_unix_endpoint(v)}
39
+
40
+ def active_local_browser_targets() -> list[BrowserTarget]:
41
+ if not REGISTRY_PATH.exists():
42
+ return []
43
+ reg = load_registry(REGISTRY_PATH)
44
+ return [
45
+ BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
46
+ for profile, sock_path in active_endpoints(reg).items()
47
+ ]
48
+
49
+ def is_active_local_profile(profile: str | None) -> bool:
50
+ """Return True when profile names a reachable local browser endpoint."""
51
+ if not profile:
52
+ return False
53
+ if REGISTRY_PATH.exists():
54
+ reg = load_registry(REGISTRY_PATH)
55
+ if profile in active_endpoints(reg):
56
+ return True
57
+ if not is_windows():
58
+ try:
59
+ return Path(endpoint_for_alias(profile)).exists()
60
+ except Exception:
61
+ return False
62
+ return False
63
+
64
+ def resolve_socket(profile: str | None = None) -> str:
65
+ """Return the socket path for the given profile (or auto-detect)."""
66
+ target = profile or os.environ.get("BROWSER_CLI_PROFILE")
67
+
68
+ if target:
69
+ if REGISTRY_PATH.exists():
70
+ reg = load_registry(REGISTRY_PATH)
71
+ if target in reg:
72
+ return reg[target]
73
+ return endpoint_for_alias(target)
74
+
75
+ try:
76
+ active = active_local_browser_targets()
77
+ if len(active) > 1:
78
+ aliases = [target.profile for target in active]
79
+ examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
80
+ raise BrowserNotConnected(
81
+ f"Multiple browser instances are active: {', '.join(aliases)}\n"
82
+ f"Use --browser <alias> to select one:\n{examples}"
83
+ )
84
+ if active:
85
+ return active[0].socket_path
86
+ except BrowserNotConnected:
87
+ raise
88
+ except Exception:
89
+ pass
90
+
91
+ raise BrowserNotConnected(
92
+ "Cannot resolve a browser socket automatically.\n"
93
+ "Make sure the browser is running with the browser-cli extension enabled,\n"
94
+ "or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
95
+ )