Compare commits

..

12 Commits

Author SHA1 Message Date
daniel156161 36abde501c update version to 0.7.0
Testing / test (push) Successful in 23s
Package Extension / package-extension (push) Successful in 21s
Build & Publish Package / publish (push) Successful in 24s
2026-04-17 20:52:47 +02:00
daniel156161 1aff084429 add deterministic extension key
Testing / test (push) Waiting to run
Extension ID: bfpmkhngkjnfhabmfckgeohlilokodkg
2026-04-17 20:45:36 +02:00
daniel156161 1c5fd0ffee feat: add browser automation commands (v0.6.0)
Testing / test (push) Successful in 24s
Package Extension / package-extension (push) Successful in 9s
Build & Publish Package / publish (push) Successful in 21s
Navigation: open-wait (open + block until loaded)
DOM: key, hover, check/uncheck, clear, focus, submit, poll, scroll, select, eval, wait-for
Tabs: pin/unpin, screenshot, watch-url (block until URL matches regex)
New command groups: page info, storage get/set, cookies list/get/set
Extension: add cookies permission
2026-04-16 14:21:19 +02:00
daniel156161 fc4ce8f74d rename Chrome to Browser
Testing / test (push) Successful in 20s
2026-04-16 11:55:09 +02:00
daniel156161 cd2ebc2982 set defaults to tab empyt url and title with muted false
Testing / test (push) Successful in 31s
2026-04-14 13:21:21 +02:00
daniel156161 edf9056430 show mute status correctly when tab mute and add to get single tab status
Package Extension / package-extension (push) Successful in 17s
Build & Publish Package / publish (push) Successful in 29s
Testing / test (push) Failing after 26s
2026-04-13 21:35:25 +02:00
daniel156161 c494e76fe2 allow to mute and unmute tabs and get mute status into tab info
Testing / test (push) Failing after 34s
2026-04-13 21:19:27 +02:00
daniel156161 5150933319 change that tab open change url inplace and get active tab from window id
Testing / test (push) Successful in 29s
Package Extension / package-extension (push) Successful in 11s
Build & Publish Package / publish (push) Successful in 43s
2026-04-13 19:50:46 +02:00
daniel156161 9dbe57c66c implement windows support of the extension
Testing / test (push) Successful in 47s
2026-04-13 11:02:54 +02:00
daniel156161 080ca6da6d update uv.lock to new version of browser-cli
Testing / test (push) Failing after 11m8s
2026-04-13 08:06:15 +02:00
daniel156161 d0c1d7c226 update README 2026-04-13 08:05:43 +02:00
daniel156161 2a38997946 add --left/--right commands into move and add shorter aliases to move flags 2026-04-13 08:04:58 +02:00
25 changed files with 1506 additions and 110 deletions
+15 -11
View File
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local socket.
It works by pairing a small browser extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
---
@@ -17,22 +17,22 @@ It works by pairing a small Chrome/Brave extension with a Python CLI tool. The e
```
terminal / python script
Unix socket (/tmp/browser-cli.sock)
Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
Native Messaging Host (Python process, launched by the browser)
│ Native Messaging Protocol (stdin/stdout, 4-byte length prefix + JSON)
Chrome Extension (background service worker)
Browser Extension (background worker/page)
chrome.* APIs
extension APIs
Your running browser
```
1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup.
2. The browser launches the native host Python process (registered in the OS).
3. The native host opens a Unix socket at `/tmp/browser-cli.sock`.
3. The native host opens a local IPC endpoint for the CLI.
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
@@ -53,7 +53,7 @@ Every response:
## Installation
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, or Vivaldi
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi
```sh
git clone <repo>
@@ -63,14 +63,14 @@ 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
1. Ask you to load the browser-specific extension package
2. For Chromium-family browsers, 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. 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.
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. On Windows the install command also registers the host in the current user's Registry for the selected browser.
---
@@ -81,7 +81,7 @@ browser-cli/
├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── cli.py # Click CLI entry point
│ ├── client.py # Unix socket client used by CLI and API
│ ├── client.py # Local IPC client used by CLI and API
│ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/
@@ -215,7 +215,11 @@ browser-cli groups add-tab 42 https://example.com # by group ID
browser-cli groups close 42 # ungroup the group
browser-cli groups move research --forward # move group right
browser-cli groups move research --right # same as --forward
browser-cli groups move research -r # short right alias
browser-cli groups move 42 --backward # move group left
browser-cli groups move 42 --left # same as --backward
browser-cli groups move 42 -l # short left alias
```
### Windows
@@ -400,7 +404,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.
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
- **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 clients rename --browser <current-alias> <new-alias>`.
- **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.
+261
View File
@@ -77,6 +77,7 @@ class BrowserCLI:
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
@@ -117,6 +118,49 @@ class BrowserCLI:
def focus_url(self, pattern: str) -> None:
self._cmd("navigate.focus", {"pattern": pattern})
def navigate_tab(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url*."""
self._cmd("navigate.to", {"tabId": tab_id, "url": url})
def open_wait(
self,
url: str,
*,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> "Tab":
"""Open URL in a new tab and block until fully loaded. Returns the Tab."""
data = self._cmd("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
def wait_for_load(
self,
tab_id: int | None = None,
*,
timeout: float = 30.0,
ready_state: str = "complete",
) -> Tab:
"""Block until the tab finishes loading. Returns the Tab when ready.
Args:
tab_id: Tab to watch. Defaults to the active tab.
timeout: Max seconds to wait before raising ``RuntimeError``.
ready_state: ``"complete"`` (default) or ``"interactive"``.
"""
data = self._cmd("navigate.wait", {
"tabId": tab_id,
"timeout": int(timeout * 1000),
"readyState": ready_state,
})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.wait returned unexpected data")
return self._make_tab(data)
# ── Search ────────────────────────────────────────────────────────────
def search(
@@ -168,6 +212,68 @@ class BrowserCLI:
"""Switch browser focus to a tab by ID."""
self._cmd("tabs.active", {"tabId": tab_id})
def tabs_status(self, tab_id: int | None = None) -> Tab:
"""Return status for the active tab or a specific tab."""
data = self._cmd("tabs.status", {"tabId": tab_id})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("No tab status returned")
return self._make_tab(data)
def tabs_mute(self, tab_id: int | None = None) -> int:
"""Mute the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.mute", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def tabs_unmute(self, tab_id: int | None = None) -> int:
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.unmute", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def tabs_pin(self, tab_id: int | None = None) -> int:
"""Pin the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.pin", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def tabs_unpin(self, tab_id: int | None = None) -> int:
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.unpin", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def tabs_watch_url(
self,
pattern: str,
*,
tab_id: int | None = None,
timeout: float = 30.0,
) -> "Tab":
"""Block until the tab URL matches regex pattern. Returns the Tab."""
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
def tabs_screenshot(
self,
tab_id: int | None = None,
*,
format: str = "png",
quality: int | None = None,
) -> str:
"""Capture the visible area of a tab. Returns a base64 data URL.
Args:
tab_id: Tab to capture. Defaults to the active tab.
format: ``"png"`` (default) or ``"jpeg"``.
quality: JPEG quality 0-100 (ignored for PNG).
"""
result = self._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
return result.get("dataUrl", "") if isinstance(result, dict) else str(result)
def window_active_tab(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window."""
data = self._cmd("tabs.active_in_window", {"windowId": window_id})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(f"No active tab found for window {window_id}")
return self._make_tab(data)
def tabs_filter(self, pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
"""Return tabs filtered by pattern or a Python callable."""
if isinstance(pattern_or_filter, str):
@@ -317,6 +423,161 @@ class BrowserCLI:
def dom_exists(self, selector: str) -> bool:
return self._cmd("dom.exists", {"selector": selector})
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
"""Scroll to a CSS selector or to pixel coordinates."""
self._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
def dom_select(self, selector: str, value: str) -> None:
"""Set the value of a <select> element."""
self._cmd("dom.select", {"selector": selector, "value": value})
def dom_eval(self, code: str, tab_id: int | None = None):
"""Evaluate JavaScript in the page's main world and return the result."""
return self._cmd("dom.eval", {"code": code, "tabId": tab_id})
def dom_key(self, key: str, selector: str | None = None) -> None:
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
self._cmd("dom.key", {"key": key, "selector": selector})
def dom_hover(self, selector: str) -> None:
"""Dispatch mouseover/mouseenter on an element."""
self._cmd("dom.hover", {"selector": selector})
def dom_check(self, selector: str) -> None:
"""Check a checkbox."""
self._cmd("dom.check", {"selector": selector})
def dom_uncheck(self, selector: str) -> None:
"""Uncheck a checkbox."""
self._cmd("dom.uncheck", {"selector": selector})
def dom_clear(self, selector: str) -> None:
"""Clear the value of an input element."""
self._cmd("dom.clear", {"selector": selector})
def dom_focus(self, selector: str) -> None:
"""Focus an element."""
self._cmd("dom.focus", {"selector": selector})
def dom_submit(self, selector: str) -> None:
"""Submit the form containing the matched element."""
self._cmd("dom.submit", {"selector": selector})
def dom_poll(
self,
selector: str,
pattern: str,
*,
attr: str | None = None,
timeout: float = 30.0,
interval: float = 0.5,
tab_id: int | None = None,
) -> dict:
"""Poll selector's text/value until it matches regex pattern.
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
"""
return self._cmd("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
def dom_wait_for(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Wait until a CSS selector appears (or disappears) in the DOM.
Args:
selector: CSS selector to watch.
timeout: Max seconds to wait before raising ``RuntimeError``.
visible: Wait until the element has non-zero dimensions.
hidden: Wait until the element is absent or has ``offsetParent == null``.
tab_id: Tab to watch. Defaults to the active tab.
"""
return self._cmd("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
# ── Page ─────────────────────────────────────────────────────────────
def page_info(self) -> dict:
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
return self._cmd("page.info", {}) or {}
# ── Storage ───────────────────────────────────────────────────────────
def storage_get(
self,
key: str | None = None,
*,
type: str = "local",
tab_id: int | None = None,
) -> str | dict | None:
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
return self._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
def storage_set(
self,
key: str,
value: str,
*,
type: str = "local",
tab_id: int | None = None,
) -> None:
"""Set a localStorage/sessionStorage entry."""
self._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
# ── Cookies ───────────────────────────────────────────────────────────
def cookies_list(
self,
*,
url: str | None = None,
domain: str | None = None,
name: str | None = None,
) -> list[dict]:
"""List cookies, optionally filtered by url, domain, or name."""
return self._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
def cookies_get(self, url: str, name: str) -> dict | None:
"""Get a single cookie by url and name."""
return self._cmd("cookies.get", {"url": url, "name": name})
def cookies_set(
self,
url: str,
name: str,
value: str,
*,
domain: str | None = None,
path: str | None = None,
secure: bool | None = None,
http_only: bool | None = None,
expiration_date: float | None = None,
same_site: str | None = None,
) -> dict:
"""Set a cookie. Returns the created cookie dict."""
return self._cmd("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure, "httpOnly": http_only,
"expirationDate": expiration_date, "sameSite": same_site,
})
# ── Extract ───────────────────────────────────────────────────────────
def extract_links(self) -> list[dict]:
+77 -24
View File
@@ -21,6 +21,9 @@ 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.client import (
send_command,
BrowserNotConnected,
@@ -28,6 +31,7 @@ from browser_cli.client import (
active_browser_targets,
display_browser_name,
)
from browser_cli.platform import install_base_dir, is_windows
console = Console()
@@ -56,6 +60,14 @@ NATIVE_HOST_DIRS = {
},
}
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:
@@ -82,10 +94,9 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
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"
base_dir = install_base_dir()
if is_windows():
return base_dir / "libexec" / "native-host.cmd"
return base_dir / "libexec" / "native-host"
@@ -93,6 +104,30 @@ def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py")
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:
@@ -147,6 +182,9 @@ 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)
# ── clients ────────────────────────────────────────────────────────────────────
@@ -235,12 +273,16 @@ def cmd_install(browser):
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)
if not is_windows():
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)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Ask for extension ID
ext_urls = {
@@ -269,30 +311,41 @@ def cmd_install(browser):
"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 is_windows():
manifest_dir = wrapper_path.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:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
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 script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (Cmd/Ctrl+Q, then reopen)")
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]")
+23 -15
View File
@@ -1,11 +1,11 @@
"""
Unix socket client sends commands to the native host relay socket.
Used by both the CLI and the public Python API.
Local IPC client sends commands to native host relay endpoint.
Used by both CLI and public Python API.
Profile selection order:
1. Explicit `profile` argument to send_command()
2. BROWSER_CLI_PROFILE environment variable
3. First entry in /tmp/.browser_cli/registry.json
3. First entry in runtime registry
4. Otherwise, no browser can be resolved automatically
"""
import json
@@ -14,11 +14,13 @@ import socket
import struct
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path
from typing import Any
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
REGISTRY_PATH = registry_path()
class BrowserNotConnected(Exception):
@@ -32,8 +34,10 @@ class BrowserTarget:
socket_path: str
def _active_sockets(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk."""
def _active_endpoints(reg: dict) -> dict:
"""Return only entries whose endpoint appears reachable."""
if is_windows():
return dict(reg)
return {k: v for k, v in reg.items() if Path(v).exists()}
@@ -52,7 +56,7 @@ def active_browser_targets() -> list[BrowserTarget]:
return []
return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_sockets(reg).items()
for profile, sock_path in _active_endpoints(reg).items()
]
@@ -68,8 +72,7 @@ def _resolve_socket(profile: str | None = None) -> str:
return reg[target]
except Exception:
pass
safe = target.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(target)
# Auto-detect: error when multiple browser instances are active
try:
@@ -107,17 +110,22 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
framed = struct.pack("<I", len(payload)) + payload
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
response = _recv_all(sock)
if is_windows():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
profile_hint = f" (profile: {profile})" if profile else ""
raise BrowserNotConnected(
f"Cannot connect to browser{profile_hint}.\n"
"Make sure:\n"
" 1. The browser-cli extension is installed and enabled\n"
" 2. The native host is registered: uv run browser-cli install chrome\n"
" 2. The native host is registered: uv run browser-cli install <browser>\n"
" 3. Your browser is running\n"
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
)
+86
View File
@@ -0,0 +1,86 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("cookies")
def cookies_group():
"""Manage browser cookies."""
@cookies_group.command("list")
@click.option("--url", default=None, help="Filter by URL")
@click.option("--domain", default=None, help="Filter by domain")
@click.option("--name", default=None, help="Filter by cookie name")
def cookies_list(url, domain, name):
"""List cookies, optionally filtered by URL, domain, or name."""
cookies = _handle("cookies.list", {"url": url, "domain": domain, "name": name}) or []
if not cookies:
console.print("[yellow]No cookies found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Value")
table.add_column("Domain")
table.add_column("Path")
table.add_column("Secure", width=7)
table.add_column("HttpOnly", width=9)
for c in cookies:
table.add_row(
c.get("name", ""),
(c.get("value") or "")[:60],
c.get("domain", ""),
c.get("path", ""),
"[green]✓[/green]" if c.get("secure") else "",
"[green]✓[/green]" if c.get("httpOnly") else "",
)
console.print(table)
@cookies_group.command("get")
@click.argument("url")
@click.argument("name")
def cookies_get(url, name):
"""Get the value of a single cookie by URL and NAME."""
cookie = _handle("cookies.get", {"url": url, "name": name})
if cookie is None:
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
raise SystemExit(1)
console.print(cookie.get("value", ""))
@cookies_group.command("set")
@click.argument("url")
@click.argument("name")
@click.argument("value")
@click.option("--domain", default=None)
@click.option("--path", default=None)
@click.option("--secure", is_flag=True)
@click.option("--http-only", "http_only", is_flag=True)
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
"""Set a cookie on URL."""
_handle("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure or None,
"httpOnly": http_only or None,
"expirationDate": expiration_date,
"sameSite": same_site,
})
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
+130
View File
@@ -87,3 +87,133 @@ def dom_exists(selector):
else:
console.print(f"[red]not found[/red]: {selector}")
raise SystemExit(1)
@dom_group.command("scroll")
@click.argument("selector", required=False)
@click.option("--x", type=int, default=None, help="Horizontal scroll position (px)")
@click.option("--y", type=int, default=None, help="Vertical scroll position (px)")
def dom_scroll(selector, x, y):
"""Scroll to a CSS SELECTOR or to an X/Y coordinate."""
_handle("dom.scroll", {"selector": selector, "x": x, "y": y})
target = selector or f"({x or 0}, {y or 0})"
console.print(f"[green]Scrolled to:[/green] {target}")
@dom_group.command("select")
@click.argument("selector")
@click.argument("value")
def dom_select(selector, value):
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
_handle("dom.select", {"selector": selector, "value": value})
console.print(f"[green]Selected '{value}' in:[/green] {selector}")
@dom_group.command("eval")
@click.argument("code")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def dom_eval(code, tab_id):
"""Evaluate JavaScript CODE in the page and print the result."""
result = _handle("dom.eval", {"code": code, "tabId": tab_id})
if result is None:
console.print("[dim]null[/dim]")
else:
console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result))
@dom_group.command("wait-for")
@click.argument("selector")
@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait")
@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)")
@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def dom_wait_for(selector, timeout, visible, hidden, tab_id):
"""Wait until CSS SELECTOR appears (or disappears) in the DOM."""
_handle("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
state = "hidden" if hidden else ("visible" if visible else "present")
console.print(f"[green]Ready ({state}):[/green] {selector}")
@dom_group.command("key")
@click.argument("key")
@click.option("--selector", default=None, help="CSS selector to target (default: focused element)")
def dom_key(key, selector):
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
_handle("dom.key", {"key": key, "selector": selector})
target = selector or "active element"
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
@dom_group.command("hover")
@click.argument("selector")
def dom_hover(selector):
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
_handle("dom.hover", {"selector": selector})
console.print(f"[green]Hovered:[/green] {selector}")
@dom_group.command("check")
@click.argument("selector")
def dom_check(selector):
"""Check a checkbox matching CSS SELECTOR."""
_handle("dom.check", {"selector": selector})
console.print(f"[green]Checked:[/green] {selector}")
@dom_group.command("uncheck")
@click.argument("selector")
def dom_uncheck(selector):
"""Uncheck a checkbox matching CSS SELECTOR."""
_handle("dom.uncheck", {"selector": selector})
console.print(f"[green]Unchecked:[/green] {selector}")
@dom_group.command("clear")
@click.argument("selector")
def dom_clear(selector):
"""Clear the value of an input matching CSS SELECTOR."""
_handle("dom.clear", {"selector": selector})
console.print(f"[green]Cleared:[/green] {selector}")
@dom_group.command("focus")
@click.argument("selector")
def dom_focus(selector):
"""Focus the element matching CSS SELECTOR."""
_handle("dom.focus", {"selector": selector})
console.print(f"[green]Focused:[/green] {selector}")
@dom_group.command("submit")
@click.argument("selector")
def dom_submit(selector):
"""Submit the form that contains the element matching CSS SELECTOR."""
_handle("dom.submit", {"selector": selector})
console.print(f"[green]Submitted form for:[/green] {selector}")
@dom_group.command("poll")
@click.argument("selector")
@click.argument("pattern")
@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def dom_poll(selector, pattern, attr, timeout, interval, tab_id):
"""Poll SELECTOR until its text/value matches regex PATTERN."""
result = _handle("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
value = result.get("value", "") if isinstance(result, dict) else ""
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
+6 -4
View File
@@ -159,12 +159,14 @@ def group_add_tab(group, url):
@group_group.command("move")
@click.argument("group")
@click.option("--forward", is_flag=True, help="Move group one position to the right")
@click.option("--backward", is_flag=True, help="Move group one position to the left")
@click.option("-f", "--forward", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move group one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move group one position to the left")
def group_move(group, forward, backward):
"""Move a tab group forward or backward (name or ID)."""
"""Move a tab group forward/backward or right/left (name or ID)."""
if not forward and not backward:
console.print("[red]Specify --forward or --backward[/red]")
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
raise SystemExit(1)
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
if isinstance(result, dict) and not result.get("moved"):
+26
View File
@@ -78,3 +78,29 @@ def cmd_focus(pattern):
console.print(f"[green]Focused:[/green] {result.get('url', result)}")
else:
console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
@nav_group.command("open-wait")
@click.argument("url")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open in tab group")
def cmd_open_wait(url, timeout, bg, window_name, group_name):
"""Open URL in a new tab and wait until fully loaded."""
result = _handle("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": bg, "window": window_name, "group": group_name,
})
title = result.get("title", "") if isinstance(result, dict) else ""
console.print(f"[green]Loaded:[/green] {url}" + (f"{title}" if title else ""))
@nav_group.command("wait")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
def cmd_wait(tab_id, timeout, ready_state):
"""Wait until tab finishes loading."""
result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state})
console.print(f"[green]Ready:[/green] {result.get('url', '')}{result.get('title', '')}")
+38
View File
@@ -0,0 +1,38 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("page")
def page_group():
"""Inspect current page metadata."""
@page_group.command("info")
def page_info():
"""Show title, URL, readyState, language, and meta tags of the active tab."""
info = _handle("page.info") or {}
table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value")
table.add_row("Title", info.get("title") or "")
table.add_row("URL", info.get("url") or "")
table.add_row("Ready", info.get("readyState") or "")
table.add_row("Lang", info.get("lang") or "")
for key, val in (info.get("meta") or {}).items():
table.add_row(f"meta:{key}", val)
console.print(table)
+48
View File
@@ -0,0 +1,48 @@
import json
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("storage")
def storage_group():
"""Read and write the page's localStorage / sessionStorage."""
@storage_group.command("get")
@click.argument("key", required=False)
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def storage_get(key, store_type, tab_id):
"""Get a localStorage KEY (or dump all keys if omitted)."""
result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id})
if result is None:
console.print("[dim]null[/dim]")
elif isinstance(result, dict):
console.print(json.dumps(result, indent=2))
else:
console.print(str(result))
@storage_group.command("set")
@click.argument("key")
@click.argument("value")
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def storage_set(key, value, store_type, tab_id):
"""Set localStorage KEY to VALUE."""
_handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id})
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
+95 -3
View File
@@ -1,3 +1,4 @@
import base64
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from rich.console import Console
@@ -44,15 +45,18 @@ def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7)
table.add_column("Muted", width=7)
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.get("active") else ""
muted = "[yellow]✓[/yellow]" if t.get("muted") else ""
row = [
t.get("browser", "") if show_browser else None,
str(t.get("id", "")),
str(t.get("windowId", "")),
active,
muted,
(t.get("title") or "")[:60],
(t.get("url") or "")[:80],
]
@@ -98,13 +102,15 @@ def tabs_close(tab_id, inactive, duplicates):
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@click.option("--forward", is_flag=True, help="Move one position to the right")
@click.option("--backward", is_flag=True, help="Move one position to the left")
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move one position to the left")
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
@click.option("--index", type=int, default=None, help="Absolute position index in target")
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward for relative movement."""
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
_handle("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
@@ -120,6 +126,23 @@ def tabs_active(tab_id):
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = _handle("tabs.status", {"tabId": tab_id}) or {}
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.get("id", "")))
table.add_row("Window", str(tab.get("windowId", "")))
table.add_row("Active", "yes" if tab.get("active") else "no")
table.add_row("Muted", "yes" if tab.get("muted") else "no")
table.add_row("Title", tab.get("title") or "")
table.add_row("URL", tab.get("url") or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
def tabs_filter(pattern):
@@ -196,3 +219,72 @@ def tabs_merge_windows():
result = _handle("tabs.merge_windows")
count = result.get("moved", 0) if isinstance(result, dict) else 0
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False)
def tabs_mute(tab_id):
"""Mute the active tab or a specific tab."""
result = _handle("tabs.mute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab."""
result = _handle("tabs.unmute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False)
def tabs_pin(tab_id):
"""Pin the active tab or a specific tab."""
result = _handle("tabs.pin", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False)
def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab."""
result = _handle("tabs.unpin", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url")
@click.argument("pattern")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
def tabs_watch_url(pattern, tab_id, timeout):
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
result = _handle("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
url = result.get("url", "") if isinstance(result, dict) else ""
console.print(f"[green]URL matched:[/green] {url}")
@tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL.
"""
result = _handle("tabs.screenshot", {"tabId": tab_id, "format": fmt, "quality": quality})
data_url = result.get("dataUrl", "") if isinstance(result, dict) else ""
if output:
header = f"data:image/{fmt};base64,"
raw = base64.b64decode(data_url[len(header):])
with open(output, "wb") as f:
f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}")
else:
console.print(data_url)
+13 -7
View File
@@ -29,8 +29,9 @@ class Tab:
id: int
window_id: int
active: bool
title: str
url: str
muted: bool = False
title: str = ""
url: str = ""
group_id: int | None = None
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
@@ -48,6 +49,14 @@ class Tab:
"""Switch browser focus to this tab."""
self._b()._cmd("tabs.active", {"tabId": self.id})
def mute(self) -> None:
"""Mute this tab."""
self._b()._cmd("tabs.mute", {"tabId": self.id})
def unmute(self) -> None:
"""Unmute this tab."""
self._b()._cmd("tabs.unmute", {"tabId": self.id})
def reload(self) -> None:
"""Reload this tab."""
self._b()._cmd("navigate.reload", {"tabId": self.id})
@@ -87,11 +96,8 @@ class Tab:
return self._b()._cmd("tabs.html", {"tabId": self.id})
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url*."""
# Re-uses navigate.open which opens a new tab; for in-place navigation
# we target by tabId via the focus then navigate approach. For now we
# open a new tab in the same window as a convenience.
self._b()._cmd("navigate.open", {"url": url, "background": background})
"""Navigate this tab to *url* in place."""
self._b().navigate_tab(self.id, url)
# ── Group ─────────────────────────────────────────────────────────────────────
+35 -19
View File
@@ -2,13 +2,9 @@
"""
Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the
Native Messaging protocol) and the CLI (via a Unix domain socket).
Multi-browser support: the extension sends a "hello" message on startup
with a profile alias. The host uses that alias to create a unique socket
path and registers it in a shared registry file.
Chrome launches this process when extension calls connectNative().
It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import os
@@ -18,15 +14,15 @@ import struct
import sys
import threading
import uuid
from multiprocessing.connection import Listener
from pathlib import Path
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
DEFAULT_ALIAS = "default"
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
@@ -71,8 +67,7 @@ def _registry_remove(alias: str) -> None:
def _socket_path_for(alias: str) -> str:
safe = alias.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(alias)
def _resolve_profile_alias(first_msg: dict | None) -> str:
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str):
if is_windows():
while True:
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
path = Path(sock_path)
if path.exists():
path.unlink()
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
conn, _ = sock.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn,), daemon=True).start()
threading.Thread(target=handle_cli_connection, args=(conn, None), daemon=True).start()
def handle_cli_connection(conn: socket.socket) -> None:
def handle_cli_connection(conn, listener=None) -> None:
try:
data = _recv_all(conn)
data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data:
return
cmd = json.loads(data)
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
_send_all(conn, json.dumps(result).encode("utf-8"))
response = json.dumps(result).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception as exc:
try:
_send_all(conn, json.dumps({"success": False, "error": str(exc)}).encode("utf-8"))
response = json.dumps({"success": False, "error": str(exc)}).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception:
pass
finally:
conn.close()
if listener is not None:
listener.close()
# --- Socket helpers (length-prefixed framing) ---
@@ -191,7 +206,8 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
def _cleanup(alias: str):
try:
Path(_socket_path_for(alias)).unlink(missing_ok=True)
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception:
pass
_registry_remove(alias)
@@ -215,7 +231,7 @@ def main():
PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg)
SOCKET_DIR.mkdir(mode=0o700, exist_ok=True)
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
_registry_add(alias, sock_path)
+36
View File
@@ -0,0 +1,36 @@
import os
import sys
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
def is_windows() -> bool:
return sys.platform.startswith("win")
def runtime_dir() -> Path:
if is_windows():
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / APP_NAME
return Path("/tmp") / RUNTIME_DIRNAME
def registry_path() -> Path:
return runtime_dir() / "registry.json"
def install_base_dir() -> Path:
if is_windows():
return runtime_dir()
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
def sanitize_alias(alias:str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in alias.strip())
return cleaned or DEFAULT_ALIAS
def endpoint_for_alias(alias:str) -> str:
safe = sanitize_alias(alias)
if is_windows():
return rf"\\.\pipe\browser-cli-{safe}"
return str(runtime_dir() / f"{safe}.sock")
+362 -15
View File
@@ -121,17 +121,22 @@ async function dispatch(command, args) {
switch (command) {
// ── Navigation ────────────────────────────────────────────────────────
case "navigate.open": return navOpen(args);
case "navigate.to": return navTo(args);
case "navigate.reload": return navReload(args, false);
case "navigate.hard_reload": return navReload(args, true);
case "navigate.back": return navBack(args);
case "navigate.forward": return navForward(args);
case "navigate.focus": return navFocus(args);
case "navigate.wait": return navWait(args);
case "navigate.open_wait": return navOpenWait(args);
// ── Tabs ──────────────────────────────────────────────────────────────
case "tabs.list": return tabsList();
case "tabs.close": return tabsClose(args);
case "tabs.move": return tabsMove(args);
case "tabs.active": return tabsActive(args);
case "tabs.active_in_window": return tabsActiveInWindow(args);
case "tabs.status": return tabsStatus(args);
case "tabs.filter": return tabsFilter(args);
case "tabs.count": return tabsCount(args);
case "tabs.query": return tabsQuery(args);
@@ -139,6 +144,12 @@ async function dispatch(command, args) {
case "tabs.dedupe": return tabsDedupe();
case "tabs.sort": return tabsSort(args);
case "tabs.merge_windows": return tabsMergeWindows();
case "tabs.mute": return tabsMute(args);
case "tabs.unmute": return tabsUnmute(args);
case "tabs.pin": return tabsPin(args);
case "tabs.unpin": return tabsUnpin(args);
case "tabs.screenshot": return tabsScreenshot(args);
case "tabs.watch_url": return tabsWatchUrl(args);
// ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList();
@@ -157,12 +168,36 @@ async function dispatch(command, args) {
case "windows.open": return windowsOpen(args);
// ── DOM ───────────────────────────────────────────────────────────────
case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", args);
case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", args);
case "dom.scroll": return domOp("domScroll", args);
case "dom.select": return domOp("domSelect", args);
case "dom.key": return domOp("domKey", args);
case "dom.hover": return domOp("domHover", args);
case "dom.check": return domOp("domCheck", { ...args, checked: true });
case "dom.uncheck": return domOp("domCheck", { ...args, checked: false });
case "dom.clear": return domOp("domClear", args);
case "dom.focus": return domOp("domFocus", args);
case "dom.submit": return domOp("domSubmit", args);
case "dom.eval": return domEval(args);
case "dom.wait_for": return domWaitFor(args);
case "dom.poll": return domPoll(args);
// ── Page ──────────────────────────────────────────────────────────────
case "page.info": return domOp("pageInfo", {});
// ── Storage ───────────────────────────────────────────────────────────
case "storage.get": return storageGet(args);
case "storage.set": return storageSet(args);
// ── Cookies ───────────────────────────────────────────────────────────
case "cookies.list": return cookiesList(args);
case "cookies.get": return cookiesGet(args);
case "cookies.set": return cookiesSet(args);
// ── Extract ───────────────────────────────────────────────────────────
case "extract.links": return domOp("extractLinks", args);
@@ -191,9 +226,11 @@ async function dispatch(command, args) {
// ── Navigation ────────────────────────────────────────────────────────────────
async function navOpen({ url, background, window: windowName, group: groupNameOrId }) {
async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
let windowId;
if (windowName) {
if (explicitWindowId != null) {
windowId = explicitWindowId;
} else if (windowName) {
const aliases = await getAliases();
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]);
@@ -221,6 +258,11 @@ async function navOpen({ url, background, window: windowName, group: groupNameOr
return { id: tab.id, url: tab.url };
}
async function navTo({ tabId, url }) {
const tab = await chrome.tabs.update(tabId, { url });
return { id: tab.id, url: tab.url || url };
}
async function navReload({ tabId }, bypassCache) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache });
@@ -255,6 +297,25 @@ async function navFocus({ pattern }) {
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
}
async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
const tab = tabId ? { id: tabId } : await getActiveTab();
const deadline = Date.now() + timeout;
const interval = 200;
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
return tabInfo(t);
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
}
async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
const opened = await navOpen({ url, background, window: windowName, group });
return await navWait({ tabId: opened.id, timeout });
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
async function tabsList() {
@@ -264,15 +325,10 @@ async function tabsList() {
for (const w of windows) {
for (const t of w.tabs) {
tabs.push({
id: t.id,
windowId: t.windowId,
...tabInfo(t),
windowAlias: aliases[t.windowId] || null,
active: t.active,
pinned: t.pinned,
title: t.title,
url: t.url,
favIconUrl: t.favIconUrl,
groupId: t.groupId >= 0 ? t.groupId : null,
});
}
}
@@ -326,6 +382,20 @@ async function tabsActive({ tabId }) {
return { tabId };
}
async function tabsActiveInWindow({ windowId }) {
const activeTabs = await chrome.tabs.query({ windowId, active: true });
const tab = activeTabs[0];
if (!tab) {
throw new Error(`No active tab found for window ${windowId}`);
}
return tabInfo(tab);
}
async function tabsStatus({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab);
}
async function tabsFilter({ pattern }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
@@ -421,8 +491,68 @@ async function tabsMergeWindows() {
return { moved };
}
async function tabsPin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true };
}
async function tabsUnpin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false };
}
async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
let windowId;
if (tabId) {
const tab = await chrome.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true });
windowId = tab.windowId;
} else {
const tab = await getActiveTab();
windowId = tab.windowId;
}
const opts = { format };
if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format };
}
async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const url = t.url || t.pendingUrl || "";
if (regex.test(url)) return tabInfo(t);
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
}
async function tabsMute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true };
}
async function tabsUnmute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false };
}
function tabInfo(t) {
return { id: t.id, windowId: t.windowId, active: t.active, title: t.title, url: t.url };
return {
id: t.id,
windowId: t.windowId,
active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
title: t.title,
url: t.url,
};
}
// ── Groups ────────────────────────────────────────────────────────────────────
@@ -576,6 +706,131 @@ async function domOp(funcName, args) {
return results[0]?.result;
}
async function domEval({ code, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (c) => (0, eval)(c),
args: [code],
});
return results[0]?.result ?? null;
}
async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
const el = document.querySelector(sel);
if (hid) return !el || el.offsetParent === null;
if (!el) return false;
if (vis) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
return true;
},
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
}
async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
const el = document.querySelector(sel);
if (!el) return null;
if (a) return el.getAttribute(a) ?? el[a] ?? null;
return el.value !== undefined ? el.value : el.textContent.trim();
},
args: [selector, attr || null],
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
}
async function storageGet({ key, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, t) => {
const store = t === "session" ? sessionStorage : localStorage;
if (k) return store.getItem(k);
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
},
args: [key || null, type],
});
return results[0]?.result ?? null;
}
async function storageSet({ key, value, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, v, t) => {
const store = t === "session" ? sessionStorage : localStorage;
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
return true;
},
args: [key, value, type],
});
return results[0]?.result ?? false;
}
async function cookiesList({ url, domain, name } = {}) {
const details = {};
if (url) details.url = url;
if (domain) details.domain = domain;
if (name) details.name = name;
return await chrome.cookies.getAll(details);
}
async function cookiesGet({ url, name }) {
return await chrome.cookies.get({ url, name });
}
async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
const details = { url, name, value };
if (domain != null) details.domain = domain;
if (path != null) details.path = path;
if (secure != null) details.secure = secure;
if (httpOnly != null) details.httpOnly = httpOnly;
if (expirationDate != null) details.expirationDate = expirationDate;
if (sameSite != null) details.sameSite = sameSite;
return await chrome.cookies.set(details);
}
// This function is serialized and injected into the page by chrome.scripting
function contentDispatch(funcName, args) {
function domQuery({ selector }) {
@@ -613,6 +868,83 @@ function contentDispatch(funcName, args) {
function domExists({ selector }) {
return document.querySelector(selector) !== null;
}
function domKey({ selector, key }) {
const el = selector ? document.querySelector(selector) : document.activeElement;
if (selector && !el) throw new Error(`No element: ${selector}`);
const target = el || document.body;
["keydown", "keypress", "keyup"].forEach(type => {
target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
});
return true;
}
function domHover({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
return true;
}
function domCheck({ selector, checked }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.checked = checked;
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domClear({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = "";
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domFocus({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.focus();
return true;
}
function domSubmit({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
const form = el.tagName === "FORM" ? el : el.closest("form");
if (!form) throw new Error(`No form found for: ${selector}`);
form.submit();
return true;
}
function pageInfo() {
const metas = {};
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
const k = m.getAttribute("name") || m.getAttribute("property");
if (k) metas[k] = m.getAttribute("content") || "";
});
return {
title: document.title,
url: location.href,
readyState: document.readyState,
lang: document.documentElement.lang || null,
meta: metas,
};
}
function domScroll({ selector, x, y }) {
if (selector) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
return true;
}
function domSelect({ selector, value }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function extractLinks() {
const seen = new Set();
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
@@ -1050,6 +1382,8 @@ function contentDispatch(funcName, args) {
}
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
pageInfo,
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
const fn = fns[funcName];
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
@@ -1204,6 +1538,19 @@ async function getActiveTab() {
|| activeTabs[0];
}
async function resolveTabForDirectAction(tabId, actionName) {
if (tabId != null) {
return chrome.tabs.get(tabId);
}
const allTabs = await chrome.tabs.query({});
if (allTabs.length !== 1) {
throw new Error(
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
);
}
return allTabs[0];
}
async function resolveGroupId(nameOrId) {
const asInt = parseInt(nameOrId);
if (!isNaN(asInt)) return asInt;
+9 -5
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.5.7",
"version": "0.7.0",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
@@ -10,9 +10,12 @@
"windows",
"storage",
"alarms",
"nativeMessaging"
"nativeMessaging",
"cookies"
],
"host_permissions": [
"<all_urls>"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
@@ -28,5 +31,6 @@
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
}
}
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfCvygCocGbU2Bm2Rg6cnvHN0Lt25gJGJ/XX7VuAccrp4dH+Whj3Fw2vYSjgx90wuWuMl5fsWSsSX9H1k1vp7ImGzszCDnScn+o+KRWrVCQVRD1NEaKavuHoaHyc3Hs+njrM8c7c6u2ygdItZkggwPU0U1dKkixP/DWR9oG13Gr4u39p/xHxITiBh0DROYdoKBzw/J+vT7zWITKyG7QBgLMuoaYc15oqRIm7raBW1GIn1A5V2WPpBM9rMAli4vCyc9rbqsUqO1Yu4SrNIoG+wfz3MED3ajylDH6Jh1bsf1l5EZNDR/EpqBsSQcEV0VXX7nkqchqgzh3bgT9psiUQAQIDAQAB"
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "browser-cli"
version = "0.5.7"
description = "Control your real running browser from the terminal via a Chrome extension"
version = "0.7.0"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
"click>=8",
+27 -1
View File
@@ -154,6 +154,12 @@ class TestNavigation:
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
@@ -244,6 +250,18 @@ class TestTabs:
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="No active tab found for window 1"):
b.window_active_tab(1)
def test_tabs_filter(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.tabs_filter("example")
@@ -564,7 +582,15 @@ class TestTabModel:
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.open", {"url": "https://new.example.com", "background": False}, profile=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
tab.open("https://new.example.com", background=True)
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None,
)
def test_unbound_raises(self):
+86 -2
View File
@@ -1,4 +1,6 @@
from pathlib import Path
from types import SimpleNamespace
import sys
from click.testing import CliRunner
from unittest.mock import patch
@@ -31,14 +33,18 @@ def test_project_version_falls_back_to_installed_package_metadata():
assert _project_version() == "9.9.9"
def test_clients_rename_uses_command_level_browser_target():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--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_clients_rename_uses_global_browser_target_when_set():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
assert result.exit_code == 0
@@ -72,6 +78,61 @@ def test_install_help_lists_supported_browsers():
assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = []
class FakeKey:
def __init__(self, path):
self.path = path
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER="HKCU",
KEY_WRITE=0x20006,
KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100,
REG_SZ=1,
)
def fake_create_key(root, path, reserved, access):
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
"browser_cli.cli.shutil.copy2"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
assert any("@echo off" in text for text in wrapper_writes)
def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
result = CliRunner().invoke(main, ["clients"])
@@ -205,6 +266,29 @@ def test_group_list_leaves_unnamed_group_cell_empty():
assert "grey" in result.output
def test_tabs_move_accepts_right_short_alias():
with patch("browser_cli.commands.tabs.send_command") as send_command:
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
assert result.exit_code == 0
send_command.assert_called_once_with(
"tabs.move",
{"tabId": 12, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None,
)
def test_groups_move_accepts_left_short_alias():
with patch("browser_cli.commands.groups.send_command") as send_command:
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
assert result.exit_code == 0
send_command.assert_called_once_with(
"group.move", {"group": "research", "forward": False, "backward": True}, profile=None
)
def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "windows.list"
+21
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
@@ -46,6 +47,13 @@ def test_display_browser_name_uses_uuid_stem_for_default():
)
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
assert _resolve_socket("work") == endpoint_for_alias("work")
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock"
active_socket.write_text("")
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
assert len(targets) == 1
assert targets[0].profile == "work"
assert targets[0].display_name == "work"
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
+14 -1
View File
@@ -18,8 +18,9 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
monkeypatch.setattr(native_host, "SOCKET_DIR", tmp_path)
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
monkeypatch.setattr(native_host, "is_windows", lambda: False)
native_host._cleanup(alias)
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = []
messages = iter([{"type": "bye"}])
+21
View File
@@ -81,3 +81,24 @@ def test_nav_open_in_background(browser):
assert not new_tab.get("active"), "background tab should not be active"
finally:
browser("tabs.close", {"tabId": new_id})
def test_nav_to_updates_existing_tab(browser):
result = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = result["id"]
try:
before_ids = {t["id"] for t in browser("tabs.list")}
updated = browser("navigate.to", {"tabId": tab_id, "url": "https://example.org"})
assert updated["id"] == tab_id
tabs = browser("tabs.list")
after_ids = {t["id"] for t in tabs}
assert after_ids == before_ids
tab = next(t for t in tabs if t["id"] == tab_id)
assert "example.org" in (tab.get("url") or "")
finally:
try:
browser("tabs.close", {"tabId": tab_id})
except Exception:
pass
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from browser_cli.platform import endpoint_for_alias, install_base_dir, registry_path, runtime_dir
def test_runtime_dir_defaults_to_tmp_on_unix(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: False)
assert runtime_dir() == Path("/tmp/.browser_cli")
def test_runtime_dir_uses_localappdata_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert runtime_dir() == tmp_path / "LocalAppData" / "browser-cli"
assert registry_path() == tmp_path / "LocalAppData" / "browser-cli" / "registry.json"
def test_endpoint_for_alias_uses_pipe_on_windows(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
assert endpoint_for_alias("work/browser") == r"\\.\pipe\browser-cli-work_browser"
def test_install_base_dir_uses_runtime_dir_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert install_base_dir() == tmp_path / "LocalAppData" / "browser-cli"
+44
View File
@@ -12,6 +12,7 @@ def test_tabs_list(browser):
assert "windowId" in first
assert "url" in first
assert "title" in first
assert "muted" in first
def test_tabs_count(browser):
@@ -44,6 +45,20 @@ def test_tabs_active_exists(browser):
assert len(active) >= 1, "Expected at least one active tab"
def test_tabs_active_in_window(browser):
active = next(t for t in browser("tabs.list") if t.get("active"))
result = browser("tabs.active_in_window", {"windowId": active["windowId"]})
assert result["id"] == active["id"]
assert result["windowId"] == active["windowId"]
def test_tabs_status(browser):
result = browser("tabs.status", {})
assert isinstance(result, dict)
assert "id" in result
assert "muted" in result
def test_tabs_html(browser, http_tab):
html = browser("tabs.html", {"tabId": http_tab["id"]})
assert isinstance(html, str)
@@ -107,3 +122,32 @@ def test_tabs_merge_windows_no_crash(browser):
result = browser("tabs.merge_windows")
assert isinstance(result, dict)
assert "moved" in result
def test_tabs_mute_and_unmute(browser, http_tab):
muted = browser("tabs.mute", {"tabId": http_tab["id"]})
assert isinstance(muted, dict)
assert muted["tabId"] == http_tab["id"]
assert muted["muted"] is True
listed = browser("tabs.list")
listed_tab = next(t for t in listed if t["id"] == http_tab["id"])
assert listed_tab["muted"] is True
unmuted = browser("tabs.unmute", {"tabId": http_tab["id"]})
assert isinstance(unmuted, dict)
assert unmuted["tabId"] == http_tab["id"]
assert unmuted["muted"] is False
listed = browser("tabs.list")
listed_tab = next(t for t in listed if t["id"] == http_tab["id"])
assert listed_tab["muted"] is False
status = browser("tabs.status", {"tabId": http_tab["id"]})
assert status["muted"] is False
def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser):
opened = browser("navigate.open", {"url": "https://example.com", "background": True})
try:
with pytest.raises(RuntimeError, match="Refusing to mute without explicit tab ID"):
browser("tabs.mute", {})
finally:
browser("tabs.close", {"tabId": opened["id"]})
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.5.6"
version = "0.7.0"
source = { editable = "." }
dependencies = [
{ name = "click" },