#!/usr/bin/env -S uv run """ browser-cli — Control your running browser from the terminal. """ import click import sys import os import json import stat 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.client import send_command, BrowserNotConnected console = Console() NATIVE_HOST_NAME = "com.browsercli.host" 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"], }, } def _native_host_wrapper_path() -> Path: if sys.platform == "darwin": base_dir = Path.home() / "Library" / "Application Support" / "browser-cli" else: base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli" return base_dir / "libexec" / "native-host" def _native_host_script_path() -> Path: return _native_host_wrapper_path().with_name("native_host.py") 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.pass_context def main(ctx, browser): """Control your running browser from the terminal via a Chrome extension.""" ctx.ensure_object(dict) if browser: os.environ["BROWSER_CLI_PROFILE"] = browser # ── Sub-command groups ───────────────────────────────────────────────────────── main.add_command(nav_group) main.add_command(tabs_group) main.add_command(group_group) main.add_command(windows_group) main.add_command(dom_group) main.add_command(extract_group) main.add_command(session_group) main.add_command(search_group) # ── clients ──────────────────────────────────────────────────────────────────── @main.command("clients") def cmd_clients(): """Show connected browser clients.""" import json as _json from browser_cli.client import REGISTRY_PATH, DEFAULT_SOCKET # Build a map of profile → socket path from the registry profiles: dict[str, str] = {} if REGISTRY_PATH.exists(): try: profiles = _json.loads(REGISTRY_PATH.read_text()) except Exception: pass if not profiles: profiles = {"default": DEFAULT_SOCKET} all_clients = [] for profile_name, sock_path in profiles.items(): try: result = send_command("clients.list", profile=profile_name) for c in (result or []): c.setdefault("profile", profile_name) all_clients.append(c) except (BrowserNotConnected, RuntimeError): # Socket registered but browser no longer connected all_clients.append({"profile": profile_name, "name": "—", "version": "—", "platform": "disconnected"}) if not all_clients: console.print("[yellow]No browser clients found[/yellow]") sys.exit(1) from rich.table import Table table = Table(show_header=True, header_style="bold cyan") table.add_column("Profile") table.add_column("Browser") table.add_column("Version") table.add_column("Platform") for c in all_clients: table.add_row(c.get("profile", "default"), c.get("name", ""), c.get("version", ""), c.get("platform", "")) console.print(table) @main.command("rename-profile") @click.argument("alias") def cmd_rename_profile(alias): """Set the profile alias used to identify this browser instance.""" try: send_command("clients.rename_profile", {"alias": alias}) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) console.print(f"[green]Profile renamed to '{alias}'[/green]") console.print(" Restart the browser for the change to take effect.") # ── install ──────────────────────────────────────────────────────────────────── @main.command("install") @click.argument("browser", type=click.Choice(["chrome", "chromium", "brave"]), default="chrome") def cmd_install(browser): """Register the native messaging host and print extension load instructions.""" # Install wrapper outside PATH — the browser uses the absolute path from the # native messaging manifest, so only `browser-cli` needs to be on PATH. wrapper_path = _native_host_wrapper_path() native_host_script_path = _native_host_script_path() wrapper_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path) native_host_script_path.chmod( native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH ) wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n' wrapper_path.write_text(wrapper_content) wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) # Ask for extension ID ext_url = "brave://extensions" if browser == "brave" else "chrome://extensions" console.print("\n[bold]Step 1:[/bold] Load the extension in your browser") console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]") console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n") extension_id = click.prompt("Paste your extension ID here") extension_id = extension_id.strip() # Build native messaging manifest manifest = { "name": NATIVE_HOST_NAME, "description": "browser-cli native messaging host", "path": str(wrapper_path), "type": "stdio", "allowed_origins": [f"chrome-extension://{extension_id}/"], } # Write to OS native messaging dirs platform = "darwin" if sys.platform == "darwin" else "linux" dirs = NATIVE_HOST_DIRS[browser][platform] installed = [] for d in dirs: try: d.mkdir(parents=True, exist_ok=True) manifest_path = d / f"{NATIVE_HOST_NAME}.json" manifest_path.write_text(json.dumps(manifest, indent=2)) installed.append(manifest_path) except Exception as e: console.print(f"[yellow]Could not write to {d}: {e}[/yellow]") if not installed: console.print("[red]Failed to install native host manifest[/red]") sys.exit(1) for p in installed: console.print(f"[green]✓[/green] Wrote native host manifest: {p}") console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}") console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}") console.print("\n[bold]Step 2:[/bold] Restart Chrome completely (Cmd/Ctrl+Q, then reopen)") console.print("\n[green bold]✓ Installation complete![/green bold]") console.print(" After restarting Chrome, try: [cyan]browser-cli tabs list[/cyan]") # ── native-host (hidden, called by Chrome via native messaging) ──────────────── @main.command("native-host", hidden=True) def cmd_native_host(): """Native messaging host — called by Chrome, not for direct use.""" from browser_cli.native_host import main as _main _main() # ── completion ───────────────────────────────────────────────────────────────── @main.command("completion") @click.argument("shell", type=click.Choice(["zsh", "bash", "fish"])) @click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions") def cmd_completion(shell, script): """Print shell completion setup instructions (or output the script with --script).""" if script: from click.shell_completion import BashComplete, ZshComplete, FishComplete cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell] comp = cls(main, {}, "browser-cli", "_BROWSER_CLI_COMPLETE") click.echo(comp.source()) return exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli") if not Path(exe).exists(): exe = "browser-cli" env_var = "_BROWSER_CLI_COMPLETE" if shell == "zsh": console.print("[bold]Quickest setup — generate the file once:[/bold]") console.print() console.print(f" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]") console.print() console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):") console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]") console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]") console.print() console.print(" Reload: [cyan]exec zsh[/cyan]") console.print() console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]") console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]') elif shell == "bash": console.print("[bold]Quickest setup — generate the file once:[/bold]") console.print() console.print(f" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]") console.print() console.print(" Reload: [cyan]source ~/.bashrc[/cyan]") console.print() console.print("[bold]Alternative — eval on every shell start:[/bold]") console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]') elif shell == "fish": console.print("[bold]Setup:[/bold]") console.print() console.print(f" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]") if __name__ == "__main__": main()