245 lines
10 KiB
Python
Executable File
245 lines
10 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
|
|
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"],
|
|
},
|
|
}
|
|
|
|
|
|
@click.group()
|
|
@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 — Chrome uses the absolute path from the manifest,
|
|
# so it doesn't need to be a shell command.
|
|
share_dir = Path.home() / ".local" / "share" / "browser-cli"
|
|
share_dir.mkdir(parents=True, exist_ok=True)
|
|
wrapper_path = share_dir / "native-host"
|
|
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" -m browser_cli.native_host "$@"\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("\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()
|