Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
36abde501c
|
|||
|
1aff084429
|
|||
|
1c5fd0ffee
|
|||
|
fc4ce8f74d
|
|||
|
cd2ebc2982
|
|||
|
edf9056430
|
|||
|
c494e76fe2
|
|||
|
5150933319
|
|||
|
9dbe57c66c
|
|||
|
080ca6da6d
|
|||
|
d0c1d7c226
|
|||
|
2a38997946
|
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+60
-7
@@ -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)
|
||||
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,11 +311,17 @@ def cmd_install(browser):
|
||||
"allowed_origins": [f"chrome-extension://{extension_id}/"],
|
||||
}
|
||||
|
||||
# Write to OS native messaging dirs
|
||||
installed = []
|
||||
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]
|
||||
|
||||
installed = []
|
||||
for d in dirs:
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -288,11 +336,16 @@ def cmd_install(browser):
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
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]")
|
||||
|
||||
|
||||
+19
-11
@@ -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,6 +110,11 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
framed = struct.pack("<I", len(payload)) + payload
|
||||
|
||||
try:
|
||||
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)
|
||||
@@ -117,7 +125,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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', '')}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
+34
-18
@@ -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,6 +206,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
|
||||
|
||||
def _cleanup(alias: str):
|
||||
try:
|
||||
if not is_windows():
|
||||
Path(_socket_path_for(alias)).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
+356
-9
@@ -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();
|
||||
@@ -163,6 +174,30 @@ async function dispatch(command, 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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]})
|
||||
|
||||
Reference in New Issue
Block a user