Compare commits

...

9 Commits

Author SHA1 Message Date
daniel156161 1bf44c0eef update uv lock file
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 14s
Build & Publish Package / publish (push) Successful in 26s
2026-04-17 21:09:27 +02:00
daniel156161 cf0c9555d0 update version to 0.7.1
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 13s
Build & Publish Package / publish (push) Successful in 31s
2026-04-17 21:08:18 +02:00
daniel156161 a7da6cfab0 hardcode extension id and not prompt user
Testing / test (push) Has been cancelled
2026-04-17 21:07:30 +02:00
daniel156161 88b4f5ed11 add key generation script 2026-04-17 21:06:52 +02:00
daniel156161 36abde501c update version to 0.7.0
Testing / test (push) Successful in 23s
Package Extension / package-extension (push) Successful in 21s
Build & Publish Package / publish (push) Successful in 24s
2026-04-17 20:52:47 +02:00
daniel156161 1aff084429 add deterministic extension key
Testing / test (push) Waiting to run
Extension ID: bfpmkhngkjnfhabmfckgeohlilokodkg
2026-04-17 20:45:36 +02:00
daniel156161 1c5fd0ffee feat: add browser automation commands (v0.6.0)
Testing / test (push) Successful in 24s
Package Extension / package-extension (push) Successful in 9s
Build & Publish Package / publish (push) Successful in 21s
Navigation: open-wait (open + block until loaded)
DOM: key, hover, check/uncheck, clear, focus, submit, poll, scroll, select, eval, wait-for
Tabs: pin/unpin, screenshot, watch-url (block until URL matches regex)
New command groups: page info, storage get/set, cookies list/get/set
Extension: add cookies permission
2026-04-16 14:21:19 +02:00
daniel156161 fc4ce8f74d rename Chrome to Browser
Testing / test (push) Successful in 20s
2026-04-16 11:55:09 +02:00
daniel156161 cd2ebc2982 set defaults to tab empyt url and title with muted false
Testing / test (push) Successful in 31s
2026-04-14 13:21:21 +02:00
16 changed files with 1019 additions and 29 deletions
+7 -7
View File
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for. 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 IPC channel. 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.
--- ---
@@ -23,9 +23,9 @@ terminal / python script
│ Native Messaging Protocol (stdin/stdout, 4-byte length prefix + JSON) │ 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 Your running browser
``` ```
@@ -53,7 +53,7 @@ Every response:
## Installation ## 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 ```sh
git clone <repo> git clone <repo>
@@ -63,8 +63,8 @@ uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi
``` ```
The `install` command will: The `install` command will:
1. Ask you to load the `extension/` folder as an unpacked extension in your browser (`brave://extensions` → Developer mode → Load unpacked) 1. Ask you to load the browser-specific extension package
2. Ask you to paste the extension ID shown on the extension card 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 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` 4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
@@ -404,7 +404,7 @@ bash examples/demo.sh
## Limitations ## Limitations
- **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **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>`. - **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. - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
- **Linux and macOS only** — Windows native messaging paths are not yet handled. - **Linux and macOS only** — Windows native messaging paths are not yet handled.
+232
View File
@@ -122,6 +122,45 @@ class BrowserCLI:
"""Navigate a specific tab to *url*.""" """Navigate a specific tab to *url*."""
self._cmd("navigate.to", {"tabId": tab_id, "url": 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 ──────────────────────────────────────────────────────────── # ── Search ────────────────────────────────────────────────────────────
def search( def search(
@@ -190,6 +229,44 @@ class BrowserCLI:
result = self._cmd("tabs.unmute", {"tabId": 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) 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: def window_active_tab(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window.""" """Return active tab for a specific browser window."""
data = self._cmd("tabs.active_in_window", {"windowId": window_id}) data = self._cmd("tabs.active_in_window", {"windowId": window_id})
@@ -346,6 +423,161 @@ class BrowserCLI:
def dom_exists(self, selector: str) -> bool: def dom_exists(self, selector: str) -> bool:
return self._cmd("dom.exists", {"selector": selector}) 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 ─────────────────────────────────────────────────────────── # ── Extract ───────────────────────────────────────────────────────────
def extract_links(self) -> list[dict]: def extract_links(self) -> list[dict]:
+10 -4
View File
@@ -21,6 +21,9 @@ from browser_cli.commands.dom import dom_group
from browser_cli.commands.extract import extract_group from browser_cli.commands.extract import extract_group
from browser_cli.commands.session import session_group from browser_cli.commands.session import session_group
from browser_cli.commands.search import search_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 ( from browser_cli.client import (
send_command, send_command,
BrowserNotConnected, BrowserNotConnected,
@@ -33,6 +36,7 @@ from browser_cli.platform import install_base_dir, is_windows
console = Console() console = Console()
NATIVE_HOST_NAME = "com.browsercli.host" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
NATIVE_HOST_DIRS = { NATIVE_HOST_DIRS = {
"chrome": { "chrome": {
@@ -179,6 +183,9 @@ main.add_command(dom_group)
main.add_command(extract_group) main.add_command(extract_group)
main.add_command(session_group) main.add_command(session_group)
main.add_command(search_group) main.add_command(search_group)
main.add_command(page_group)
main.add_command(storage_group)
main.add_command(cookies_group)
# ── clients ──────────────────────────────────────────────────────────────────── # ── clients ────────────────────────────────────────────────────────────────────
@@ -278,7 +285,7 @@ def cmd_install(browser):
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n' wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8") wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Ask for extension ID # Load extension
ext_urls = { ext_urls = {
"chrome": "chrome://extensions", "chrome": "chrome://extensions",
"chromium": "chrome://extensions", "chromium": "chrome://extensions",
@@ -291,10 +298,9 @@ def cmd_install(browser):
console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]") console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]")
console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n") console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
extension_id = click.prompt("Paste your extension ID here") extension_id = EXTENSION_ID
extension_id = extension_id.strip()
# Build native messaging manifest # Build native messaging manifest
manifest = { manifest = {
+1 -1
View File
@@ -125,7 +125,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
f"Cannot connect to browser{profile_hint}.\n" f"Cannot connect to browser{profile_hint}.\n"
"Make sure:\n" "Make sure:\n"
" 1. The browser-cli extension is installed and enabled\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" " 3. Your browser is running\n"
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile" " Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
) )
+86
View File
@@ -0,0 +1,86 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("cookies")
def cookies_group():
"""Manage browser cookies."""
@cookies_group.command("list")
@click.option("--url", default=None, help="Filter by URL")
@click.option("--domain", default=None, help="Filter by domain")
@click.option("--name", default=None, help="Filter by cookie name")
def cookies_list(url, domain, name):
"""List cookies, optionally filtered by URL, domain, or name."""
cookies = _handle("cookies.list", {"url": url, "domain": domain, "name": name}) or []
if not cookies:
console.print("[yellow]No cookies found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Value")
table.add_column("Domain")
table.add_column("Path")
table.add_column("Secure", width=7)
table.add_column("HttpOnly", width=9)
for c in cookies:
table.add_row(
c.get("name", ""),
(c.get("value") or "")[:60],
c.get("domain", ""),
c.get("path", ""),
"[green]✓[/green]" if c.get("secure") else "",
"[green]✓[/green]" if c.get("httpOnly") else "",
)
console.print(table)
@cookies_group.command("get")
@click.argument("url")
@click.argument("name")
def cookies_get(url, name):
"""Get the value of a single cookie by URL and NAME."""
cookie = _handle("cookies.get", {"url": url, "name": name})
if cookie is None:
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
raise SystemExit(1)
console.print(cookie.get("value", ""))
@cookies_group.command("set")
@click.argument("url")
@click.argument("name")
@click.argument("value")
@click.option("--domain", default=None)
@click.option("--path", default=None)
@click.option("--secure", is_flag=True)
@click.option("--http-only", "http_only", is_flag=True)
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
"""Set a cookie on URL."""
_handle("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure or None,
"httpOnly": http_only or None,
"expirationDate": expiration_date,
"sameSite": same_site,
})
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
+130
View File
@@ -87,3 +87,133 @@ def dom_exists(selector):
else: else:
console.print(f"[red]not found[/red]: {selector}") console.print(f"[red]not found[/red]: {selector}")
raise SystemExit(1) 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}")
+26
View File
@@ -78,3 +78,29 @@ def cmd_focus(pattern):
console.print(f"[green]Focused:[/green] {result.get('url', result)}") console.print(f"[green]Focused:[/green] {result.get('url', result)}")
else: else:
console.print(f"[yellow]No tab found matching:[/yellow] {pattern}") console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
@nav_group.command("open-wait")
@click.argument("url")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open in tab group")
def cmd_open_wait(url, timeout, bg, window_name, group_name):
"""Open URL in a new tab and wait until fully loaded."""
result = _handle("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": bg, "window": window_name, "group": group_name,
})
title = result.get("title", "") if isinstance(result, dict) else ""
console.print(f"[green]Loaded:[/green] {url}" + (f"{title}" if title else ""))
@nav_group.command("wait")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
def cmd_wait(tab_id, timeout, ready_state):
"""Wait until tab finishes loading."""
result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state})
console.print(f"[green]Ready:[/green] {result.get('url', '')}{result.get('title', '')}")
+38
View File
@@ -0,0 +1,38 @@
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
from rich.table import Table
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("page")
def page_group():
"""Inspect current page metadata."""
@page_group.command("info")
def page_info():
"""Show title, URL, readyState, language, and meta tags of the active tab."""
info = _handle("page.info") or {}
table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value")
table.add_row("Title", info.get("title") or "")
table.add_row("URL", info.get("url") or "")
table.add_row("Ready", info.get("readyState") or "")
table.add_row("Lang", info.get("lang") or "")
for key, val in (info.get("meta") or {}).items():
table.add_row(f"meta:{key}", val)
console.print(table)
+48
View File
@@ -0,0 +1,48 @@
import json
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
console = Console()
def _handle(command, args=None):
try:
return send_command(command, args or {})
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
@click.group("storage")
def storage_group():
"""Read and write the page's localStorage / sessionStorage."""
@storage_group.command("get")
@click.argument("key", required=False)
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def storage_get(key, store_type, tab_id):
"""Get a localStorage KEY (or dump all keys if omitted)."""
result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id})
if result is None:
console.print("[dim]null[/dim]")
elif isinstance(result, dict):
console.print(json.dumps(result, indent=2))
else:
console.print(str(result))
@storage_group.command("set")
@click.argument("key")
@click.argument("value")
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def storage_set(key, value, store_type, tab_id):
"""Set localStorage KEY to VALUE."""
_handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id})
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
+52
View File
@@ -1,3 +1,4 @@
import base64
import click import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from rich.console import Console from rich.console import Console
@@ -236,3 +237,54 @@ def tabs_unmute(tab_id):
result = _handle("tabs.unmute", {"tabId": tab_id}) result = _handle("tabs.unmute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Unmuted tab {target}[/green]") 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)
+3 -3
View File
@@ -29,9 +29,9 @@ class Tab:
id: int id: int
window_id: int window_id: int
active: bool active: bool
muted: bool muted: bool = False
title: str title: str = ""
url: str url: str = ""
group_id: int | None = None group_id: int | None = None
browser: str | None = None browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
+300 -6
View File
@@ -127,6 +127,8 @@ async function dispatch(command, args) {
case "navigate.back": return navBack(args); case "navigate.back": return navBack(args);
case "navigate.forward": return navForward(args); case "navigate.forward": return navForward(args);
case "navigate.focus": return navFocus(args); case "navigate.focus": return navFocus(args);
case "navigate.wait": return navWait(args);
case "navigate.open_wait": return navOpenWait(args);
// ── Tabs ────────────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────
case "tabs.list": return tabsList(); case "tabs.list": return tabsList();
@@ -144,6 +146,10 @@ async function dispatch(command, args) {
case "tabs.merge_windows": return tabsMergeWindows(); case "tabs.merge_windows": return tabsMergeWindows();
case "tabs.mute": return tabsMute(args); case "tabs.mute": return tabsMute(args);
case "tabs.unmute": return tabsUnmute(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 ──────────────────────────────────────────────────────────── // ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList(); case "group.list": return groupList();
@@ -162,12 +168,36 @@ async function dispatch(command, args) {
case "windows.open": return windowsOpen(args); case "windows.open": return windowsOpen(args);
// ── DOM ─────────────────────────────────────────────────────────────── // ── DOM ───────────────────────────────────────────────────────────────
case "dom.query": return domOp("domQuery", args); case "dom.query": return domOp("domQuery", args);
case "dom.click": return domOp("domClick", args); case "dom.click": return domOp("domClick", args);
case "dom.type": return domOp("domType", args); case "dom.type": return domOp("domType", args);
case "dom.attr": return domOp("domAttr", args); case "dom.attr": return domOp("domAttr", args);
case "dom.text": return domOp("domText", args); case "dom.text": return domOp("domText", args);
case "dom.exists": return domOp("domExists", 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 ─────────────────────────────────────────────────────────── // ── Extract ───────────────────────────────────────────────────────────
case "extract.links": return domOp("extractLinks", args); case "extract.links": return domOp("extractLinks", args);
@@ -267,6 +297,25 @@ async function navFocus({ pattern }) {
return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; 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 ────────────────────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────────────
async function tabsList() { async function tabsList() {
@@ -442,6 +491,47 @@ async function tabsMergeWindows() {
return { moved }; 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 }) { async function tabsMute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "mute"); const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true }); await chrome.tabs.update(tab.id, { muted: true });
@@ -616,6 +706,131 @@ async function domOp(funcName, args) {
return results[0]?.result; 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 // This function is serialized and injected into the page by chrome.scripting
function contentDispatch(funcName, args) { function contentDispatch(funcName, args) {
function domQuery({ selector }) { function domQuery({ selector }) {
@@ -653,6 +868,83 @@ function contentDispatch(funcName, args) {
function domExists({ selector }) { function domExists({ selector }) {
return document.querySelector(selector) !== null; 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() { function extractLinks() {
const seen = new Set(); const seen = new Set();
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => { return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
@@ -1090,6 +1382,8 @@ function contentDispatch(funcName, args) {
} }
const fns = { domQuery, domClick, domType, domAttr, domText, domExists, const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
pageInfo,
extractLinks, extractImages, extractText, extractJson, extractMarkdown }; extractLinks, extractImages, extractText, extractJson, extractMarkdown };
const fn = fns[funcName]; const fn = fns[funcName];
if (!fn) throw new Error(`Unknown content function: ${funcName}`); if (!fn) throw new Error(`Unknown content function: ${funcName}`);
+9 -5
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.5.12", "version": "0.7.1",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "tabs",
@@ -10,9 +10,12 @@
"windows", "windows",
"storage", "storage",
"alarms", "alarms",
"nativeMessaging" "nativeMessaging",
"cookies"
],
"host_permissions": [
"<all_urls>"
], ],
"host_permissions": ["<all_urls>"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
@@ -28,5 +31,6 @@
"16": "icons/icon-16.png", "16": "icons/icon-16.png",
"32": "icons/icon-32.png" "32": "icons/icon-32.png"
} }
} },
} "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfCvygCocGbU2Bm2Rg6cnvHN0Lt25gJGJ/XX7VuAccrp4dH+Whj3Fw2vYSjgx90wuWuMl5fsWSsSX9H1k1vp7ImGzszCDnScn+o+KRWrVCQVRD1NEaKavuHoaHyc3Hs+njrM8c7c6u2ygdItZkggwPU0U1dKkixP/DWR9oG13Gr4u39p/xHxITiBh0DROYdoKBzw/J+vT7zWITKyG7QBgLMuoaYc15oqRIm7raBW1GIn1A5V2WPpBM9rMAli4vCyc9rbqsUqO1Yu4SrNIoG+wfz3MED3ajylDH6Jh1bsf1l5EZNDR/EpqBsSQcEV0VXX7nkqchqgzh3bgT9psiUQAQIDAQAB"
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.5.12" version = "0.7.1"
description = "Control your real running browser from the terminal via a Chrome extension" description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"click>=8", "click>=8",
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env -S uv run
"""Generate or derive Chrome extension key and ID.
Usage:
python scripts/gen_extension_key.py # generate new key pair
python scripts/gen_extension_key.py --from-manifest # derive ID from extension/manifest.json
python scripts/gen_extension_key.py --key <base64> # derive ID from given public key
"""
import argparse, hashlib
import base64, json, sys
from pathlib import Path
def public_key_to_extension_id(pub_key_der:bytes) -> str:
digest = hashlib.sha256(pub_key_der).hexdigest()
return "".join(chr(ord("a") + int(c, 16)) for c in digest[:32])
def derive_from_key_b64(key_b64:str) -> tuple[str, str]:
der = base64.b64decode(key_b64)
ext_id = public_key_to_extension_id(der)
return key_b64, ext_id
def generate_new_key() -> tuple[str, str, str]:
try:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
PrivateFormat,
NoEncryption,
)
except ImportError:
print("Install 'cryptography' to generate new keys: pip install cryptography", file=sys.stderr)
sys.exit(1)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_der = private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
priv_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()).decode()
key_b64 = base64.b64encode(pub_der).decode()
ext_id = public_key_to_extension_id(pub_der)
return key_b64, ext_id, priv_pem
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Chrome extension key/ID tool")
group = parser.add_mutually_exclusive_group()
group.add_argument("--from-manifest", action="store_true", help="Derive ID from extension/manifest.json")
group.add_argument("--key", metavar="BASE64", help="Derive ID from given base64 public key")
args = parser.parse_args()
if args.from_manifest:
manifest_path = Path(__file__).parent.parent / "extension" / "manifest.json"
manifest = json.loads(manifest_path.read_text())
key_b64 = manifest.get("key")
if not key_b64:
print("No 'key' field in manifest.json", file=sys.stderr)
sys.exit(1)
key_b64, ext_id = derive_from_key_b64(key_b64)
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
elif args.key:
key_b64, ext_id = derive_from_key_b64(args.key)
print(f"Extension ID: {ext_id}")
else:
key_b64, ext_id, priv_pem = generate_new_key()
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
print()
print("Add this to extension/manifest.json:")
print(f' "key": "{key_b64}"')
print()
print("Private key (keep secret, needed to re-derive same ID):")
print(priv_pem)
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.5.7" version = "0.7.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },