fd5447cbb9
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Restructure the Python API and internals around composable namespaces and a standalone transport/endpoint layer. Bump to 0.12.0. Python API: - Replace flat methods (b.tabs_list(), b.group_list()) with namespaces: b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage, b.cookies, b.session, b.perf, b.extension. - Shrink browser_cli/__init__.py to a thin composition root; move all behaviour into browser_cli/sdk/ (one module per namespace + factories, base, routing). Internals: - Add browser_cli/transport.py and remote_transport.py to isolate IPC from command logic; client.py now delegates instead of owning transport. - Add browser_cli/endpoints.py for endpoint resolution and browser_cli/errors.py for shared error types. - Extract markdown rendering into browser_cli/markdown.py (out of extract). - Add USER_AGENT to version_manager. Tooling & tests: - Add justfile with common dev tasks. - Update CLI commands and demo to the namespaced API. - Rework tests for the new layout; add test_transport.py and test_refactor_boundaries.py to lock in module boundaries. BREAKING CHANGE: flat API methods are removed in favour of namespaces (e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
607 lines
25 KiB
Python
Executable File
607 lines
25 KiB
Python
Executable File
#!/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[:<selector>]",
|
|
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
|
|
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 <public-key> --name <label>[/dim]")
|
|
return
|
|
|
|
table = Table(show_header=True, header_style="bold cyan")
|
|
table.add_column("Name")
|
|
table.add_column("Public Key")
|
|
for entry in entries:
|
|
name = entry.get("name") or "[dim]—[/dim]"
|
|
table.add_row(name, entry.get("pubkey", ""))
|
|
console.print(table)
|
|
|
|
main.add_command(auth_group)
|
|
|
|
# ── 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)
|
|
main.add_command(page_group)
|
|
main.add_command(storage_group)
|
|
main.add_command(cookies_group)
|
|
main.add_command(perf_group)
|
|
main.add_command(extension_group)
|
|
main.add_command(cmd_serve)
|
|
|
|
# ── clients ────────────────────────────────────────────────────────────────────
|
|
|
|
def _append_clients(into, label, *, profile=None, remote=None, key=None):
|
|
"""Query clients.list for one target and append each, tagged with *label*."""
|
|
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
|
for c in (result or []):
|
|
c["profile"] = label
|
|
into.append(c)
|
|
|
|
@click.group("clients", invoke_without_command=True)
|
|
@click.pass_context
|
|
def clients_group(ctx):
|
|
"""Inspect and manage connected browser clients."""
|
|
if ctx.invoked_subcommand is not None:
|
|
return
|
|
|
|
all_clients = []
|
|
|
|
browser_alias = (ctx.obj or {}).get("browser")
|
|
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
|
key = (ctx.obj or {}).get("key")
|
|
|
|
if not remote and browser_alias:
|
|
# --browser <host> without --remote: resolve host alias to a remote endpoint,
|
|
# then show ALL clients from that remote (not just the one resolved profile).
|
|
resolved = remote_target_for_alias(browser_alias)
|
|
if resolved:
|
|
try:
|
|
targets = remote_browser_targets(resolved.remote)
|
|
except (BrowserNotConnected, RuntimeError) as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
sys.exit(1)
|
|
for target in targets:
|
|
try:
|
|
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
|
|
except (BrowserNotConnected, RuntimeError):
|
|
continue
|
|
elif remote:
|
|
try:
|
|
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
|
for c in (result or []):
|
|
c["profile"] = c.get("profile") or browser_alias or "remote"
|
|
all_clients.append(c)
|
|
except (BrowserNotConnected, RuntimeError) as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
sys.exit(1)
|
|
else:
|
|
profiles: dict[str, str] = {}
|
|
if REGISTRY_PATH.exists():
|
|
profiles = load_registry(REGISTRY_PATH)
|
|
|
|
for profile_name, sock_path in profiles.items():
|
|
display_profile = display_browser_name(profile_name, sock_path)
|
|
try:
|
|
_append_clients(all_clients, display_profile, profile=profile_name)
|
|
except (BrowserNotConnected, RuntimeError):
|
|
# Socket registered but browser no longer connected
|
|
all_clients.append({
|
|
"profile": display_profile,
|
|
"name": "—",
|
|
"version": "—",
|
|
"extensionVersion": "disconnected",
|
|
})
|
|
|
|
for target in active_browser_targets():
|
|
if target.remote is None:
|
|
continue
|
|
try:
|
|
_append_clients(all_clients, target.display_name, profile=target.profile, remote=target.remote)
|
|
except (BrowserNotConnected, RuntimeError):
|
|
continue
|
|
|
|
if not all_clients:
|
|
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/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("Extension Version")
|
|
for c in all_clients:
|
|
table.add_row(
|
|
c.get("profile", ""),
|
|
c.get("name", ""),
|
|
c.get("version", ""),
|
|
c.get("extensionVersion", ""),
|
|
)
|
|
console.print(table)
|
|
|
|
main.add_command(clients_group)
|
|
|
|
@clients_group.command("rename")
|
|
@click.option(
|
|
"--browser", "target_browser", default=None, metavar="ALIAS",
|
|
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
|
)
|
|
@click.argument("alias")
|
|
def cmd_clients_rename(target_browser, alias):
|
|
"""Set the profile alias used to identify this browser instance."""
|
|
try:
|
|
_ensure_unique_browser_alias(alias, target_browser)
|
|
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
|
|
except BrowserNotConnected as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
sys.exit(1)
|
|
console.print(f"[green]Profile renamed to '{alias}'[/green]")
|
|
|
|
# ── install ────────────────────────────────────────────────────────────────────
|
|
|
|
@main.command("install")
|
|
@click.argument("browser", type=click.Choice(["chrome", "chromium", "brave", "edge", "vivaldi"]), default="chrome")
|
|
def cmd_install(browser):
|
|
"""Register the native messaging host and print extension load instructions."""
|
|
|
|
host_exe = _native_host_exe()
|
|
_write_native_host_exe(host_exe)
|
|
|
|
# Load extension
|
|
ext_urls = {
|
|
"chrome": "chrome://extensions",
|
|
"chromium": "chrome://extensions",
|
|
"brave": "brave://extensions",
|
|
"edge": "edge://extensions",
|
|
"vivaldi": "vivaldi://extensions",
|
|
}
|
|
ext_url = ext_urls[browser]
|
|
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(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
|
|
|
|
extension_id = EXTENSION_ID
|
|
|
|
# Build native messaging manifest
|
|
manifest = {
|
|
"name": NATIVE_HOST_NAME,
|
|
"description": "browser-cli native messaging host",
|
|
"path": str(host_exe),
|
|
"type": "stdio",
|
|
"allowed_origins": [f"chrome-extension://{extension_id}/"],
|
|
}
|
|
|
|
installed = []
|
|
if is_windows():
|
|
manifest_dir = host_exe.parent
|
|
manifest_dir.mkdir(parents=True, exist_ok=True)
|
|
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
|
|
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
installed = _register_windows_native_host(browser, manifest_path)
|
|
else:
|
|
platform = "darwin" if sys.platform == "darwin" else "linux"
|
|
dirs = NATIVE_HOST_DIRS[browser][platform]
|
|
|
|
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:
|
|
if is_windows():
|
|
console.print(f"[green]✓[/green] Registered native host: {p}")
|
|
else:
|
|
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
|
|
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
|
|
|
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
|
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
|
console.print(" After restarting the browser, 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()
|