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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Safety policy for generic command execution surfaces.
|
|
2
|
+
|
|
3
|
+
Dedicated first-party CLI/SDK methods keep their normal behavior. This module
|
|
4
|
+
only gates raw surfaces where a single string can trigger arbitrary browser
|
|
5
|
+
capabilities: ``browser-cli command``, ``browser-cli script``, and the HTTP
|
|
6
|
+
``/command`` endpoint.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
SAFE_COMMANDS = {
|
|
13
|
+
"clients.list",
|
|
14
|
+
"extension.capabilities",
|
|
15
|
+
"extension.info",
|
|
16
|
+
"group.list",
|
|
17
|
+
"group.query",
|
|
18
|
+
"group.tabs",
|
|
19
|
+
"page.info",
|
|
20
|
+
"perf.status",
|
|
21
|
+
"tabs.active_in_window",
|
|
22
|
+
"tabs.count",
|
|
23
|
+
"tabs.filter",
|
|
24
|
+
"tabs.list",
|
|
25
|
+
"tabs.query",
|
|
26
|
+
"tabs.status",
|
|
27
|
+
"windows.list",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
READ_PAGE_COMMANDS = {
|
|
31
|
+
"dom.attr",
|
|
32
|
+
"dom.exists",
|
|
33
|
+
"dom.query",
|
|
34
|
+
"dom.text",
|
|
35
|
+
"extract.html",
|
|
36
|
+
"extract.images",
|
|
37
|
+
"extract.json",
|
|
38
|
+
"extract.links",
|
|
39
|
+
"extract.markdown",
|
|
40
|
+
"extract.text",
|
|
41
|
+
"tabs.html",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CONTROL_PREFIXES = (
|
|
45
|
+
"navigate.",
|
|
46
|
+
"nav.",
|
|
47
|
+
"group.",
|
|
48
|
+
"session.",
|
|
49
|
+
"tabs.",
|
|
50
|
+
"windows.",
|
|
51
|
+
)
|
|
52
|
+
CONTROL_COMMANDS = {
|
|
53
|
+
"dom.check",
|
|
54
|
+
"dom.clear",
|
|
55
|
+
"dom.click",
|
|
56
|
+
"dom.focus",
|
|
57
|
+
"dom.hover",
|
|
58
|
+
"dom.key",
|
|
59
|
+
"dom.poll",
|
|
60
|
+
"dom.scroll",
|
|
61
|
+
"dom.select",
|
|
62
|
+
"dom.submit",
|
|
63
|
+
"dom.type",
|
|
64
|
+
"dom.uncheck",
|
|
65
|
+
"dom.wait_for",
|
|
66
|
+
"extension.reload",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DANGEROUS_COMMANDS = {
|
|
70
|
+
"dom.eval",
|
|
71
|
+
"tabs.screenshot",
|
|
72
|
+
}
|
|
73
|
+
DANGEROUS_PREFIXES = (
|
|
74
|
+
"storage.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class CommandPolicy:
|
|
79
|
+
allow_read_page: bool = False
|
|
80
|
+
allow_control: bool = False
|
|
81
|
+
allow_dangerous: bool = False
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def unrestricted(cls) -> "CommandPolicy":
|
|
85
|
+
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
|
86
|
+
|
|
87
|
+
def _is_control(command: str) -> bool:
|
|
88
|
+
if command in CONTROL_COMMANDS:
|
|
89
|
+
return True
|
|
90
|
+
if any(command.startswith(prefix) for prefix in CONTROL_PREFIXES):
|
|
91
|
+
return command not in SAFE_COMMANDS and command not in READ_PAGE_COMMANDS and command not in DANGEROUS_COMMANDS
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
def command_category(command: str) -> str:
|
|
95
|
+
name = str(command or "")
|
|
96
|
+
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
|
|
97
|
+
return "dangerous"
|
|
98
|
+
if name in READ_PAGE_COMMANDS:
|
|
99
|
+
return "read-page"
|
|
100
|
+
if name in SAFE_COMMANDS:
|
|
101
|
+
return "safe"
|
|
102
|
+
if _is_control(name):
|
|
103
|
+
return "control"
|
|
104
|
+
return "unknown"
|
|
105
|
+
|
|
106
|
+
def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
|
|
107
|
+
category = command_category(command)
|
|
108
|
+
if category == "safe":
|
|
109
|
+
return
|
|
110
|
+
if category == "read-page" and policy.allow_read_page:
|
|
111
|
+
return
|
|
112
|
+
if category == "control" and policy.allow_control:
|
|
113
|
+
return
|
|
114
|
+
if category == "dangerous" and policy.allow_dangerous:
|
|
115
|
+
return
|
|
116
|
+
raise PermissionError(
|
|
117
|
+
f"Raw command '{command}' is {category} and blocked by default; "
|
|
118
|
+
"use --allow-read-page, --allow-control, or --allow-dangerous explicitly"
|
|
119
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared helpers for the Click CLI command modules.
|
|
2
|
+
|
|
3
|
+
Every CLI command is a thin presentation layer over the Python SDK: it builds a
|
|
4
|
+
:class:`~browser_cli.BrowserCLI` from the global ``--browser/--remote/--key``
|
|
5
|
+
options, calls the matching SDK namespace method, and renders the result. The
|
|
6
|
+
SDK is the single source of truth for command strings, argument shapes, and
|
|
7
|
+
multi-browser routing.
|
|
8
|
+
"""
|
|
9
|
+
import functools
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from browser_cli import BrowserCLI, BrowserCounts
|
|
15
|
+
from browser_cli.client import BrowserNotConnected
|
|
16
|
+
from browser_cli.constants import GENTLE_MODES
|
|
17
|
+
|
|
18
|
+
_console = Console()
|
|
19
|
+
|
|
20
|
+
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
|
|
21
|
+
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gentle_mode_option(help_text: str):
|
|
25
|
+
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
|
|
26
|
+
return click.option(
|
|
27
|
+
"--gentle-mode",
|
|
28
|
+
type=click.Choice(GENTLE_MODES),
|
|
29
|
+
default="auto",
|
|
30
|
+
show_default=True,
|
|
31
|
+
help=help_text,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
|
35
|
+
"""Render a count result.
|
|
36
|
+
|
|
37
|
+
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
|
|
38
|
+
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(result, BrowserCounts):
|
|
41
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
42
|
+
table.add_column("Browser")
|
|
43
|
+
table.add_column(f"{noun.capitalize()}s", justify="right")
|
|
44
|
+
for name, count in result.by_browser.items():
|
|
45
|
+
table.add_row(name, str(count))
|
|
46
|
+
table.add_row("Total", str(result.total))
|
|
47
|
+
_console.print(table)
|
|
48
|
+
else:
|
|
49
|
+
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
|
|
50
|
+
|
|
51
|
+
def client_from_ctx() -> BrowserCLI:
|
|
52
|
+
"""Build a BrowserCLI from the root context's global options.
|
|
53
|
+
|
|
54
|
+
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
|
|
55
|
+
Falls back to an unconfigured client when a command group is invoked
|
|
56
|
+
standalone (e.g. in unit tests).
|
|
57
|
+
"""
|
|
58
|
+
obj = click.get_current_context().find_root().obj or {}
|
|
59
|
+
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
|
|
60
|
+
|
|
61
|
+
def handle_errors(fn):
|
|
62
|
+
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
|
|
63
|
+
|
|
64
|
+
Apply as the innermost decorator (directly above ``def``) so Click's option
|
|
65
|
+
decorators attach their params to the wrapper.
|
|
66
|
+
"""
|
|
67
|
+
@functools.wraps(fn)
|
|
68
|
+
def wrapper(*args, **kwargs):
|
|
69
|
+
try:
|
|
70
|
+
return fn(*args, **kwargs)
|
|
71
|
+
except BrowserNotConnected as e:
|
|
72
|
+
_console.print(f"[red]Error:[/red] {e}")
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
except PermissionError as e:
|
|
75
|
+
_console.print(f"[red]Blocked:[/red] {e}")
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
except RuntimeError as e:
|
|
78
|
+
_console.print(f"[red]Browser error:[/red] {e}")
|
|
79
|
+
raise SystemExit(1)
|
|
80
|
+
|
|
81
|
+
return wrapper
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Click commands for browser-cli remote authentication keys."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
@click.group("auth")
|
|
14
|
+
def auth_group():
|
|
15
|
+
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
|
|
16
|
+
|
|
17
|
+
@auth_group.command("keygen")
|
|
18
|
+
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
|
|
19
|
+
@click.option("--force", is_flag=True, help="Overwrite existing key.")
|
|
20
|
+
def cmd_auth_keygen(output, force):
|
|
21
|
+
"""Generate an Ed25519 keypair for pubkey auth."""
|
|
22
|
+
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
|
|
23
|
+
|
|
24
|
+
key_path = Path(output) if output else DEFAULT_KEY_PATH
|
|
25
|
+
if key_path.exists() and not force:
|
|
26
|
+
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
pem, pub_hex = generate_keypair()
|
|
29
|
+
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
31
|
+
with os.fdopen(fd, "wb") as f:
|
|
32
|
+
f.write(pem)
|
|
33
|
+
console.print(f"[green]✓[/green] Private key: {key_path}")
|
|
34
|
+
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
|
|
35
|
+
console.print("\nOn the serve host, trust this key:")
|
|
36
|
+
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
|
|
37
|
+
|
|
38
|
+
@auth_group.command("trust")
|
|
39
|
+
@click.argument("pubkey")
|
|
40
|
+
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
|
41
|
+
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
|
44
|
+
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
|
|
45
|
+
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
|
46
|
+
|
|
47
|
+
if len(pubkey) != 64:
|
|
48
|
+
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
try:
|
|
51
|
+
bytes.fromhex(pubkey)
|
|
52
|
+
except ValueError:
|
|
53
|
+
console.print("[red]Invalid public key:[/red] not valid hex")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
remote = (ctx.obj or {}).get("remote")
|
|
57
|
+
if remote:
|
|
58
|
+
from browser_cli.client import send_command
|
|
59
|
+
result = send_command(
|
|
60
|
+
"browser-cli.auth.trust",
|
|
61
|
+
args={"pubkey": pubkey, "name": name},
|
|
62
|
+
remote=remote,
|
|
63
|
+
key=(ctx.obj or {}).get("key"),
|
|
64
|
+
)
|
|
65
|
+
added = (result or {}).get("added", False)
|
|
66
|
+
label = f" ({name})" if name else ""
|
|
67
|
+
if added:
|
|
68
|
+
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
|
|
69
|
+
else:
|
|
70
|
+
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
|
74
|
+
added = add_authorized_key(path, pubkey, name)
|
|
75
|
+
label = f" ({name})" if name else ""
|
|
76
|
+
if added:
|
|
77
|
+
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
|
78
|
+
console.print(f" File: {path}")
|
|
79
|
+
console.print("\nStart the server with:")
|
|
80
|
+
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
|
81
|
+
else:
|
|
82
|
+
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
|
83
|
+
|
|
84
|
+
@auth_group.command("show")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--key",
|
|
87
|
+
"key_src",
|
|
88
|
+
default=None,
|
|
89
|
+
metavar="PATH|agent[:<selector>]",
|
|
90
|
+
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.",
|
|
91
|
+
)
|
|
92
|
+
def cmd_auth_show(key_src):
|
|
93
|
+
"""Print the Ed25519 public key that browser-cli will use for auth."""
|
|
94
|
+
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
|
|
95
|
+
|
|
96
|
+
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
|
|
97
|
+
|
|
98
|
+
if src == "agent" or src.startswith("agent:"):
|
|
99
|
+
selector = src[6:] or None
|
|
100
|
+
key = agent_find_key(selector)
|
|
101
|
+
if key is None:
|
|
102
|
+
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
|
|
103
|
+
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
console.print(f"[dim]source:[/dim] agent ({key.comment})")
|
|
106
|
+
console.print(public_key_hex(key))
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
path = Path(src)
|
|
110
|
+
if not path.exists():
|
|
111
|
+
console.print(f"[red]No key found at {path}[/red]")
|
|
112
|
+
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
|
|
113
|
+
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
try:
|
|
116
|
+
priv = load_private_key(path)
|
|
117
|
+
console.print(f"[dim]source:[/dim] {path}")
|
|
118
|
+
console.print(public_key_hex(priv))
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[red]Failed to load key:[/red] {e}")
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
@auth_group.command("keys")
|
|
124
|
+
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
|
125
|
+
@click.pass_context
|
|
126
|
+
def cmd_auth_keys(ctx, keys_file):
|
|
127
|
+
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
|
128
|
+
from rich.table import Table
|
|
129
|
+
|
|
130
|
+
remote = (ctx.obj or {}).get("remote")
|
|
131
|
+
if remote:
|
|
132
|
+
from browser_cli.client import send_command
|
|
133
|
+
result = send_command(
|
|
134
|
+
"browser-cli.auth.keys",
|
|
135
|
+
remote=remote,
|
|
136
|
+
key=(ctx.obj or {}).get("key"),
|
|
137
|
+
)
|
|
138
|
+
entries = result or []
|
|
139
|
+
source_label = remote
|
|
140
|
+
else:
|
|
141
|
+
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
|
|
142
|
+
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
|
143
|
+
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
|
|
144
|
+
source_label = str(path)
|
|
145
|
+
|
|
146
|
+
if not entries:
|
|
147
|
+
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
|
|
148
|
+
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
152
|
+
table.add_column("Name")
|
|
153
|
+
table.add_column("Public Key")
|
|
154
|
+
for entry in entries:
|
|
155
|
+
name = entry.get("name") or "[dim]—[/dim]"
|
|
156
|
+
table.add_row(name, entry.get("pubkey", ""))
|
|
157
|
+
console.print(table)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Click commands for inspecting connected browser clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from browser_cli.client import (
|
|
11
|
+
BrowserNotConnected,
|
|
12
|
+
REGISTRY_PATH,
|
|
13
|
+
active_browser_targets,
|
|
14
|
+
display_browser_name,
|
|
15
|
+
remote_browser_targets,
|
|
16
|
+
remote_target_for_alias,
|
|
17
|
+
send_command,
|
|
18
|
+
)
|
|
19
|
+
from browser_cli.registry import load_registry
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
def _rename_target_profile(target_browser: str | None) -> str | None:
|
|
24
|
+
if target_browser:
|
|
25
|
+
return target_browser
|
|
26
|
+
|
|
27
|
+
active = active_browser_targets()
|
|
28
|
+
if len(active) == 1:
|
|
29
|
+
return active[0].profile
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
|
|
33
|
+
target_profile = _rename_target_profile(target_browser)
|
|
34
|
+
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
|
|
35
|
+
|
|
36
|
+
if alias in profiles and alias != target_profile:
|
|
37
|
+
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
|
38
|
+
|
|
39
|
+
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
|
|
40
|
+
"""Query clients.list for one target and append each, tagged with *label*."""
|
|
41
|
+
if quiet_remote_warning:
|
|
42
|
+
result = send_command(
|
|
43
|
+
"clients.list",
|
|
44
|
+
profile=profile,
|
|
45
|
+
remote=remote,
|
|
46
|
+
key=key,
|
|
47
|
+
suppress_pq_warning=True,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
|
51
|
+
for c in (result or []):
|
|
52
|
+
c["profile"] = label
|
|
53
|
+
into.append(c)
|
|
54
|
+
|
|
55
|
+
@click.group("clients", invoke_without_command=True)
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def clients_group(ctx):
|
|
58
|
+
"""Inspect and manage connected browser clients."""
|
|
59
|
+
if ctx.invoked_subcommand is not None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
all_clients = []
|
|
63
|
+
|
|
64
|
+
browser_alias = (ctx.obj or {}).get("browser")
|
|
65
|
+
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
|
66
|
+
key = (ctx.obj or {}).get("key")
|
|
67
|
+
|
|
68
|
+
if not remote and browser_alias:
|
|
69
|
+
_collect_remote_alias_clients(all_clients, browser_alias, key)
|
|
70
|
+
elif remote:
|
|
71
|
+
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
|
|
72
|
+
else:
|
|
73
|
+
_collect_local_and_saved_remote_clients(all_clients)
|
|
74
|
+
|
|
75
|
+
if not all_clients:
|
|
76
|
+
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
_print_clients(all_clients)
|
|
80
|
+
|
|
81
|
+
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
|
|
82
|
+
resolved = remote_target_for_alias(browser_alias)
|
|
83
|
+
if not resolved:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
targets = remote_browser_targets(resolved.remote)
|
|
87
|
+
except (BrowserNotConnected, RuntimeError) as e:
|
|
88
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
for target in targets:
|
|
91
|
+
try:
|
|
92
|
+
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
|
93
|
+
except (BrowserNotConnected, RuntimeError):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
|
|
97
|
+
try:
|
|
98
|
+
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
|
99
|
+
for c in (result or []):
|
|
100
|
+
c["profile"] = c.get("profile") or browser_alias or "remote"
|
|
101
|
+
all_clients.append(c)
|
|
102
|
+
except (BrowserNotConnected, RuntimeError) as e:
|
|
103
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
|
107
|
+
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
|
|
108
|
+
|
|
109
|
+
for profile_name, sock_path in profiles.items():
|
|
110
|
+
display_profile = display_browser_name(profile_name, sock_path)
|
|
111
|
+
try:
|
|
112
|
+
_append_clients(all_clients, display_profile, profile=profile_name)
|
|
113
|
+
except (BrowserNotConnected, RuntimeError):
|
|
114
|
+
all_clients.append({
|
|
115
|
+
"profile": display_profile,
|
|
116
|
+
"name": "—",
|
|
117
|
+
"version": "—",
|
|
118
|
+
"extensionVersion": "disconnected",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
targets = active_browser_targets(suppress_pq_warning=True)
|
|
122
|
+
|
|
123
|
+
for target in targets:
|
|
124
|
+
if target.remote is None:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
_append_clients(
|
|
128
|
+
all_clients,
|
|
129
|
+
target.display_name,
|
|
130
|
+
profile=target.profile,
|
|
131
|
+
remote=target.remote,
|
|
132
|
+
quiet_remote_warning=True,
|
|
133
|
+
)
|
|
134
|
+
except (BrowserNotConnected, RuntimeError):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
def _print_clients(all_clients: list) -> None:
|
|
138
|
+
from rich.table import Table
|
|
139
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
140
|
+
table.add_column("Profile")
|
|
141
|
+
table.add_column("Browser")
|
|
142
|
+
table.add_column("Version")
|
|
143
|
+
table.add_column("Extension Version")
|
|
144
|
+
for c in all_clients:
|
|
145
|
+
table.add_row(
|
|
146
|
+
c.get("profile", ""),
|
|
147
|
+
c.get("name", ""),
|
|
148
|
+
c.get("version", ""),
|
|
149
|
+
c.get("extensionVersion", ""),
|
|
150
|
+
)
|
|
151
|
+
console.print(table)
|
|
152
|
+
|
|
153
|
+
@clients_group.command("rename")
|
|
154
|
+
@click.option(
|
|
155
|
+
"--browser",
|
|
156
|
+
"target_browser",
|
|
157
|
+
default=None,
|
|
158
|
+
metavar="ALIAS",
|
|
159
|
+
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
|
160
|
+
)
|
|
161
|
+
@click.argument("alias")
|
|
162
|
+
@click.pass_context
|
|
163
|
+
def cmd_clients_rename(ctx, target_browser, alias):
|
|
164
|
+
"""Set the profile alias used to identify this browser instance."""
|
|
165
|
+
root_obj = ctx.find_root().obj or {}
|
|
166
|
+
selected_browser = target_browser or root_obj.get("browser")
|
|
167
|
+
try:
|
|
168
|
+
_ensure_unique_browser_alias(alias, selected_browser)
|
|
169
|
+
send_command("clients.rename_profile", {"alias": alias}, profile=selected_browser)
|
|
170
|
+
except BrowserNotConnected as e:
|
|
171
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
console.print(f"[green]Profile renamed to '{alias}'[/green]")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shell completion command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
@click.command("completion")
|
|
13
|
+
@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"]))
|
|
14
|
+
@click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions")
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def cmd_completion(ctx, shell, script):
|
|
17
|
+
"""Print shell completion setup instructions (or output the script with --script)."""
|
|
18
|
+
if script:
|
|
19
|
+
from click.shell_completion import BashComplete, FishComplete, ZshComplete
|
|
20
|
+
cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell]
|
|
21
|
+
comp = cls(ctx.find_root().command, {}, "browser-cli", "_BROWSER_CLI_COMPLETE")
|
|
22
|
+
click.echo(comp.source())
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli")
|
|
26
|
+
if not Path(exe).exists():
|
|
27
|
+
exe = "browser-cli"
|
|
28
|
+
|
|
29
|
+
env_var = "_BROWSER_CLI_COMPLETE"
|
|
30
|
+
|
|
31
|
+
if shell == "zsh":
|
|
32
|
+
console.print("[bold]Quickest setup — generate the file once:[/bold]")
|
|
33
|
+
console.print()
|
|
34
|
+
console.print(" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]")
|
|
35
|
+
console.print()
|
|
36
|
+
console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):")
|
|
37
|
+
console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]")
|
|
38
|
+
console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]")
|
|
39
|
+
console.print()
|
|
40
|
+
console.print(" Reload: [cyan]exec zsh[/cyan]")
|
|
41
|
+
console.print()
|
|
42
|
+
console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]")
|
|
43
|
+
console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]')
|
|
44
|
+
elif shell == "bash":
|
|
45
|
+
console.print("[bold]Quickest setup — generate the file once:[/bold]")
|
|
46
|
+
console.print()
|
|
47
|
+
console.print(" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]")
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(" Reload: [cyan]source ~/.bashrc[/cyan]")
|
|
50
|
+
console.print()
|
|
51
|
+
console.print("[bold]Alternative — eval on every shell start:[/bold]")
|
|
52
|
+
console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]')
|
|
53
|
+
elif shell == "fish":
|
|
54
|
+
console.print("[bold]Setup:[/bold]")
|
|
55
|
+
console.print()
|
|
56
|
+
console.print(" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version as package_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from browser_cli.commands import handle_errors, client_from_ctx
|
|
13
|
+
from browser_cli.client import active_browser_targets
|
|
14
|
+
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME
|
|
15
|
+
from browser_cli.platform import is_windows
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
def _project_version() -> str:
|
|
20
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
21
|
+
try:
|
|
22
|
+
content = pyproject_path.read_text(encoding="utf-8")
|
|
23
|
+
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
|
24
|
+
if match:
|
|
25
|
+
return match.group(1)
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
try:
|
|
29
|
+
return package_version("browser-cli")
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
return "unknown"
|
|
32
|
+
|
|
33
|
+
def _status(ok: bool) -> str:
|
|
34
|
+
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
|
35
|
+
|
|
36
|
+
@click.command("doctor")
|
|
37
|
+
@click.option("--remote", "check_remote", is_flag=True, help="Also probe the configured remote endpoint")
|
|
38
|
+
@handle_errors
|
|
39
|
+
def cmd_doctor(check_remote):
|
|
40
|
+
"""Diagnose browser-cli installation, extension, and connection health."""
|
|
41
|
+
rows: list[tuple[str, bool, str]] = []
|
|
42
|
+
version = _project_version()
|
|
43
|
+
rows.append(("Python package", version != "unknown", version))
|
|
44
|
+
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
|
|
45
|
+
|
|
46
|
+
manifest_notes = []
|
|
47
|
+
if not is_windows():
|
|
48
|
+
import sys
|
|
49
|
+
platform = "darwin" if sys.platform == "darwin" else "linux"
|
|
50
|
+
for browser, by_platform in NATIVE_HOST_DIRS.items():
|
|
51
|
+
for directory in by_platform.get(platform, []):
|
|
52
|
+
path = directory / f"{NATIVE_HOST_NAME}.json"
|
|
53
|
+
if path.exists():
|
|
54
|
+
manifest_notes.append(f"{browser}: {path}")
|
|
55
|
+
rows.append(("Native host manifest", bool(manifest_notes), "; ".join(manifest_notes) or "not found for common browsers"))
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
targets = active_browser_targets(include_remotes=check_remote)
|
|
59
|
+
rows.append(("Browser registry", bool(targets), f"{len(targets)} active target(s)"))
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
rows.append(("Browser registry", False, str(exc)))
|
|
62
|
+
|
|
63
|
+
client = client_from_ctx()
|
|
64
|
+
try:
|
|
65
|
+
clients = client.clients()
|
|
66
|
+
rows.append(("Connection", True, f"{len(clients)} client(s) responded"))
|
|
67
|
+
ext_versions = sorted({str(c.get("extensionVersion", "unknown")) for c in clients if isinstance(c, dict)})
|
|
68
|
+
if ext_versions:
|
|
69
|
+
rows.append(("Extension version", version in ext_versions, ", ".join(ext_versions)))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
rows.append(("Connection", False, str(exc)))
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
info = client.extension.info()
|
|
75
|
+
caps = info.get("capabilities") or []
|
|
76
|
+
rows.append(("Extension info", True, f"v{info.get('version', 'unknown')} · {len(caps)} capabilities"))
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
rows.append(("Extension info", False, f"not available ({exc})"))
|
|
79
|
+
|
|
80
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
81
|
+
table.add_column("Check")
|
|
82
|
+
table.add_column("Status")
|
|
83
|
+
table.add_column("Details")
|
|
84
|
+
for name, ok, detail in rows:
|
|
85
|
+
table.add_row(name, _status(ok), detail)
|
|
86
|
+
console.print(table)
|
|
87
|
+
|
|
88
|
+
failed = [name for name, ok, _ in rows if not ok and name in {"Connection", "Browser registry"}]
|
|
89
|
+
if failed:
|
|
90
|
+
raise SystemExit(1)
|