From 7b9a8777312877c4f5f03887e844c7652b70eef7 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Fri, 10 Apr 2026 01:48:18 +0200 Subject: [PATCH] adding the edge and vivaldi browser for installing the extension to, generate uuids when the browser not have a alias set --- README.md | 51 +++++++++++++++++++++++++++++++++----- browser_cli/cli.py | 31 ++++++++++++++++++----- browser_cli/native_host.py | 15 ++++++++--- tests/test_cli.py | 24 +++++++++++++++--- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 77da592..68e95f6 100644 --- a/README.md +++ b/README.md @@ -53,23 +53,25 @@ Every response: ## Installation -**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome or Brave +**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, or Vivaldi ```sh git clone cd browser-cli uv sync -uv run browser-cli install brave # or: chrome, chromium +uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi ``` The `install` command will: 1. Ask you to load the `extension/` folder as an unpacked extension in your browser (`brave://extensions` → Developer mode → Load unpacked) 2. Ask you to paste the extension ID shown on the extension card 3. Write the native messaging manifest to your OS so the browser can find the host -4. Create an executable wrapper script for the native host +4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH` After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup. +Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy. + --- ## Project structure @@ -103,7 +105,11 @@ browser-cli/ ## CLI reference -All commands are run with `uv run browser-cli `. +All commands are run with `uv run browser-cli [--browser ALIAS] `. + +Use `--browser ALIAS` when multiple browser instances are connected. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --browser `. + +Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. ### Navigation (`nav`) @@ -127,6 +133,30 @@ browser-cli nav forward 1234 # forward in specific tab browser-cli nav focus github # focuses first tab whose URL contains "github" ``` +### Search + +Each search command opens the search results in your browser using the same flags as `nav open`. + +```sh +browser-cli search google openai api +browser-cli search brave rust iterators --bg +browser-cli search ddg tab groups --window work +browser-cli search youtube browser automation +browser-cli search yt lo fi +browser-cli search spotify aphex twin +browser-cli search amazon mechanical keyboard +browser-cli search ecosia native messaging +browser-cli search furaffinity dragons +browser-cli search fa dragons +browser-cli search bing browser cli +browser-cli search github browser-cli +browser-cli search wikipedia native messaging +browser-cli search wiki native messaging +browser-cli search reddit chrome extensions +browser-cli search stackoverflow click choices +browser-cli search so click choices +``` + ### Tabs ```sh @@ -169,6 +199,8 @@ browser-cli group add-tab research https://example.com # open URL in the group browser-cli group add-tab 42 https://example.com # by group ID browser-cli group close 42 # ungroup the group +browser-cli group move research --forward # move group right +browser-cli group move 42 --backward # move group left ``` ### Windows @@ -176,6 +208,7 @@ browser-cli group close 42 # ungroup the group ```sh browser-cli windows list # list all windows browser-cli windows open # open a new window +browser-cli windows open --profile Default # request a specific Chrome profile name browser-cli windows rename 1 "work" # give a window a local alias browser-cli windows close 1 # close a window ``` @@ -200,6 +233,7 @@ browser-cli extract links # all links on the page browser-cli extract images # all tags (src + alt) browser-cli extract text # all visible text (innerText) browser-cli extract json "#data" # parse JSON inside a CSS selector +browser-cli extract html # full HTML of the active tab ``` ### Sessions @@ -220,7 +254,11 @@ browser-cli session auto-save off ```sh browser-cli clients # show connected browser info +browser-cli rename-profile --browser abcd1234 work # rename one connected browser instance +browser-cli --browser abcd1234 rename-profile work # equivalent global form browser-cli install brave # (re)register the native host +browser-cli completion zsh # print setup instructions +browser-cli completion zsh --script # output raw completion script ``` --- @@ -329,6 +367,7 @@ bash examples/demo.sh ## Limitations - **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. -- **Profile switching** via `windows open --profile` opens a plain window; launching a different profile requires the browser to be started externally with `--profile-directory`. -- **One browser at a time** — the native host socket supports one connected extension. Running multiple browser profiles simultaneously is not supported. +- **Profile switching** via `windows open --profile` depends on browser support and does not replace launching a separate browser profile externally with `--profile-directory`. +- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli rename-profile --browser ` and restarting that browser. +- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely. - **Linux and macOS only** — Windows native messaging paths are not yet handled. diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 2b49fa0..d577f6d 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -40,6 +40,14 @@ NATIVE_HOST_DIRS = { "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"], + }, } @@ -155,11 +163,15 @@ def cmd_clients(): @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(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}) + send_command("clients.rename_profile", {"alias": alias}, profile=target_browser) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) @@ -170,7 +182,7 @@ def cmd_rename_profile(alias): # ── install ──────────────────────────────────────────────────────────────────── @main.command("install") -@click.argument("browser", type=click.Choice(["chrome", "chromium", "brave"]), default="chrome") +@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.""" @@ -188,7 +200,14 @@ def cmd_install(browser): 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" + 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)") @@ -230,9 +249,9 @@ def cmd_install(browser): 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(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 Chrome, try: [cyan]browser-cli tabs list[/cyan]") + console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]") # ── native-host (hidden, called by Chrome via native messaging) ──────────────── diff --git a/browser_cli/native_host.py b/browser_cli/native_host.py index 7b2b2fd..4470f34 100644 --- a/browser_cli/native_host.py +++ b/browser_cli/native_host.py @@ -75,6 +75,15 @@ def _socket_path_for(alias: str) -> str: return str(SOCKET_DIR / f"{safe}.sock") +def _resolve_profile_alias(first_msg: dict | None) -> str: + """Return a unique alias when the extension did not provide one.""" + if first_msg and first_msg.get("type") == "hello": + alias = first_msg.get("alias") + if alias and alias != DEFAULT_ALIAS: + return alias + return str(uuid.uuid4()) + + # --- Thread A: read messages from extension (stdin) --- def stdin_reader(alias: str): @@ -191,10 +200,10 @@ def main(): # Wait for the hello handshake to learn the profile alias first_msg = read_native_message(stdin) if first_msg and first_msg.get("type") == "hello": - alias = first_msg.get("alias") or DEFAULT_ALIAS + alias = _resolve_profile_alias(first_msg) else: - # No hello — fall back to default, re-queue message if it was a command - alias = DEFAULT_ALIAS + # No hello — use a generated alias and re-queue the first command if needed. + alias = str(uuid.uuid4()) if first_msg: msg_id = first_msg.get("id") if msg_id: diff --git a/tests/test_cli.py b/tests/test_cli.py index 759a6bb..df2f032 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,6 @@ from unittest.mock import patch from browser_cli.cli import main, _project_version - def _expected_version() -> str: pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" for line in pyproject.read_text(encoding="utf-8").splitlines(): @@ -13,21 +12,38 @@ def _expected_version() -> str: return line.split('"')[1] raise AssertionError("version not found in pyproject.toml") - def test_short_version_option(): result = CliRunner().invoke(main, ["-V"]) assert result.exit_code == 0 assert result.output.strip() == _expected_version() - def test_long_version_option(): result = CliRunner().invoke(main, ["--version"]) assert result.exit_code == 0 assert result.output.strip() == _expected_version() - def test_project_version_falls_back_to_installed_package_metadata(): with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch( "browser_cli.cli.package_version", return_value="9.9.9" ): assert _project_version() == "9.9.9" + +def test_rename_profile_uses_command_level_browser_target(): + with patch("browser_cli.cli.send_command") as send_command: + result = CliRunner().invoke(main, ["rename-profile", "--browser", "old-id", "work"]) + + assert result.exit_code == 0 + send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id") + +def test_rename_profile_uses_global_browser_target_when_set(): + with patch("browser_cli.cli.send_command") as send_command: + result = CliRunner().invoke(main, ["--browser", "old-id", "rename-profile", "work"]) + + assert result.exit_code == 0 + send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile=None) + +def test_install_help_lists_supported_browsers(): + result = CliRunner().invoke(main, ["install", "--help"]) + + assert result.exit_code == 0 + assert "[chrome|chromium|brave|edge|vivaldi]" in result.output