adding the edge and vivaldi browser for installing the extension to, generate uuids when the browser not have a alias set
This commit is contained in:
@@ -53,23 +53,25 @@ Every response:
|
|||||||
|
|
||||||
## Installation
|
## 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
|
```sh
|
||||||
git clone <repo>
|
git clone <repo>
|
||||||
cd browser-cli
|
cd browser-cli
|
||||||
uv sync
|
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:
|
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)
|
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
|
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
|
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.
|
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
|
## Project structure
|
||||||
@@ -103,7 +105,11 @@ browser-cli/
|
|||||||
|
|
||||||
## CLI reference
|
## CLI reference
|
||||||
|
|
||||||
All commands are run with `uv run browser-cli <command>`.
|
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
|
||||||
|
|
||||||
|
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 <current-alias> <new-alias>`.
|
||||||
|
|
||||||
|
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`)
|
### 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"
|
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
|
### Tabs
|
||||||
|
|
||||||
```sh
|
```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 add-tab 42 https://example.com # by group ID
|
||||||
|
|
||||||
browser-cli group close 42 # ungroup the group
|
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
|
### Windows
|
||||||
@@ -176,6 +208,7 @@ browser-cli group close 42 # ungroup the group
|
|||||||
```sh
|
```sh
|
||||||
browser-cli windows list # list all windows
|
browser-cli windows list # list all windows
|
||||||
browser-cli windows open # open a new window
|
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 rename 1 "work" # give a window a local alias
|
||||||
browser-cli windows close 1 # close a window
|
browser-cli windows close 1 # close a window
|
||||||
```
|
```
|
||||||
@@ -200,6 +233,7 @@ browser-cli extract links # all <a href> links on the page
|
|||||||
browser-cli extract images # all <img> tags (src + alt)
|
browser-cli extract images # all <img> tags (src + alt)
|
||||||
browser-cli extract text # all visible text (innerText)
|
browser-cli extract text # all visible text (innerText)
|
||||||
browser-cli extract json "#data" # parse JSON inside a CSS selector
|
browser-cli extract json "#data" # parse JSON inside a CSS selector
|
||||||
|
browser-cli extract html # full HTML of the active tab
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
@@ -220,7 +254,11 @@ browser-cli session auto-save off
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
browser-cli clients # show connected browser info
|
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 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
|
## Limitations
|
||||||
|
|
||||||
- **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
- **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`.
|
- **Profile switching** via `windows open --profile` depends on browser support and does not replace launching a separate browser profile 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.
|
- **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 <current-alias> <new-alias>` 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.
|
- **Linux and macOS only** — Windows native messaging paths are not yet handled.
|
||||||
|
|||||||
+25
-6
@@ -40,6 +40,14 @@ NATIVE_HOST_DIRS = {
|
|||||||
"linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
|
"linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
|
||||||
"darwin": [Path.home() / "Library/Application Support/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")
|
@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")
|
@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."""
|
"""Set the profile alias used to identify this browser instance."""
|
||||||
try:
|
try:
|
||||||
send_command("clients.rename_profile", {"alias": alias})
|
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -170,7 +182,7 @@ def cmd_rename_profile(alias):
|
|||||||
# ── install ────────────────────────────────────────────────────────────────────
|
# ── install ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@main.command("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):
|
def cmd_install(browser):
|
||||||
"""Register the native messaging host and print extension load instructions."""
|
"""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)
|
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
# Ask for extension ID
|
# 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("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||||
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
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 script: {native_host_script_path}")
|
||||||
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_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("\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) ────────────────
|
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ def _socket_path_for(alias: str) -> str:
|
|||||||
return str(SOCKET_DIR / f"{safe}.sock")
|
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) ---
|
# --- Thread A: read messages from extension (stdin) ---
|
||||||
|
|
||||||
def stdin_reader(alias: str):
|
def stdin_reader(alias: str):
|
||||||
@@ -191,10 +200,10 @@ def main():
|
|||||||
# Wait for the hello handshake to learn the profile alias
|
# Wait for the hello handshake to learn the profile alias
|
||||||
first_msg = read_native_message(stdin)
|
first_msg = read_native_message(stdin)
|
||||||
if first_msg and first_msg.get("type") == "hello":
|
if first_msg and first_msg.get("type") == "hello":
|
||||||
alias = first_msg.get("alias") or DEFAULT_ALIAS
|
alias = _resolve_profile_alias(first_msg)
|
||||||
else:
|
else:
|
||||||
# No hello — fall back to default, re-queue message if it was a command
|
# No hello — use a generated alias and re-queue the first command if needed.
|
||||||
alias = DEFAULT_ALIAS
|
alias = str(uuid.uuid4())
|
||||||
if first_msg:
|
if first_msg:
|
||||||
msg_id = first_msg.get("id")
|
msg_id = first_msg.get("id")
|
||||||
if msg_id:
|
if msg_id:
|
||||||
|
|||||||
+20
-4
@@ -5,7 +5,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from browser_cli.cli import main, _project_version
|
from browser_cli.cli import main, _project_version
|
||||||
|
|
||||||
|
|
||||||
def _expected_version() -> str:
|
def _expected_version() -> str:
|
||||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||||
for line in pyproject.read_text(encoding="utf-8").splitlines():
|
for line in pyproject.read_text(encoding="utf-8").splitlines():
|
||||||
@@ -13,21 +12,38 @@ def _expected_version() -> str:
|
|||||||
return line.split('"')[1]
|
return line.split('"')[1]
|
||||||
raise AssertionError("version not found in pyproject.toml")
|
raise AssertionError("version not found in pyproject.toml")
|
||||||
|
|
||||||
|
|
||||||
def test_short_version_option():
|
def test_short_version_option():
|
||||||
result = CliRunner().invoke(main, ["-V"])
|
result = CliRunner().invoke(main, ["-V"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output.strip() == _expected_version()
|
assert result.output.strip() == _expected_version()
|
||||||
|
|
||||||
|
|
||||||
def test_long_version_option():
|
def test_long_version_option():
|
||||||
result = CliRunner().invoke(main, ["--version"])
|
result = CliRunner().invoke(main, ["--version"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output.strip() == _expected_version()
|
assert result.output.strip() == _expected_version()
|
||||||
|
|
||||||
|
|
||||||
def test_project_version_falls_back_to_installed_package_metadata():
|
def test_project_version_falls_back_to_installed_package_metadata():
|
||||||
with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch(
|
with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch(
|
||||||
"browser_cli.cli.package_version", return_value="9.9.9"
|
"browser_cli.cli.package_version", return_value="9.9.9"
|
||||||
):
|
):
|
||||||
assert _project_version() == "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
|
||||||
|
|||||||
Reference in New Issue
Block a user