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.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|