real-browser-cli 0.14.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -0,0 +1,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)