Files
browser-cli/browser_cli/cli.py
T
daniel156161 61b774a7a4
Package Extension / package-extension (push) Successful in 12s
Build & Publish Package / publish (push) Successful in 22s
add multi browser mode to arragate data from all browsers by tabs list, tabs count, group list, group count and windows list
remove (unnamed) into the group names just leave it a empty string, remove Focused on windows how should the browser know what windows are focused
2026-04-10 12:49:51 +02:00

321 lines
13 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 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, REGISTRY_PATH, display_browser_name
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"],
},
"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"],
},
}
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)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
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."""
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
try:
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
pass
all_clients = []
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
result = send_command("clients.list", profile=profile_name)
for c in (result or []):
c["profile"] = display_profile
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
# Socket registered but browser no longer connected
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
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.command("rename-profile")
@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_rename_profile(target_browser, alias):
"""Set the profile alias used to identify this browser instance."""
try:
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."""
# 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_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(" 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(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (Cmd/Ctrl+Q, 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()