#!/usr/bin/env -S uv run """ browser-cli — Control your running browser from the terminal. """ import click import sys import os import json import shutil import re from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path from rich.console import Console from browser_cli.commands.navigate import nav_group from browser_cli.commands.tabs import tabs_group from browser_cli.commands.groups import group_group from browser_cli.commands.windows import windows_group from browser_cli.commands.dom import dom_group from browser_cli.commands.extract import extract_group from browser_cli.commands.session import session_group from browser_cli.commands.search import search_group from browser_cli.commands.page import page_group from browser_cli.commands.storage import storage_group from browser_cli.commands.cookies import cookies_group from browser_cli.commands.perf import perf_group from browser_cli.commands.extension import extension_group from browser_cli.commands.serve import cmd_serve from browser_cli.client import ( send_command, BrowserNotConnected, REGISTRY_PATH, active_browser_targets, display_browser_name, remote_target_for_alias, remote_browser_targets, ) from browser_cli.platform import install_base_dir, is_windows from browser_cli.registry import load_registry console = Console() # Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars); # patch to use a wider limit so zsh completion descriptions aren't truncated. def _patched_group_shell_complete(self, ctx, incomplete): from click.shell_completion import CompletionItem results = [ CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns)) for name, command in self.commands.items() if not command.hidden and name.startswith(incomplete) ] results.extend(click.Command.shell_complete(self, ctx, incomplete)) return results click.Group.shell_complete = _patched_group_shell_complete NATIVE_HOST_NAME = "com.browsercli.host" EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg" NATIVE_HOST_DIRS = { "chrome": { "linux": [Path.home() / ".config/google-chrome/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts"], }, "chromium": { "linux": [Path.home() / ".config/chromium/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/Chromium/NativeMessagingHosts"], }, "brave": { "linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"], }, "edge": { "linux": [Path.home() / ".config/microsoft-edge/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/Microsoft Edge/NativeMessagingHosts"], }, "vivaldi": { "linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"], "darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"], }, } WINDOWS_NATIVE_HOST_REGISTRY_KEYS = { "chrome": [r"Software\Google\Chrome\NativeMessagingHosts"], "chromium": [r"Software\Chromium\NativeMessagingHosts"], "brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"], "edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"], "vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"], } def _rename_target_profile(target_browser: str | None) -> str | None: if target_browser: return target_browser active = active_browser_targets() if len(active) == 1: return active[0].profile return None def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None: target_profile = _rename_target_profile(target_browser) profiles: dict[str, str] = load_registry(REGISTRY_PATH) if alias in profiles and alias != target_profile: raise click.ClickException(f"Browser alias '{alias}' already exists") def _native_host_exe() -> Path: base = install_base_dir() if is_windows(): return base / "libexec" / "browser-cli-native-host.cmd" return base / "libexec" / "browser-cli-native-host" def _write_native_host_exe(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) if is_windows(): path.write_text( f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n', encoding="utf-8", ) else: path.write_text( f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n' ) path.chmod(path.stat().st_mode | 0o111) def _windows_registry_views(): import winreg return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)] def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]: import winreg installed = [] for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]: full_key = f"{key_path}\\{NATIVE_HOST_NAME}" for view in _windows_registry_views(): try: access = winreg.KEY_WRITE | view key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access) with key: winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path)) installed.append(f"HKCU\\{full_key}") except OSError as e: console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]") return installed def _project_version() -> str: pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" try: content = pyproject_path.read_text(encoding="utf-8") match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) if match: return match.group(1) except OSError: pass try: return package_version("browser-cli") except PackageNotFoundError: return "unknown" def _print_version(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo(_project_version()) ctx.exit() @click.group() @click.option( "-V", "--version", is_flag=True, is_eager=True, expose_value=False, callback=_print_version, help="Show the browser-cli version and exit.", ) @click.option( "--browser", default=None, metavar="ALIAS", help="Browser profile alias to target (required when multiple browsers are active).", ) @click.option( "--remote", default=None, metavar="HOST[:PORT]", help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.", ) @click.option( "--key", default=None, metavar="PATH", help="Ed25519 private key PEM for pubkey auth with a remote serve instance.", ) @click.pass_context def main(ctx, browser, remote, key): """Control your running browser from the terminal via a Chrome extension.""" ctx.ensure_object(dict) ctx.obj["browser"] = browser ctx.obj["browser_explicit"] = browser is not None if browser: os.environ["BROWSER_CLI_PROFILE"] = browser ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None)) ctx.obj["remote"] = remote ctx.obj["key"] = key if remote: os.environ["BROWSER_CLI_REMOTE"] = remote ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None)) if key: os.environ["BROWSER_CLI_KEY"] = key ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None)) # ── auth ────────────────────────────────────────────────────────────────────── @click.group("auth") def auth_group(): """Manage Ed25519 keys for public-key authentication with browser-cli serve.""" @auth_group.command("keygen") @click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.") @click.option("--force", is_flag=True, help="Overwrite existing key.") def cmd_auth_keygen(output, force): """Generate an Ed25519 keypair for pubkey auth.""" from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair key_path = Path(output) if output else DEFAULT_KEY_PATH if key_path.exists() and not force: console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)") sys.exit(1) pem, pub_hex = generate_keypair() key_path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "wb") as f: f.write(pem) console.print(f"[green]✓[/green] Private key: {key_path}") console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]") console.print(f"\nOn the serve host, trust this key:") console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]") @auth_group.command("trust") @click.argument("pubkey") @click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.") @click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).") @click.pass_context def cmd_auth_trust(ctx, pubkey, name, keys_file): """Add a public key to the authorized keys file (locally or on a remote serve host).""" from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key if len(pubkey) != 64: console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)") sys.exit(1) try: bytes.fromhex(pubkey) except ValueError: console.print("[red]Invalid public key:[/red] not valid hex") sys.exit(1) remote = (ctx.obj or {}).get("remote") if remote: from browser_cli.client import send_command result = send_command( "browser-cli.auth.trust", args={"pubkey": pubkey, "name": name}, remote=remote, key=(ctx.obj or {}).get("key"), ) added = (result or {}).get("added", False) label = f" ({name})" if name else "" if added: console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]") else: console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}") return path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH added = add_authorized_key(path, pubkey, name) label = f" ({name})" if name else "" if added: console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]") console.print(f" File: {path}") console.print(f"\nStart the server with:") console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]") else: console.print(f"[yellow]Already trusted:[/yellow] {pubkey}") @auth_group.command("show") @click.option("--key", "key_src", default=None, metavar="PATH|agent[:]", help="Key source: path to PEM file, 'agent', or 'agent:'.") def cmd_auth_show(key_src): """Print the Ed25519 public key that browser-cli will use for auth.""" from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH)) if src == "agent" or src.startswith("agent:"): selector = src[6:] or None key = agent_find_key(selector) if key is None: console.print("[red]No Ed25519 key found in SSH agent.[/red]") console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.") sys.exit(1) console.print(f"[dim]source:[/dim] agent ({key.comment})") console.print(public_key_hex(key)) return path = Path(src) if not path.exists(): console.print(f"[red]No key found at {path}[/red]") console.print(" Run: [dim]browser-cli auth keygen[/dim]") console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]") sys.exit(1) try: priv = load_private_key(path) console.print(f"[dim]source:[/dim] {path}") console.print(public_key_hex(priv)) except Exception as e: console.print(f"[red]Failed to load key:[/red] {e}") sys.exit(1) @auth_group.command("keys") @click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).") @click.pass_context def cmd_auth_keys(ctx, keys_file): """List trusted public keys (server's authorized_keys). With --remote, queries the remote server.""" from rich.table import Table remote = (ctx.obj or {}).get("remote") if remote: from browser_cli.client import send_command result = send_command( "browser-cli.auth.keys", remote=remote, key=(ctx.obj or {}).get("key"), ) entries = result or [] source_label = remote else: from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)] source_label = str(path) if not entries: console.print(f"[yellow]No trusted keys[/yellow] in {source_label}") console.print(" Add one: [dim]browser-cli auth trust --name