refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s

Restructure the Python API and internals around composable namespaces and
a standalone transport/endpoint layer. Bump to 0.12.0.

Python API:
- Replace flat methods (b.tabs_list(), b.group_list()) with namespaces:
  b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage,
  b.cookies, b.session, b.perf, b.extension.
- Shrink browser_cli/__init__.py to a thin composition root; move all
  behaviour into browser_cli/sdk/ (one module per namespace + factories,
  base, routing).

Internals:
- Add browser_cli/transport.py and remote_transport.py to isolate IPC from
  command logic; client.py now delegates instead of owning transport.
- Add browser_cli/endpoints.py for endpoint resolution and
  browser_cli/errors.py for shared error types.
- Extract markdown rendering into browser_cli/markdown.py (out of extract).
- Add USER_AGENT to version_manager.

Tooling & tests:
- Add justfile with common dev tasks.
- Update CLI commands and demo to the namespaced API.
- Rework tests for the new layout; add test_transport.py and
  test_refactor_boundaries.py to lock in module boundaries.

BREAKING CHANGE: flat API methods are removed in favour of namespaces
(e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
2026-06-11 13:58:41 +02:00
parent 0813ae2de9
commit fd5447cbb9
52 changed files with 3344 additions and 2348 deletions
+61 -821
View File
@@ -5,34 +5,66 @@ Usage:
from browser_cli import BrowserCLI
b = BrowserCLI()
tabs = b.tabs_list() # list[Tab]
tabs = b.tabs.list() # list[Tab]
tabs[0].close()
tabs[0].move(forward=True)
groups = b.group_list() # list[Group]
groups = b.groups.list() # list[Group]
groups[0].tabs()
groups[0].add_tab("https://example.com")
b.nav.open("https://example.com")
b.dom.click("#submit")
b.session.save("work")
# When multiple browser instances are active, pass the alias:
b = BrowserCLI(browser="brave")
"""
from collections.abc import Callable, Iterable
from dataclasses import dataclass
Commands are grouped into namespaces on the client:
b.nav navigation (open, reload, back, forward, focus, search)
b.tabs tabs (list, open, close, move, status, mute, sort, ...)
b.groups tab groups (list, create, add_tab, move, close)
b.windows browser windows (list, open, close, rename)
b.dom page elements (query, click, type, wait_for, eval, ...)
b.extract content extraction (links, images, text, json, markdown)
b.page page info
b.storage localStorage / sessionStorage
b.cookies cookies (list, get, set)
b.session sessions (save, load, list, diff, ...)
b.perf performance profile + background jobs
b.extension control the extension itself
"""
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.models import Group, Tab
from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk import (
CookiesNS,
DomNS,
ExtensionNS,
ExtractNS,
GroupsNS,
NavigationNS,
PageNS,
PerfNS,
SessionNS,
StorageNS,
TabsNS,
WindowsNS,
)
from browser_cli.sdk.factories import FactoryMixin
from browser_cli.sdk.routing import RoutingMixin
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
class BrowserCLI(FactoryMixin, RoutingMixin):
"""Client for a running browser, with commands grouped into namespaces.
@dataclass(frozen=True)
class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
The client itself holds the connection target (browser/remote/key) and the
shared machinery; the actual commands live on namespace accessors such as
:attr:`tabs`, :attr:`dom`, and :attr:`session`. Object construction
(``Tab``/``Group``) comes from :class:`~browser_cli.sdk.factories.FactoryMixin`
and multi-browser fan-out from :class:`~browser_cli.sdk.routing.RoutingMixin`.
"""
class BrowserCLI:
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
"""
Args:
@@ -50,6 +82,20 @@ class BrowserCLI:
self._remote = remote
self._key = key if key else None
# Command namespaces.
self.nav = NavigationNS(self)
self.tabs = TabsNS(self)
self.groups = GroupsNS(self)
self.windows = WindowsNS(self)
self.dom = DomNS(self)
self.extract = ExtractNS(self)
self.page = PageNS(self)
self.storage = StorageNS(self)
self.cookies = CookiesNS(self)
self.session = SessionNS(self)
self.perf = PerfNS(self)
self.extension = ExtensionNS(self)
@property
def browser(self) -> str | None:
"""Target browser/profile alias, equivalent to ``--browser``."""
@@ -72,816 +118,10 @@ class BrowserCLI:
"""Send a raw browser-cli command and return its response.
This is the SDK escape hatch for commands that do not have a dedicated
convenience method yet.
namespace method yet.
"""
return self._cmd(command, args or {})
def _multi_browser_targets(self):
if self._browser is not None:
return []
if self._remote:
targets = remote_browser_targets(self._remote, key=self._key)
else:
targets = active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if targets:
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
"Make sure the browser is running with the browser-cli extension enabled,\n"
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
)
return []
# ── Internal factories ────────────────────────────────────────────────
def _make_tab(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Tab:
tab = Tab(
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,
browser=browser_name,
)
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return tab
def _make_group(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
browser=browser_name,
)
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return group
# ── Navigation ────────────────────────────────────────────────────────
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
def open_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> "Tab":
"""Open URL in a new tab and return a bound :class:`Tab` object.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self.open_wait(url, timeout=timeout, background=background, window=window, group=group)
data = self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open returned unexpected data")
return self._make_tab(data)
def reload(self, tab_id: int | None = None) -> None:
self._cmd("navigate.reload", {"tabId": tab_id})
def hard_reload(self, tab_id: int | None = None) -> None:
self._cmd("navigate.hard_reload", {"tabId": tab_id})
def back(self, tab_id: int | None = None) -> None:
self._cmd("navigate.back", {"tabId": tab_id})
def forward(self, tab_id: int | None = None) -> None:
self._cmd("navigate.forward", {"tabId": tab_id})
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,
})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open_wait returned unexpected data")
return self._make_tab(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(
self, engine: str, query: str, *,
background: bool = False, window: str | None = None, group: str | None = None,
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
url = template.format(query=quote_plus(query))
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
# ── Tabs ──────────────────────────────────────────────────────────────
def tabs(self) -> list[Tab]:
"""Alias for :meth:`tabs_list`."""
return self.tabs_list()
def tab(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.tabs_status(tab_id)
def active_tab(self) -> Tab:
"""Return the active tab."""
return self.tabs_status()
def find_tabs(self, search: str) -> list[Tab]:
"""Alias for :meth:`tabs_query`."""
return self.tabs_query(search)
def find_tab(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.tabs_query(search)
return matches[0] if matches else None
def close_tab(self, tab: int | Tab) -> int:
"""Close a tab by ID or :class:`Tab` object. Returns count closed."""
tab_id = tab.id if isinstance(tab, Tab) else tab
return self.tabs_close(tab_id)
def tabs_list(self) -> list[Tab]:
"""Return all open tabs across all windows.
When multiple browsers are active and no browser was specified, each Tab
includes ``tab.browser`` naming its source browser.
"""
multi_results = self._collect_multi_browser("tabs.list", {})
if multi_results:
return [
self._make_tab(
tab,
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
)
for target, tabs in multi_results
for tab in (tabs or [])
]
return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])]
def tabs_close(
self,
tab_id: int | None = None,
*,
tab_ids: Iterable[int | Tab] | None = None,
inactive: bool = False,
duplicates: bool = False,
) -> int:
"""Close tab(s). Returns the number of tabs closed.
Pass ``tab_ids`` to close many tabs in a single round-trip instead of
calling :meth:`close_tab` per tab. Accepts tab IDs or :class:`Tab`
objects. The extension throttles large batches automatically.
"""
ids = None
if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
result = self._cmd("tabs.close", {
"tabId": tab_id,
"tabIds": ids,
"inactive": inactive,
"duplicates": duplicates,
})
return result.get("closed", 1) if isinstance(result, dict) else 1
def tabs_move(
self, tab_id: int, *,
forward: bool = False, backward: bool = False,
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
) -> None:
self._cmd("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
def tabs_active(self, tab_id: int) -> None:
"""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)})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("tabs.watch_url returned unexpected data")
return self._make_tab(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):
return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
return self._apply_tab_filter(pattern_or_filter)
def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts:
"""Count open tabs, optionally filtered by URL pattern.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
multi_results = self._collect_multi_browser("tabs.count", {"pattern": pattern})
if multi_results:
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
return self._cmd("tabs.count", {"pattern": pattern})
def tabs_query(self, search: str) -> list[Tab]:
"""Search tabs by URL or title."""
return [self._make_tab(t) for t in (self._cmd("tabs.query", {"search": search}) or [])]
def tabs_html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active (or specified) tab."""
return self._cmd("tabs.html", {"tabId": tab_id}) or ""
def tabs_dedupe(self) -> int:
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
result = self._cmd("tabs.dedupe", {})
return result.get("closed", 0) if isinstance(result, dict) else 0
def tabs_sort(self, by: str = "domain") -> None:
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
self._cmd("tabs.sort", {"by": by})
def tabs_merge_windows(self) -> int:
"""Move all tabs into the focused window. Returns count moved."""
result = self._cmd("tabs.merge_windows", {})
return result.get("moved", 0) if isinstance(result, dict) else 0
def tabs_close_inactive(self) -> int:
"""Close all inactive tabs. Returns count closed."""
result = self._cmd("tabs.close", {"inactive": True})
return result.get("closed", 0) if isinstance(result, dict) else 0
def tabs_close_duplicates(self) -> int:
"""Close duplicate tabs. Returns count closed."""
result = self._cmd("tabs.close", {"duplicates": True})
return result.get("closed", 0) if isinstance(result, dict) else 0
# ── Tab Groups ────────────────────────────────────────────────────────
def groups(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def groups_list(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def group_list(self) -> list[Group]:
"""Return all tab groups.
When multiple browsers are active and no browser was specified, each Group
includes ``group.browser`` naming its source browser.
"""
multi_results = self._collect_multi_browser("group.list", {})
if multi_results:
return [
self._make_group(
group,
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
)
for target, groups in multi_results
for group in (groups or [])
]
return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])]
def group_tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group."""
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
def groups_count(self) -> int | BrowserCounts:
"""Alias for :meth:`group_count`."""
return self.group_count()
def group_count(self) -> int | BrowserCounts:
"""Return the number of tab groups.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
multi_results = self._collect_multi_browser("group.count", {})
if multi_results:
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
return self._cmd("group.count", {})
def groups_query(self, search: str) -> list[Group]:
"""Alias for :meth:`group_query`."""
return self.group_query(search)
def group_query(self, search: str) -> list[Group]:
"""Search groups by name."""
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
def group_close(self, group_id: int) -> None:
"""Ungroup (and close) a tab group by ID."""
self._cmd("group.close", {"groupId": group_id})
def groups_create(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_open(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group."""
data = self._cmd("group.open", {"name": name})
return self._make_group(data) if isinstance(data, dict) else Group(id=data, title=name, color="", collapsed=False, tab_count=0)
def group_add_tab(self, group: str | int, url: str | None = None) -> int | None:
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
result = self._cmd("group.add_tab", {"group": str(group), "url": url})
return result.get("tabId") if isinstance(result, dict) else result
def group_move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> None:
"""Move a tab group forward or backward."""
self._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
# ── Windows ───────────────────────────────────────────────────────────
def windows_list(self) -> list[dict]:
"""Return browser windows.
In implicit multi-browser mode each window dict includes a ``browser`` key.
"""
multi_results = self._collect_multi_browser("windows.list", {})
if multi_results:
return [
{**window, "browser": target.display_name}
for target, windows in multi_results
for window in (windows or [])
]
return self._cmd("windows.list", {}) or []
def windows_rename(self, window_id: int, name: str) -> None:
self._cmd("windows.rename", {"windowId": window_id, "name": name})
def windows_close(self, window_id: int) -> None:
self._cmd("windows.close", {"windowId": window_id})
def windows_open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
return self._cmd("windows.open", {"url": url}) or {}
# ── DOM ───────────────────────────────────────────────────────────────
def dom_query(self, selector: str) -> list[dict]:
return self._cmd("dom.query", {"selector": selector}) or []
def dom_click(self, selector: str) -> None:
self._cmd("dom.click", {"selector": selector})
def dom_type(self, selector: str, text: str) -> None:
self._cmd("dom.type", {"selector": selector, "text": text})
def dom_attr(self, selector: str, attr: str) -> list[str]:
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
def dom_text(self, selector: str) -> list[str]:
return self._cmd("dom.text", {"selector": selector}) or []
def dom_exists(self, selector: str) -> bool:
return self._cmd("dom.exists", {"selector": selector}) or False
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 wait_for_selector(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Alias for :meth:`dom_wait_for`."""
return self.dom_wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=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]:
return self._cmd("extract.links", {}) or []
def extract_images(self) -> list[dict]:
return self._cmd("extract.images", {}) or []
def extract_text(self) -> str:
return self._cmd("extract.text", {}) or ""
def extract_json(self, selector: str):
return self._cmd("extract.json", {"selector": selector})
def extract_markdown(self, selector: str | None = None) -> str:
return self._cmd("extract.markdown", {"selector": selector}) or ""
# ── Session ───────────────────────────────────────────────────────────
def session_save(self, name: str) -> None:
self._cmd("session.save", {"name": name})
def session_load(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> None:
self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
})
def session_load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
return self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": True,
}) or {}
def job_status(self, job_id: str) -> dict:
return self._cmd("jobs.status", {"jobId": job_id}) or {}
def job_cancel(self, job_id: str) -> dict:
return self._cmd("jobs.cancel", {"jobId": job_id}) or {}
def reload_extension(self) -> None:
"""Reload the browser-cli extension service worker.
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
self._cmd("extension.reload", {})
def perf_status(self) -> dict:
return self._cmd("perf.status", {}) or {}
def set_performance_profile(self, profile: str) -> dict:
return self._cmd("perf.set_profile", {"profile": profile}) or {}
def session_diff(self, name_a: str, name_b: str) -> dict:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
def session_list(self) -> list[dict]:
"""Return saved sessions.
In implicit multi-browser mode each session dict includes a ``browser`` key.
"""
multi_results = self._collect_multi_browser("session.list", {})
if multi_results:
return [
{**session, "browser": target.display_name}
for target, sessions in multi_results
for session in (sessions or [])
]
return self._cmd("session.list", {}) or []
def session_remove(self, name: str) -> None:
self._cmd("session.remove", {"name": name})
def session_auto_save(self, enabled: bool) -> None:
self._cmd("session.auto_save", {"enabled": enabled})
# ── Misc ──────────────────────────────────────────────────────────────
def clients(self) -> list[dict]:
"""Return the active browser clients known to this connection."""
return self._cmd("clients.list", {})
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
tabs = self.tabs_list()
try:
transformed = filter_fn(tabs)
except (AttributeError, TypeError):
return [tab for tab in tabs if filter_fn(tab)]
if isinstance(transformed, list):
return transformed
if isinstance(transformed, tuple):
return list(transformed)
if isinstance(transformed, set):
return list(transformed)
if transformed is tabs:
return tabs
if isinstance(transformed, bool):
return [tab for tab in tabs if filter_fn(tab)]
try:
return list(transformed)
except TypeError:
return [tab for tab in tabs if filter_fn(tab)]
+10 -33
View File
@@ -88,7 +88,6 @@ WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser:
return target_browser
@@ -98,7 +97,6 @@ def _rename_target_profile(target_browser: str | None) -> str | None:
return active[0].profile
return None
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
target_profile = _rename_target_profile(target_browser)
@@ -107,14 +105,12 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_exe() -> Path:
base = install_base_dir()
if is_windows():
return base / "libexec" / "browser-cli-native-host.cmd"
return base / "libexec" / "browser-cli-native-host"
def _write_native_host_exe(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
@@ -128,13 +124,11 @@ def _write_native_host_exe(path: Path) -> None:
)
path.chmod(path.stat().st_mode | 0o111)
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
@@ -152,7 +146,6 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
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:
@@ -168,7 +161,6 @@ def _project_version() -> str:
except PackageNotFoundError:
return "unknown"
def _print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
@@ -214,14 +206,12 @@ def main(ctx, browser, remote, key):
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
# ── auth ──────────────────────────────────────────────────────────────────────
@click.group("auth")
def auth_group():
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
@auth_group.command("keygen")
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
@click.option("--force", is_flag=True, help="Overwrite existing key.")
@@ -243,7 +233,6 @@ def cmd_auth_keygen(output, force):
console.print(f"\nOn the serve host, trust this key:")
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
@auth_group.command("trust")
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@@ -290,7 +279,6 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("show")
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
@@ -325,7 +313,6 @@ def cmd_auth_show(key_src):
console.print(f"[red]Failed to load key:[/red] {e}")
sys.exit(1)
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
@@ -362,7 +349,6 @@ def cmd_auth_keys(ctx, keys_file):
table.add_row(name, entry.get("pubkey", ""))
console.print(table)
main.add_command(auth_group)
# ── Sub-command groups ─────────────────────────────────────────────────────────
@@ -381,9 +367,15 @@ main.add_command(perf_group)
main.add_command(extension_group)
main.add_command(cmd_serve)
# ── clients ────────────────────────────────────────────────────────────────────
def _append_clients(into, label, *, profile=None, remote=None, key=None):
"""Query clients.list for one target and append each, tagged with *label*."""
result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []):
c["profile"] = label
into.append(c)
@click.group("clients", invoke_without_command=True)
@click.pass_context
def clients_group(ctx):
@@ -409,10 +401,7 @@ def clients_group(ctx):
sys.exit(1)
for target in targets:
try:
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
except (BrowserNotConnected, RuntimeError):
continue
elif remote:
@@ -432,10 +421,7 @@ def clients_group(ctx):
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
result = send_command("clients.list", profile=profile_name)
for c in (result or []):
c["profile"] = display_profile
all_clients.append(c)
_append_clients(all_clients, display_profile, profile=profile_name)
except (BrowserNotConnected, RuntimeError):
# Socket registered but browser no longer connected
all_clients.append({
@@ -449,10 +435,7 @@ def clients_group(ctx):
if target.remote is None:
continue
try:
result = send_command("clients.list", profile=target.profile, remote=target.remote)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
_append_clients(all_clients, target.display_name, profile=target.profile, remote=target.remote)
except (BrowserNotConnected, RuntimeError):
continue
@@ -475,10 +458,8 @@ def clients_group(ctx):
)
console.print(table)
main.add_command(clients_group)
@clients_group.command("rename")
@click.option(
"--browser", "target_browser", default=None, metavar="ALIAS",
@@ -495,7 +476,6 @@ def cmd_clients_rename(target_browser, alias):
sys.exit(1)
console.print(f"[green]Profile renamed to '{alias}'[/green]")
# ── install ────────────────────────────────────────────────────────────────────
@main.command("install")
@@ -567,7 +547,6 @@ def cmd_install(browser):
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
@main.command("native-host", hidden=True)
@@ -576,7 +555,6 @@ def cmd_native_host():
from browser_cli.native_host import main as _main
_main()
# ── completion ─────────────────────────────────────────────────────────────────
@main.command("completion")
@@ -624,6 +602,5 @@ def cmd_completion(shell, script):
console.print()
console.print(f" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
if __name__ == "__main__":
main()
+17 -182
View File
@@ -10,10 +10,8 @@ Profile selection order:
"""
import json
import os
import re
import socket
import struct
import sys
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
@@ -21,57 +19,26 @@ from pathlib import Path
from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.registry import load_registry
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
try:
from importlib.metadata import version as _pkg_version
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
except Exception:
_USER_AGENT = "browser-cli/0"
# Re-exported for backward compatibility — these used to live here and are still
# referenced as ``browser_cli.client.<name>`` by callers, serve.py, and tests.
from browser_cli.errors import BrowserNotConnected # noqa: F401
from browser_cli.endpoints import ( # noqa: F401
_DEFAULT_REMOTE_PORT,
_looks_like_domain,
_normalize_endpoint,
_remote_display_name,
_resolve_connect_endpoint,
display_browser_name,
)
from browser_cli.remote_transport import _recv_all, _recv_exact, _send_remote # noqa: F401
REGISTRY_PATH = registry_path()
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
_DEFAULT_REMOTE_PORT = 443
def _looks_like_domain(host: str) -> bool:
"""True if host looks like a domain name rather than an IP address or localhost."""
if host in {"localhost", "127.0.0.1", "::1"}:
return False
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
return False
return '.' in host and any(c.isalpha() for c in host)
def _normalize_endpoint(endpoint: str) -> str:
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
if not endpoint:
return endpoint
host, sep, port = endpoint.rpartition(":")
if sep and port == "443" and _looks_like_domain(host):
return host
return endpoint
def _resolve_connect_endpoint(endpoint: str) -> str:
"""Return host:port for TCP connection; domain without port defaults to :443."""
_, sep, _ = endpoint.rpartition(":")
if not sep:
if _looks_like_domain(endpoint):
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
raise BrowserNotConnected(
f"Invalid remote endpoint '{endpoint}': expected host:port"
)
return endpoint
class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available."""
@dataclass(frozen=True)
class BrowserTarget:
profile: str
@@ -98,12 +65,6 @@ def _active_endpoints(reg: dict) -> dict:
return dict(reg)
return {k: v for k, v in reg.items() if _is_reachable_unix_endpoint(v)}
def display_browser_name(profile_name: str, sock_path: str) -> str:
if profile_name != "default":
return profile_name
return Path(sock_path).stem or profile_name
def _load_remotes() -> dict[str, dict[str, str]]:
if not REMOTE_REGISTRY_PATH.exists():
return {}
@@ -116,13 +77,10 @@ def _load_remotes() -> dict[str, dict[str, str]]:
# normalize keys so old entries stored as "domain:443" match current lookups
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def _is_valid_key_spec(s: str) -> bool:
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec:
@@ -138,7 +96,6 @@ def save_remote_key(endpoint: str, key_spec: str) -> None:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
@@ -152,16 +109,6 @@ def key_for_remote(endpoint: str | None) -> str | None:
return None
return key_str
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
host, sep, port = endpoint.rpartition(":")
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
display_endpoint = host
else:
display_endpoint = endpoint # normalized domain (no port) or non-default port
return f"{display_endpoint}:{display_name or profile_name}"
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
"""Return browser targets advertised by a single remote endpoint."""
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
@@ -179,7 +126,6 @@ def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
)
return targets
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint in _load_remotes():
@@ -189,7 +135,6 @@ def _remote_browser_targets(key=None) -> list[BrowserTarget]:
continue
return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
if not alias:
@@ -228,7 +173,6 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
)
return None
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
if REGISTRY_PATH.exists():
@@ -241,7 +185,6 @@ def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[Br
targets.extend(_remote_browser_targets(key=key))
return targets
def _is_active_local_profile(profile: str | None) -> bool:
"""Return True when profile names a reachable local browser endpoint."""
if not profile:
@@ -257,7 +200,6 @@ def _is_active_local_profile(profile: str | None) -> bool:
return False
return False
def _resolve_socket(profile: str | None = None) -> str:
"""Return the socket path for the given profile (or auto-detect)."""
target = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -292,7 +234,6 @@ def _resolve_socket(profile: str | None = None) -> str:
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
)
def _load_private_key(key_path: "Path | str | None" = None):
"""Load an Ed25519 signing key.
@@ -320,96 +261,6 @@ def _load_private_key(key_path: "Path | str | None" = None):
except Exception:
return None
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
port = int(port_str)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
# receive challenge
challenge_raw = _recv_all(sock)
if challenge_raw is None:
raise BrowserNotConnected(f"No challenge received from {endpoint}")
try:
challenge = json.loads(challenge_raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
except (json.JSONDecodeError, AttributeError):
nonce_hex = None
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if min_ver:
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
pq_shared_secret = None
if nonce_hex and private_key is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
if pq_shared_secret is not None:
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
msg = {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": public_key_hex(private_key),
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
response = _recv_all(sock)
if response is not None and pq_shared_secret is not None:
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
@@ -423,7 +274,6 @@ def _auto_route_remote(endpoint: str, key=None) -> str | None:
)
return None
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
"""Send a command to the browser and return the response data."""
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -443,7 +293,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
"args": args or {},
}
if remote_endpoint:
from browser_cli import transport
msg["user_agent"] = _USER_AGENT
msg["accept_encoding"] = transport.client_accept_encoding()
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
key_spec = key if key is not None else key_for_remote(remote_endpoint)
private_key = _load_private_key(key_spec)
@@ -493,25 +345,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
if response is None:
raise ConnectionError("Connection closed before full response received")
result = json.loads(response)
from browser_cli import transport
result = transport.decode_response(response)
if not result.get("success", True):
raise RuntimeError(result.get("error", "unknown error from browser"))
return result.get("data")
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
raise ConnectionError(f"Response too large ({msg_len} bytes)")
return _recv_exact(sock, msg_len)
def _recv_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Socket closed before full message received")
buf += chunk
return buf
+68 -29
View File
@@ -1,40 +1,79 @@
"""Shared helpers for the Click CLI command modules.
Every CLI command is a thin presentation layer over the Python SDK: it builds a
:class:`~browser_cli.BrowserCLI` from the global ``--browser/--remote/--key``
options, calls the matching SDK namespace method, and renders the result. The
SDK is the single source of truth for command strings, argument shapes, and
multi-browser routing.
"""
import functools
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
from rich.table import Table
from browser_cli import BrowserCLI, BrowserCounts
from browser_cli.client import BrowserNotConnected
_console = Console()
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
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)
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result.
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items():
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
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)
return wrapper
+12 -15
View File
@@ -1,23 +1,22 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
@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")
@handle_errors
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 []
cookies = client_from_ctx().cookies.list(url=url, domain=domain, name=name)
if not cookies:
console.print("[yellow]No cookies found[/yellow]")
return
@@ -39,19 +38,18 @@ def cookies_list(url, domain, name):
)
console.print(table)
@cookies_group.command("get")
@click.argument("url")
@click.argument("name")
@handle_errors
def cookies_get(url, name):
"""Get the value of a single cookie by URL and NAME."""
cookie = _handle("cookies.get", {"url": url, "name": name})
cookie = client_from_ctx().cookies.get(url, 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")
@@ -62,14 +60,13 @@ def cookies_get(url, name):
@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)
@handle_errors
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,
})
client_from_ctx().cookies.set(
url, name, value,
domain=domain, path=path,
secure=secure or None, http_only=http_only or None,
expiration_date=expiration_date, same_site=same_site,
)
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
+40 -57
View File
@@ -1,22 +1,21 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
from rich.table import Table
import json
console = Console()
@click.group("dom")
def dom_group():
"""Query and interact with page DOM elements."""
@dom_group.command("query")
@click.argument("selector")
@handle_errors
def dom_query(selector):
"""Return elements matching CSS SELECTOR (like mini DevTools)."""
elements = _handle("dom.query", {"selector": selector})
elements = client_from_ctx().dom.query(selector)
if not elements:
console.print("[yellow]No elements found[/yellow]")
return
@@ -29,180 +28,164 @@ def dom_query(selector):
table.add_row(el.get("tag", ""), (el.get("text") or "")[:60], attrs[:80])
console.print(table)
@dom_group.command("click")
@click.argument("selector")
@handle_errors
def dom_click(selector):
"""Click the first element matching CSS SELECTOR."""
_handle("dom.click", {"selector": selector})
client_from_ctx().dom.click(selector)
console.print(f"[green]Clicked:[/green] {selector}")
@dom_group.command("type")
@click.argument("selector")
@click.argument("text")
@handle_errors
def dom_type(selector, text):
"""Type TEXT into the element matching CSS SELECTOR."""
_handle("dom.type", {"selector": selector, "text": text})
client_from_ctx().dom.type(selector, text)
console.print(f"[green]Typed into:[/green] {selector}")
@dom_group.command("attr")
@click.argument("selector")
@click.argument("attr_name")
@handle_errors
def dom_attr(selector, attr_name):
"""Get attribute ATTR_NAME from elements matching CSS SELECTOR."""
values = _handle("dom.attr", {"selector": selector, "attr": attr_name})
for v in (values or []):
for v in client_from_ctx().dom.attr(selector, attr_name):
console.print(v)
@dom_group.command("text")
@click.argument("selector")
@handle_errors
def dom_text(selector):
"""Get text content of elements matching CSS SELECTOR."""
values = _handle("dom.text", {"selector": selector})
for v in (values or []):
for v in client_from_ctx().dom.text(selector):
console.print(v)
@dom_group.command("exists")
@click.argument("selector")
@handle_errors
def dom_exists(selector):
"""Check if an element matching CSS SELECTOR exists on the page."""
exists = _handle("dom.exists", {"selector": selector})
if exists:
if client_from_ctx().dom.exists(selector):
console.print(f"[green]exists[/green]: {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)")
@handle_errors
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})
client_from_ctx().dom.scroll(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")
@handle_errors
def dom_select(selector, value):
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
_handle("dom.select", {"selector": selector, "value": value})
client_from_ctx().dom.select(selector, 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)")
@tab_option
@handle_errors
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})
result = client_from_ctx().dom.eval(code, 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)")
@tab_option
@handle_errors
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,
})
client_from_ctx().dom.wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=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)")
@handle_errors
def dom_key(key, selector):
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
_handle("dom.key", {"key": key, "selector": selector})
client_from_ctx().dom.key(key, selector)
target = selector or "active element"
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
@dom_group.command("hover")
@click.argument("selector")
@handle_errors
def dom_hover(selector):
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
_handle("dom.hover", {"selector": selector})
client_from_ctx().dom.hover(selector)
console.print(f"[green]Hovered:[/green] {selector}")
@dom_group.command("check")
@click.argument("selector")
@handle_errors
def dom_check(selector):
"""Check a checkbox matching CSS SELECTOR."""
_handle("dom.check", {"selector": selector})
client_from_ctx().dom.check(selector)
console.print(f"[green]Checked:[/green] {selector}")
@dom_group.command("uncheck")
@click.argument("selector")
@handle_errors
def dom_uncheck(selector):
"""Uncheck a checkbox matching CSS SELECTOR."""
_handle("dom.uncheck", {"selector": selector})
client_from_ctx().dom.uncheck(selector)
console.print(f"[green]Unchecked:[/green] {selector}")
@dom_group.command("clear")
@click.argument("selector")
@handle_errors
def dom_clear(selector):
"""Clear the value of an input matching CSS SELECTOR."""
_handle("dom.clear", {"selector": selector})
client_from_ctx().dom.clear(selector)
console.print(f"[green]Cleared:[/green] {selector}")
@dom_group.command("focus")
@click.argument("selector")
@handle_errors
def dom_focus(selector):
"""Focus the element matching CSS SELECTOR."""
_handle("dom.focus", {"selector": selector})
client_from_ctx().dom.focus(selector)
console.print(f"[green]Focused:[/green] {selector}")
@dom_group.command("submit")
@click.argument("selector")
@handle_errors
def dom_submit(selector):
"""Submit the form that contains the element matching CSS SELECTOR."""
_handle("dom.submit", {"selector": selector})
client_from_ctx().dom.submit(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)")
@tab_option
@handle_errors
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,
})
result = client_from_ctx().dom.poll(selector, pattern, attr=attr, timeout=timeout, interval=interval, tab_id=tab_id)
value = result.get("value", "") if isinstance(result, dict) else ""
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
+3 -3
View File
@@ -1,7 +1,6 @@
import time
import click
from rich.console import Console
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
@@ -10,6 +9,7 @@ def extension_group():
"""Manage the browser-cli browser extension."""
@extension_group.command("reload")
@handle_errors
def extension_reload():
"""Reload the browser-cli extension service worker.
@@ -17,5 +17,5 @@ def extension_reload():
The command returns immediately; the extension restarts ~200 ms later.
Re-connects automatically via the keepalive alarm within ~25 seconds.
"""
_handle("extension.reload")
client_from_ctx().extension.reload()
console.print("[green]Extension reloading…[/green] reconnects automatically")
+16 -435
View File
@@ -1,437 +1,24 @@
import json
import re
from html.parser import HTMLParser
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
# Re-exported for backward compatibility: the HTML→Markdown engine now lives in
# browser_cli.markdown and is applied by the SDK (ExtractNS.markdown).
from browser_cli.markdown import _clean_markdown_output, _convert_html_to_markdown # noqa: F401
from rich.console import Console
from rich.table import Table
console = Console()
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return _clean_markdown_output(markdown)
@click.group("extract")
def extract_group():
"""Extract content from the active tab."""
@extract_group.command("links")
@handle_errors
def extract_links():
"""Extract all links from the active tab."""
links = _handle("extract.links")
links = client_from_ctx().extract.links()
if not links:
console.print("[yellow]No links found[/yellow]")
return
@@ -442,11 +29,11 @@ def extract_links():
table.add_row((lnk.get("text") or "")[:60], lnk.get("href") or "")
console.print(table)
@extract_group.command("images")
@handle_errors
def extract_images():
"""Extract all images from the active tab."""
images = _handle("extract.images")
images = client_from_ctx().extract.images()
if not images:
console.print("[yellow]No images found[/yellow]")
return
@@ -457,36 +44,30 @@ def extract_images():
table.add_row((img.get("alt") or "")[:40], img.get("src") or "")
console.print(table)
@extract_group.command("text")
@handle_errors
def extract_text():
"""Extract all visible text from the active tab."""
text = _handle("extract.text")
console.print(text or "")
console.print(client_from_ctx().extract.text())
@extract_group.command("json")
@click.argument("selector")
@handle_errors
def extract_json(selector):
"""Parse and pretty-print JSON content inside SELECTOR."""
data = _handle("extract.json", {"selector": selector})
data = client_from_ctx().extract.json(selector)
console.print_json(json.dumps(data))
@extract_group.command("html")
@handle_errors
def extract_html():
"""Print the full HTML of the active tab to stdout."""
html = _handle("extract.html")
click.echo(html or "")
click.echo(client_from_ctx().extract.html())
@extract_group.command("markdown")
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
@handle_errors
def extract_markdown(selector):
"""Extract the page's main content as Markdown."""
markdown = _handle("extract.markdown", {"selector": selector})
if (markdown or "").lstrip().startswith("<"):
markdown = _convert_html_to_markdown(markdown)
else:
markdown = _clean_markdown_output(markdown or "")
markdown = client_from_ctx().extract.markdown(selector)
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
+27 -68
View File
@@ -1,12 +1,11 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from rich.console import Console
from rich.table import Table
console = Console()
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
return
@@ -20,128 +19,88 @@ def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
table.add_column("Tabs", width=6)
for g in groups:
row = [
g.get("browser", "") if show_browser else None,
str(g.get("id", "")),
g.get("title") or "",
g.get("color") or "",
"yes" if g.get("collapsed") else "no",
str(g.get("tabCount", "")),
(g.browser or "") if show_browser else None,
str(g.id),
g.title or "",
g.color or "",
"yes" if g.collapsed else "no",
str(g.tab_count),
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("groups")
def group_group():
"""Manage tab groups."""
@group_group.command("list")
@handle_errors
def group_list():
"""List all tab groups."""
targets = _multi_browser_targets()
if targets:
groups = []
for target in targets:
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
if result is None:
continue
groups.extend({**group, "browser": target.display_name} for group in result)
if not groups:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_groups(groups, show_browser=True)
return
groups = _handle("group.list")
_print_groups(groups or [])
groups = client_from_ctx().groups.list()
_print_groups(groups, show_browser=any(g.browser for g in groups))
@group_group.command("tabs")
@click.argument("group_id", type=int)
@handle_errors
def group_tabs(group_id):
"""List tabs inside a group."""
from browser_cli.commands.tabs import _print_tabs
tabs = _handle("group.tabs", {"groupId": group_id})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().groups.tabs(group_id))
@group_group.command("count")
@handle_errors
def group_count():
"""Count all tab groups."""
targets = _multi_browser_targets()
if targets:
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column("Groups", justify="right")
total = 0
rows = 0
for target in targets:
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
total += count
rows += 1
table.add_row(target.display_name, str(count))
if rows == 0:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
table.add_row("Total", str(total))
console.print(table)
return
count = _handle("group.count")
console.print(f"[bold]{count}[/bold] group(s)")
print_counts(client_from_ctx().groups.count(), "group")
@group_group.command("query")
@click.argument("search")
@handle_errors
def group_query(search):
"""Search groups by name."""
groups = _handle("group.query", {"search": search})
_print_groups(groups or [])
_print_groups(client_from_ctx().groups.query(search))
@group_group.command("close")
@click.argument("group_id", type=int)
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.")
@gentle_mode_option("Throttle mode for large group operations.")
@handle_errors
def group_close(group_id, gentle_mode):
"""Close (ungroup and optionally close) a tab group."""
_handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
client_from_ctx().groups.close(group_id, gentle_mode=gentle_mode)
console.print(f"[green]Group {group_id} closed[/green]")
@group_group.command("create")
@click.argument("name")
@handle_errors
def group_create(name):
"""Create a new tab group with NAME."""
result = _handle("group.open", {"name": name})
gid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Created group '{name}'[/green] (id: {gid})")
group = client_from_ctx().groups.create(name)
console.print(f"[green]Created group '{name}'[/green] (id: {group.id})")
@group_group.command("add-tab")
@click.argument("group")
@click.argument("url", required=False)
@handle_errors
def group_add_tab(group, url):
"""Open a new tab (optionally at URL) inside GROUP (name or ID)."""
result = _handle("group.add_tab", {"group": group, "url": url})
tab_id = result.get("tabId") if isinstance(result, dict) else result
tab_id = client_from_ctx().groups.add_tab(group, url)
label = url or "new tab"
console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})")
@group_group.command("move")
@click.argument("group")
@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")
@handle_errors
def group_move(group, forward, backward):
"""Move a tab group forward/backward or right/left (name or ID)."""
if not forward and not backward:
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
raise SystemExit(1)
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
result = client_from_ctx().groups.move(group, forward=forward, backward=backward)
if isinstance(result, dict) and not result.get("moved"):
console.print(f"[yellow]Group '{group}' is already at the {'end' if forward else 'start'}[/yellow]")
else:
+20 -25
View File
@@ -1,23 +1,22 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
console = Console()
@click.group("nav")
def nav_group():
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
@nav_group.command("open")
@click.argument("url")
@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 directly into a tab group (name or ID)")
@handle_errors
def cmd_open(url, bg, window_name, group_name):
"""Open URL in a new tab."""
_handle("navigate.open", {"url": url, "background": bg, "window": window_name, "group": group_name})
client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name)
suffix = ""
if group_name:
suffix = f" in group '{group_name}'"
@@ -25,71 +24,67 @@ def cmd_open(url, bg, window_name, group_name):
suffix = f" in window '{window_name}'"
console.print(f"[green]Opened:[/green] {url}{suffix}")
@nav_group.command("reload")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_reload(tab_id):
"""Reload the active (or specified) tab."""
_handle("navigate.reload", {"tabId": tab_id})
client_from_ctx().nav.reload(tab_id)
console.print("[green]Reloaded[/green]")
@nav_group.command("hard-reload")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_hard_reload(tab_id):
"""Hard reload (bypass cache) the active (or specified) tab."""
_handle("navigate.hard_reload", {"tabId": tab_id})
client_from_ctx().nav.hard_reload(tab_id)
console.print("[green]Hard reloaded[/green]")
@nav_group.command("back")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_back(tab_id):
"""Navigate back in the active (or specified) tab."""
_handle("navigate.back", {"tabId": tab_id})
client_from_ctx().nav.back(tab_id)
console.print("[green]Navigated back[/green]")
@nav_group.command("forward")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_forward(tab_id):
"""Navigate forward in the active (or specified) tab."""
_handle("navigate.forward", {"tabId": tab_id})
client_from_ctx().nav.forward(tab_id)
console.print("[green]Navigated forward[/green]")
@nav_group.command("focus")
@click.argument("pattern")
@handle_errors
def cmd_focus(pattern):
"""Jump to a tab by URL pattern or tab ID."""
result = _handle("navigate.focus", {"pattern": pattern})
result = client_from_ctx().nav.focus(pattern)
if result:
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")
@handle_errors
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 ""))
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name)
console.print(f"[green]Loaded:[/green] {url}" + (f"{tab.title}" if tab.title else ""))
@nav_group.command("wait")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@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")
@handle_errors
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', '')}")
tab = client_from_ctx().tabs.wait_for_load(tab_id, timeout=timeout, ready_state=ready_state)
console.print(f"[green]Ready:[/green] {tab.url}{tab.title}")
+3 -4
View File
@@ -1,20 +1,19 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
@click.group("page")
def page_group():
"""Inspect current page metadata."""
@page_group.command("info")
@handle_errors
def page_info():
"""Show title, URL, readyState, language, and meta tags of the active tab."""
info = _handle("page.info") or {}
info = client_from_ctx().page.info()
table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value")
+5 -3
View File
@@ -1,7 +1,7 @@
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
@@ -10,9 +10,10 @@ def perf_group():
"""Inspect and tune browser-cli performance behavior."""
@perf_group.command("status")
@handle_errors
def perf_status():
"""Show performance profile, throttle and running jobs."""
result = _handle("perf.status") or {}
result = client_from_ctx().perf.status()
console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
throttle = result.get("throttle") or {}
@@ -39,7 +40,8 @@ def perf_status():
@perf_group.command("profile")
@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
@handle_errors
def perf_profile(profile):
"""Set global performance profile."""
result = _handle("perf.set_profile", {"profile": profile}) or {}
result = client_from_ctx().perf.set_profile(profile)
console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
+3 -6
View File
@@ -1,6 +1,5 @@
import click
from urllib.parse import quote_plus
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
console = Console()
@@ -61,23 +60,21 @@ _SUBCOMMANDS = [
def search_group():
"""Search the web — open a query in a search engine."""
def _build_command(engine_key: str, help_text: str) -> click.Command:
@click.command(engine_key, help=help_text)
@click.argument("query", nargs=-1, required=True)
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window", default=None, help="Open in named window")
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
@handle_errors
def _cmd(query, bg, window, group):
terms = " ".join(query)
url = ENGINES[engine_key].format(query=quote_plus(terms))
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group)
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
return _cmd
for _name, _help in _SUBCOMMANDS:
search_group.add_command(_build_command(_name, _help))
+35 -17
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from rich.console import Console
from browser_cli import transport
from browser_cli.client import _recv_exact, _recv_all
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
@@ -12,7 +13,6 @@ _UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
console = Console()
def _framed_send(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
@@ -25,11 +25,12 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
def _send_payload(data: bytes) -> None:
if response_secret is not None:
@@ -38,16 +39,17 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
# errors stay plain JSON: tiny, and safe for any client
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload) -> None:
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
def _send_ok(msg_id, payload, command=None) -> None:
obj = {"id": msg_id, "success": True, "data": payload}
try:
_send_payload(out)
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
except OSError:
pass
@@ -141,13 +143,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
return
response_secret = pq_shared_secret if transport_encrypted else None
# client advertises what response encodings it can decode (signed, then stripped)
accept_encoding = msg.get("accept_encoding")
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
_send_ok(msg_id, targets)
_send_ok(msg_id, targets, command)
_log(addr, command, None, "OK")
return
@@ -158,7 +163,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
_send_ok(msg_id, entries)
_send_ok(msg_id, entries, command)
_log(addr, command, None, "OK")
return
@@ -176,14 +181,14 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
_send_ok(msg_id, {"added": added})
_send_ok(msg_id, {"added": added}, command)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
@@ -203,16 +208,19 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
pipe.send_bytes(clean_payload)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
_send_payload(resp_payload)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
_send_payload(resp_payload)
# parse once: drives both the access log and (re-)encoding for the client
resp_data = json.loads(resp_payload)
if compress:
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
else:
_send_payload(resp_payload)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
@@ -221,7 +229,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None", compress:bool=True) -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
@@ -253,7 +261,7 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_framed_send(client_sock, challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
finally:
_CONN_LIMIT.release()
@@ -264,10 +272,13 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
help="Disable response compression / msgpack even for clients that support it.")
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
if host in ("0.0.0.0", "::"):
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
@@ -302,18 +313,25 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
if compress:
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
else:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
console.print("Ctrl-C to stop.\n")
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
+33 -34
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
from rich.console import Console
console = Console()
@@ -10,41 +10,47 @@ def session_group():
@session_group.command("save")
@click.argument("name")
@handle_errors
def session_save(name):
"""Save all current tabs as session NAME."""
result = _handle("session.save", {"name": name})
result = client_from_ctx().session.save(name)
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
@session_group.command("load")
@click.argument("name")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.")
@gentle_mode_option("Throttle mode for large restores.")
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
@handle_errors
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
"""Restore session NAME (opens all saved tabs)."""
result = _handle("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": background_job,
})
if background_job and isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
b = client_from_ctx()
if background_job:
result = b.session.load_background(
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
lazy=lazy, eager_tabs=eager_tabs,
)
if isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
else:
result = b.session.load(
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
lazy=lazy, eager_tabs=eager_tabs,
)
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
@session_group.command("diff")
@click.argument("name_a")
@click.argument("name_b")
@handle_errors
def session_diff(name_a, name_b):
"""Show tabs added/removed between two saved sessions."""
diff = _handle("session.diff", {"nameA": name_a, "nameB": name_b})
diff = client_from_ctx().session.diff(name_a, name_b)
if not diff:
console.print("[yellow]No diff data returned[/yellow]")
return
@@ -66,26 +72,16 @@ def session_diff(name_a, name_b):
console.print("[green]Sessions are identical[/green]")
@session_group.command("list")
@handle_errors
def session_list():
"""List all saved sessions."""
from datetime import datetime
from rich.table import Table
targets = _multi_browser_targets()
show_browser = bool(targets)
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
if not sessions:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
else:
sessions = _handle("session.list")
sessions = client_from_ctx().session.list()
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
show_browser = any("browser" in s for s in sessions)
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
@@ -93,7 +89,6 @@ def session_list():
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
from datetime import datetime
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
@@ -102,16 +97,18 @@ def session_list():
@session_group.command("remove")
@click.argument("name")
@handle_errors
def session_remove(name):
"""Delete a saved session."""
_handle("session.remove", {"name": name})
client_from_ctx().session.remove(name)
console.print(f"[green]Session '{name}' removed[/green]")
@session_group.command("job-status")
@click.argument("job_id")
@handle_errors
def session_job_status(job_id):
"""Show status for a background session job."""
result = _handle("jobs.status", {"jobId": job_id}) or {}
result = client_from_ctx().perf.job_status(job_id)
status = result.get("status", "unknown")
console.print(f"[bold]{job_id}[/bold]: {status}")
if result.get("error"):
@@ -121,15 +118,17 @@ def session_job_status(job_id):
@session_group.command("job-cancel")
@click.argument("job_id")
@handle_errors
def session_job_cancel(job_id):
"""Cancel a running background job."""
_handle("jobs.cancel", {"jobId": job_id})
client_from_ctx().perf.job_cancel(job_id)
console.print(f"[green]Cancel requested for {job_id}[/green]")
@session_group.command("auto-save")
@click.argument("state", type=click.Choice(["on", "off"]))
@handle_errors
def session_auto_save(state):
"""Enable or disable automatic session saving."""
enabled = state == "on"
_handle("session.auto_save", {"enabled": enabled})
client_from_ctx().session.auto_save(enabled)
console.print(f"[green]Auto-save {state}[/green]")
+7 -8
View File
@@ -1,23 +1,22 @@
import json
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
console = Console()
@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)")
@tab_option
@handle_errors
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})
result = client_from_ctx().storage.get(key, type=store_type, tab_id=tab_id)
if result is None:
console.print("[dim]null[/dim]")
elif isinstance(result, dict):
@@ -25,13 +24,13 @@ def storage_get(key, store_type, tab_id):
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)")
@tab_option
@handle_errors
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})
client_from_ctx().storage.set(key, value, type=store_type, tab_id=tab_id)
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
+62 -111
View File
@@ -1,14 +1,13 @@
import base64
import binascii
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from rich.console import Console
from rich.table import Table
console = Console()
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs:
console.print("[yellow]No tabs found[/yellow]")
return
@@ -22,58 +21,42 @@ def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
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 ""
active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [
t.get("browser", "") if show_browser else None,
str(t.get("id", "")),
str(t.get("windowId", "")),
(t.browser or "") if show_browser else None,
str(t.id),
str(t.window_id),
active,
muted,
(t.get("title") or "")[:60],
(t.get("url") or "")[:80],
(t.title or "")[:60],
(t.url or "")[:80],
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("tabs")
def tabs_group():
"""Manage browser tabs."""
@tabs_group.command("list")
@handle_errors
def tabs_list():
"""List all open tabs across all windows."""
targets = _multi_browser_targets()
if targets:
tabs = []
for target in targets:
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
if result is None:
continue
tabs.extend({**tab, "browser": target.display_name} for tab in result)
if not tabs:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_tabs(tabs, show_browser=True)
return
tabs = _handle("tabs.list")
_print_tabs(tabs or [])
tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("close")
@click.argument("tab_id", type=int, required=False)
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.")
@gentle_mode_option("Throttle mode for large close operations.")
@handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs."""
result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 1
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
@@ -83,180 +66,148 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
@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")
@handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""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,
})
client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index,
)
console.print("[green]Tab moved[/green]")
@tabs_group.command("active")
@click.argument("tab_id", type=int)
@handle_errors
def tabs_active(tab_id):
"""Switch browser focus to a tab."""
_handle("tabs.active", {"tabId": tab_id})
client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = _handle("tabs.status", {"tabId": tab_id}) or {}
tab = client_from_ctx().tabs.status(tab_id)
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 "")
table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
@handle_errors
def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN."""
tabs = _handle("tabs.filter", {"pattern": pattern})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count")
@click.argument("pattern", required=False)
@handle_errors
def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN."""
targets = _multi_browser_targets()
if targets:
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column("Tabs", justify="right")
total = 0
rows = 0
for target in targets:
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
total += count
rows += 1
table.add_row(target.display_name, str(count))
if rows == 0:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
table.add_row("Total", str(total))
console.print(table)
return
count = _handle("tabs.count", {"pattern": pattern})
label = f" matching '{pattern}'" if pattern else ""
console.print(f"[bold]{count}[/bold] tab(s){label}")
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query")
@click.argument("search")
@handle_errors
def tabs_query(search):
"""Search tabs by URL or title."""
tabs = _handle("tabs.query", {"search": search})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_html(tab_id):
"""Print the full HTML of a tab."""
html = _handle("tabs.html", {"tabId": tab_id})
console.print(html or "")
console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.")
@gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL)."""
result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 0
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.")
@gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window."""
_handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.")
@gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window."""
result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
count = result.get("moved", 0) if isinstance(result, dict) else 0
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
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)
@handle_errors
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
target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
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
target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
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
target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
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
target = client_from_ctx().tabs.unpin(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)")
@tab_option
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors
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}")
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.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)")
@tab_option
@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")
@handle_errors
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 ""
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output:
header = f"data:image/{fmt};base64,"
if not data_url.startswith(header):
+10 -25
View File
@@ -1,11 +1,10 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows:
console.print("[yellow]No windows found[/yellow]")
@@ -28,53 +27,39 @@ def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("windows")
def windows_group():
"""Manage browser windows."""
@windows_group.command("list")
@handle_errors
def windows_list():
"""List all browser windows."""
targets = _multi_browser_targets()
if targets:
windows = []
for target in targets:
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
if result is None:
continue
windows.extend({**window, "browser": target.display_name} for window in result)
if not windows:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_windows(windows, show_browser=True)
return
windows = _handle("windows.list")
_print_windows(windows or [])
windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows))
@windows_group.command("rename")
@click.argument("window_id", type=int)
@click.argument("name")
@handle_errors
def windows_rename(window_id, name):
"""Give a window a local alias NAME (stored in native host)."""
_handle("windows.rename", {"windowId": window_id, "name": name})
client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
@windows_group.command("close")
@click.argument("window_id", type=int)
@handle_errors
def windows_close(window_id):
"""Close a browser window."""
_handle("windows.close", {"windowId": window_id})
client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]")
@windows_group.command("open")
@click.argument("url", required=False)
@handle_errors
def windows_open(url):
"""Open a new browser window."""
result = _handle("windows.open", {"url": url})
result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
+57
View File
@@ -0,0 +1,57 @@
"""Remote endpoint string handling — parsing, normalization, display.
Pure helpers (no sockets, no I/O) for turning user-facing ``host[:port]``
strings into the canonical forms the rest of the client uses, and back into the
short forms shown to humans. Re-exported from :mod:`browser_cli.client` for
backward compatibility.
"""
from __future__ import annotations
import re
from browser_cli.errors import BrowserNotConnected
_DEFAULT_REMOTE_PORT = 443
def _looks_like_domain(host: str) -> bool:
"""True if host looks like a domain name rather than an IP address or localhost."""
if host in {"localhost", "127.0.0.1", "::1"}:
return False
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
return False
return '.' in host and any(c.isalpha() for c in host)
def _normalize_endpoint(endpoint: str) -> str:
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
if not endpoint:
return endpoint
host, sep, port = endpoint.rpartition(":")
if sep and port == "443" and _looks_like_domain(host):
return host
return endpoint
def _resolve_connect_endpoint(endpoint: str) -> str:
"""Return host:port for TCP connection; domain without port defaults to :443."""
_, sep, _ = endpoint.rpartition(":")
if not sep:
if _looks_like_domain(endpoint):
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
raise BrowserNotConnected(
f"Invalid remote endpoint '{endpoint}': expected host:port"
)
return endpoint
def display_browser_name(profile_name: str, sock_path: str) -> str:
from pathlib import Path
if profile_name != "default":
return profile_name
return Path(sock_path).stem or profile_name
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
host, sep, port = endpoint.rpartition(":")
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
display_endpoint = host
else:
display_endpoint = endpoint # normalized domain (no port) or non-default port
return f"{display_endpoint}:{display_name or profile_name}"
+10
View File
@@ -0,0 +1,10 @@
"""Shared exception types for the browser-cli client stack.
Kept dependency-free so the transport/endpoint modules and ``client`` itself can
import it without creating an import cycle. ``BrowserNotConnected`` is re-exported
from :mod:`browser_cli.client` for backward compatibility.
"""
from __future__ import annotations
class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available."""
+413
View File
@@ -0,0 +1,413 @@
"""HTML → Markdown conversion and Markdown clean-up.
Pure, presentation-agnostic text transforms shared by the SDK
(:meth:`browser_cli.sdk.dom.ExtractNS.markdown`) and the ``extract markdown``
CLI command. No Click/Rich/IPC dependencies just an HTML tree walker plus a
set of repair passes for the markdown the page (or a markdown editor like
Obsidian/CodeMirror) hands back.
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return _clean_markdown_output(markdown)
def render_markdown(raw: str | None) -> str:
"""Normalize *raw* extractor output into clean Markdown.
If the payload looks like HTML (first non-space char is ``<``) it is run
through the HTMLMarkdown converter; otherwise it is treated as Markdown and
only the clean-up/repair passes are applied.
"""
raw = raw or ""
if raw.lstrip().startswith("<"):
return _convert_html_to_markdown(raw)
return _clean_markdown_output(raw)
+17 -9
View File
@@ -4,11 +4,11 @@ Typed dataclasses returned by the BrowserCLI Python API.
Each object is bound to a BrowserCLI instance so you can call actions
directly on it:
tabs = b.tabs_list()
tabs = b.tabs.list()
tabs[0].close()
tabs[0].move(forward=True)
groups = b.group_list()
groups = b.groups.list()
groups[0].tabs()
groups[0].add_tab("https://example.com")
"""
@@ -21,6 +21,14 @@ if TYPE_CHECKING:
from browser_cli import BrowserCLI
# ── BrowserCounts ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
# ── Tab ───────────────────────────────────────────────────────────────────────
@dataclass
@@ -97,7 +105,7 @@ class Tab:
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
return self._b().tabs_screenshot(self.id, format=format, quality=quality)
return self._b().tabs.screenshot(self.id, format=format, quality=quality)
def pin(self) -> None:
"""Pin this tab."""
@@ -109,19 +117,19 @@ class Tab:
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
return self._b().tabs_status(self.id)
return self._b().tabs.status(self.id)
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
"""Wait until this tab reaches the requested readyState."""
return self._b().wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
return self._b().tabs.wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
"""Wait until this tab's URL matches regex *pattern*."""
return self._b().tabs_watch_url(pattern, tab_id=self.id, timeout=timeout)
return self._b().tabs.watch_url(pattern, tab_id=self.id, timeout=timeout)
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url* in place."""
self._b().navigate_tab(self.id, url)
self._b().nav.to(self.id, url)
# ── Group ─────────────────────────────────────────────────────────────────────
@@ -148,7 +156,7 @@ class Group:
def tabs(self) -> list[Tab]:
"""Return all tabs inside this group."""
return self._b().group_tabs(self.id)
return self._b().groups.tabs(self.id)
def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups."""
@@ -160,4 +168,4 @@ class Group:
def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID."""
return self._b().group_add_tab(self.id, url)
return self._b().groups.add_tab(self.id, url)
+123
View File
@@ -0,0 +1,123 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
complete the signed challenge/response handshake with an optional post-quantum
key exchange, frame the request, and read the framed (possibly encrypted)
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client`, which re-exports these for
backward compatibility.
"""
from __future__ import annotations
import json
import socket
import struct
import sys
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
_PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
def _recv_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Socket closed before full message received")
buf += chunk
return buf
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
raise ConnectionError(f"Response too large ({msg_len} bytes)")
return _recv_exact(sock, msg_len)
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
port = int(port_str)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
# receive challenge
challenge_raw = _recv_all(sock)
if challenge_raw is None:
raise BrowserNotConnected(f"No challenge received from {endpoint}")
try:
challenge = json.loads(challenge_raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
except (json.JSONDecodeError, AttributeError):
nonce_hex = None
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if min_ver:
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
pq_shared_secret = None
if nonce_hex and private_key is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
sys.stderr.write(_PQ_WARNING)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
if pq_shared_secret is not None:
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
msg = {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": public_key_hex(private_key),
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
else:
sys.stderr.write(_PQ_WARNING)
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
response = _recv_all(sock)
if response is not None and pq_shared_secret is not None:
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
+30
View File
@@ -0,0 +1,30 @@
"""SDK command namespaces for :class:`browser_cli.BrowserCLI`.
Each namespace groups related browser commands under a short accessor on the
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
in the browser extension.
"""
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
from browser_cli.sdk.extension import ExtensionNS
from browser_cli.sdk.groups import GroupsNS
from browser_cli.sdk.navigation import NavigationNS
from browser_cli.sdk.perf import PerfNS
from browser_cli.sdk.session import SessionNS
from browser_cli.sdk.tabs import TabsNS
from browser_cli.sdk.windows import WindowsNS
__all__ = [
"NavigationNS",
"TabsNS",
"GroupsNS",
"WindowsNS",
"DomNS",
"ExtractNS",
"PageNS",
"StorageNS",
"CookiesNS",
"SessionNS",
"PerfNS",
"ExtensionNS",
]
+19
View File
@@ -0,0 +1,19 @@
"""Base class for SDK command namespaces.
Each namespace (``b.tabs``, ``b.dom``, ...) is a thin object bound to its
:class:`~browser_cli.BrowserCLI` client. Namespaces hold no state of their own;
they delegate to the client's shared infrastructure (``_cmd``, the multi-browser
helpers, and the ``Tab``/``Group`` factories).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from browser_cli import BrowserCLI
class Namespace:
"""A group of related SDK methods, bound to a BrowserCLI client."""
def __init__(self, client: "BrowserCLI"):
self._c = client
+66
View File
@@ -0,0 +1,66 @@
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class StorageNS(Namespace):
"""Read and write localStorage / sessionStorage."""
def 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._c._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
def set(
self,
key: str,
value: str,
*,
type: str = "local",
tab_id: int | None = None,
) -> None:
"""Set a localStorage/sessionStorage entry."""
self._c._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
class CookiesNS(Namespace):
"""List, get, and set cookies."""
def 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._c._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
def get(self, url: str, name: str) -> dict | None:
"""Get a single cookie by url and name."""
return self._c._cmd("cookies.get", {"url": url, "name": name})
def 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._c._cmd("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure, "httpOnly": http_only,
"expirationDate": expiration_date, "sameSite": same_site,
})
+150
View File
@@ -0,0 +1,150 @@
"""DOM, content-extraction, and page-info namespaces: ``b.dom.*``, ``b.extract.*``, ``b.page.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class DomNS(Namespace):
"""Query and drive page elements in the active (or specified) tab."""
def query(self, selector: str) -> list[dict]:
return self._c._cmd("dom.query", {"selector": selector}) or []
def click(self, selector: str) -> None:
self._c._cmd("dom.click", {"selector": selector})
def type(self, selector: str, text: str) -> None:
self._c._cmd("dom.type", {"selector": selector, "text": text})
def attr(self, selector: str, attr: str) -> list[str]:
return self._c._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
def text(self, selector: str) -> list[str]:
return self._c._cmd("dom.text", {"selector": selector}) or []
def exists(self, selector: str) -> bool:
return self._c._cmd("dom.exists", {"selector": selector}) or False
def 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._c._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
def select(self, selector: str, value: str) -> None:
"""Set the value of a <select> element."""
self._c._cmd("dom.select", {"selector": selector, "value": value})
def eval(self, code: str, tab_id: int | None = None):
"""Evaluate JavaScript in the page's main world and return the result."""
return self._c._cmd("dom.eval", {"code": code, "tabId": tab_id})
def key(self, key: str, selector: str | None = None) -> None:
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
self._c._cmd("dom.key", {"key": key, "selector": selector})
def hover(self, selector: str) -> None:
"""Dispatch mouseover/mouseenter on an element."""
self._c._cmd("dom.hover", {"selector": selector})
def check(self, selector: str) -> None:
"""Check a checkbox."""
self._c._cmd("dom.check", {"selector": selector})
def uncheck(self, selector: str) -> None:
"""Uncheck a checkbox."""
self._c._cmd("dom.uncheck", {"selector": selector})
def clear(self, selector: str) -> None:
"""Clear the value of an input element."""
self._c._cmd("dom.clear", {"selector": selector})
def focus(self, selector: str) -> None:
"""Focus an element."""
self._c._cmd("dom.focus", {"selector": selector})
def submit(self, selector: str) -> None:
"""Submit the form containing the matched element."""
self._c._cmd("dom.submit", {"selector": selector})
def 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._c._cmd("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
def 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._c._cmd("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
class ExtractNS(Namespace):
"""Extract structured content from the active tab."""
def links(self) -> list[dict]:
return self._c._cmd("extract.links", {}) or []
def images(self) -> list[dict]:
return self._c._cmd("extract.images", {}) or []
def text(self) -> str:
return self._c._cmd("extract.text", {}) or ""
def json(self, selector: str):
return self._c._cmd("extract.json", {"selector": selector})
def html(self) -> str:
"""Return the full HTML source of the active tab."""
return self._c._cmd("extract.html", {}) or ""
def markdown(self, selector: str | None = None) -> str:
"""Extract the page's main content as clean Markdown.
The extractor may return either Markdown or raw HTML; both are
normalized to Markdown here so SDK and CLI callers get identical output.
"""
from browser_cli.markdown import render_markdown
return render_markdown(self._c._cmd("extract.markdown", {"selector": selector}))
class PageNS(Namespace):
"""Inspect the active page."""
def info(self) -> dict:
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
return self._c._cmd("page.info", {}) or {}
+16
View File
@@ -0,0 +1,16 @@
"""Extension-control namespace: ``b.extension.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class ExtensionNS(Namespace):
"""Control the browser-cli extension itself."""
def reload(self) -> None:
"""Reload the browser-cli extension service worker.
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
self._c._cmd("extension.reload", {})
+94
View File
@@ -0,0 +1,94 @@
"""Object-factory mixin for :class:`~browser_cli.BrowserCLI`.
Builds the typed :class:`~browser_cli.models.Tab` / :class:`~browser_cli.models.Group`
dataclasses from raw command responses and binds each one to the client that
should run its actions. In multi-browser mode an object is bound to a sibling
client targeting the browser it came from, so ``tab.close()`` routes correctly.
"""
from __future__ import annotations
from browser_cli.models import Group, Tab
class FactoryMixin:
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
def _make_tab(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Tab:
tab = Tab(
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,
browser=browser_name,
)
tab._browser = self if browser_profile is None else type(self)(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return tab
def _require_tab(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self._make_tab(data)
def _make_group(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0),
browser=browser_name,
)
group._browser = self if browser_profile is None else type(self)(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return group
def _make_tab_for(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self._make_tab(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
)
def _make_group_for(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self._make_group(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
)
@staticmethod
def _tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name}
+56
View File
@@ -0,0 +1,56 @@
"""Tab groups namespace: ``b.groups.*``."""
from __future__ import annotations
from typing import TYPE_CHECKING
from browser_cli.models import Group, Tab
from browser_cli.sdk.base import Namespace
if TYPE_CHECKING:
from browser_cli import BrowserCounts
class GroupsNS(Namespace):
"""List, create, query, and modify tab groups."""
def list(self) -> list[Group]:
"""Return all tab groups.
When multiple browsers are active and no browser was specified, each Group
includes ``group.browser`` naming its source browser.
"""
return self._c._multi_list("group.list", {}, self._c._make_group_for)
def count(self) -> "int | BrowserCounts":
"""Return the number of tab groups.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self._c._multi_count("group.count", {})
def query(self, search: str) -> list[Group]:
"""Search groups by name."""
return [self._c._make_group(g) for g in (self._c._cmd("group.query", {"search": search}) or [])]
def create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group."""
data = self._c._cmd("group.open", {"name": name})
if isinstance(data, dict):
return self._c._make_group(data)
return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
def tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group."""
return [self._c._make_tab(t) for t in (self._c._cmd("group.tabs", {"groupId": group_id}) or [])]
def add_tab(self, group: str | int, url: str | None = None) -> int | None:
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
result = self._c._cmd("group.add_tab", {"group": str(group), "url": url})
return self._c._field(result, "tabId", fallback=result)
def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
"""Move a tab group forward or backward. Returns the raw move result."""
return self._c._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
"""Ungroup (and close) a tab group by ID."""
self._c._cmd("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
+63
View File
@@ -0,0 +1,63 @@
"""Navigation namespace: ``b.nav.*``."""
from __future__ import annotations
from browser_cli.models import Tab
from browser_cli.sdk.base import Namespace
class NavigationNS(Namespace):
"""Open URLs, navigate history, and focus tabs."""
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
"""Open *url* in a new tab."""
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
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."""
return self._c._require_tab(
self._c._cmd("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
}),
"navigate.open_wait returned unexpected data",
)
def reload(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.reload", {"tabId": tab_id})
def hard_reload(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.hard_reload", {"tabId": tab_id})
def back(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.back", {"tabId": tab_id})
def forward(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.forward", {"tabId": tab_id})
def focus(self, pattern: str) -> dict | None:
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
return self._c._cmd("navigate.focus", {"pattern": pattern})
def to(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url* in place."""
self._c._cmd("navigate.to", {"tabId": tab_id, "url": url})
def search(
self, engine: str, query: str, *,
background: bool = False, window: str | None = None, group: str | None = None,
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
url = template.format(query=quote_plus(query))
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
+19
View File
@@ -0,0 +1,19 @@
"""Performance and background-jobs namespace: ``b.perf.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class PerfNS(Namespace):
"""Inspect the performance profile and manage background jobs."""
def status(self) -> dict:
return self._c._cmd("perf.status", {}) or {}
def set_profile(self, profile: str) -> dict:
return self._c._cmd("perf.set_profile", {"profile": profile}) or {}
def job_status(self, job_id: str) -> dict:
return self._c._cmd("jobs.status", {"jobId": job_id}) or {}
def job_cancel(self, job_id: str) -> dict:
return self._c._cmd("jobs.cancel", {"jobId": job_id}) or {}
+123
View File
@@ -0,0 +1,123 @@
"""Multi-browser routing mixin for :class:`~browser_cli.BrowserCLI`.
When no specific browser is selected and more than one browser (local or
remote) is active, list/count commands fan out to every target and aggregate
the results. This mixin holds that fan-out machinery plus a few small response
helpers; single-browser mode falls straight through to ``_cmd``.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
import browser_cli as _pkg
from browser_cli.client import BrowserNotConnected
from browser_cli.models import BrowserCounts, Tab
# send_command / active_browser_targets / remote_browser_targets are resolved
# through the ``browser_cli`` package namespace (``_pkg``) at call time, not bound
# here at import, so tests patching ``browser_cli.send_command`` still take effect.
_UNSET = object()
class RoutingMixin:
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
and a ``tabs`` namespace.
"""
def _multi_browser_targets(self):
if self._browser is not None:
return []
if self._remote:
targets = _pkg.remote_browser_targets(self._remote, key=self._key)
else:
targets = _pkg.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = _pkg.send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = _pkg.send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if targets:
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
"Make sure the browser is running with the browser-cli extension enabled,\n"
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
)
return []
@staticmethod
def _field(result, key, default=None, *, fallback=_UNSET):
"""Pull *key* out of a dict response, with a non-dict fallback.
Returns ``result[key]`` (or *default*) when *result* is a dict. When it
is not a dict, returns *fallback* if given, else *default*.
"""
if isinstance(result, dict):
return result.get(key, default)
return default if fallback is _UNSET else fallback
def _toggle_tab(self, command: str, tab_id: int | None) -> int:
"""Run a tab toggle command (mute/pin/...) and return the target tab ID."""
result = self._cmd(command, {"tabId": tab_id})
return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
def _multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
"""Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
multi_results = self._collect_multi_browser(command, args or {})
if not multi_results:
return self._cmd(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
def _multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode.
*mapper* is ``(item, target) -> mapped`` where ``target`` is the source
:class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
"""
multi_results = self._collect_multi_browser(command, args or {})
if multi_results:
return [
mapper(item, target)
for target, items in multi_results
for item in (items or [])
]
return [mapper(item, None) for item in (self._cmd(command, args or {}) or [])]
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
tabs = self.tabs.list()
try:
transformed = filter_fn(tabs)
except (AttributeError, TypeError):
return [tab for tab in tabs if filter_fn(tab)]
if isinstance(transformed, list):
return transformed
if isinstance(transformed, tuple):
return list(transformed)
if isinstance(transformed, set):
return list(transformed)
if transformed is tabs:
return tabs
if isinstance(transformed, bool):
return [tab for tab in tabs if filter_fn(tab)]
try:
return list(transformed)
except TypeError:
return [tab for tab in tabs if filter_fn(tab)]
+63
View File
@@ -0,0 +1,63 @@
"""Session namespace: ``b.session.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class SessionNS(Namespace):
"""Save, restore, list, and diff browser sessions."""
def save(self, name: str) -> dict:
"""Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
return self._c._cmd("session.save", {"name": name}) or {}
@staticmethod
def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
return {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
}
def load(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name*. Returns the load result (incl. tabs opened)."""
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
return self._c._cmd("session.load", args) or {}
def load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name* as a background job. Returns the job descriptor."""
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
return self._c._cmd("session.load", {**args, "__background": True}) or {}
def diff(self, name_a: str, name_b: str) -> dict:
return self._c._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
def list(self) -> list[dict]:
"""Return saved sessions.
In implicit multi-browser mode each session dict includes a ``browser`` key.
"""
return self._c._multi_list("session.list", {}, self._c._tag_browser)
def remove(self, name: str) -> None:
self._c._cmd("session.remove", {"name": name})
def auto_save(self, enabled: bool) -> None:
self._c._cmd("session.auto_save", {"enabled": enabled})
+215
View File
@@ -0,0 +1,215 @@
"""Tabs namespace: ``b.tabs.*``."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING
from browser_cli.models import Tab
from browser_cli.sdk.base import Namespace
if TYPE_CHECKING:
from browser_cli import BrowserCounts
class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs."""
def list(self) -> list[Tab]:
"""Return all open tabs across all windows.
When multiple browsers are active and no browser was specified, each Tab
includes ``tab.browser`` naming its source browser.
"""
return self._c._multi_list("tabs.list", {}, self._c._make_tab_for)
def open(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and return a bound :class:`Tab`.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
return self._c._require_tab(
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
"navigate.open returned unexpected data",
)
def get(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.status(tab_id)
def active(self) -> Tab:
"""Return the active tab."""
return self.status()
def query(self, search: str) -> list[Tab]:
"""Search tabs by URL or title."""
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.query", {"search": search}) or [])]
def first(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.query(search)
return matches[0] if matches else None
def close(
self,
tab_id: int | None = None,
*,
tab_ids: Iterable[int | Tab] | None = None,
inactive: bool = False,
duplicates: bool = False,
gentle_mode: str = "auto",
) -> int:
"""Close tab(s). Returns the number of tabs closed.
Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
controls throttling of large close operations.
"""
ids = None
if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
result = self._c._cmd("tabs.close", {
"tabId": tab_id,
"tabIds": ids,
"inactive": inactive,
"duplicates": duplicates,
"gentleMode": gentle_mode,
})
return self._c._field(result, "closed", 1)
def close_inactive(self) -> int:
"""Close all inactive tabs. Returns count closed."""
return self._c._field(self._c._cmd("tabs.close", {"inactive": True}), "closed", 0)
def close_duplicates(self) -> int:
"""Close duplicate tabs. Returns count closed."""
return self._c._field(self._c._cmd("tabs.close", {"duplicates": True}), "closed", 0)
def move(
self, tab_id: int, *,
forward: bool = False, backward: bool = False,
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
) -> None:
self._c._cmd("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
def activate(self, tab_id: int) -> None:
"""Switch browser focus to a tab by ID."""
self._c._cmd("tabs.active", {"tabId": tab_id})
def status(self, tab_id: int | None = None) -> Tab:
"""Return status for the active tab or a specific tab."""
return self._c._require_tab(self._c._cmd("tabs.status", {"tabId": tab_id}), "No tab status returned")
def mute(self, tab_id: int | None = None) -> int:
"""Mute the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.mute", tab_id)
def unmute(self, tab_id: int | None = None) -> int:
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.unmute", tab_id)
def pin(self, tab_id: int | None = None) -> int:
"""Pin the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.pin", tab_id)
def unpin(self, tab_id: int | None = None) -> int:
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.unpin", tab_id)
def 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."""
return self._c._require_tab(
self._c._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
"tabs.watch_url returned unexpected 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"``.
"""
return self._c._require_tab(
self._c._cmd("navigate.wait", {
"tabId": tab_id,
"timeout": int(timeout * 1000),
"readyState": ready_state,
}),
"navigate.wait returned unexpected data",
)
def 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._c._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
return self._c._field(result, "dataUrl", "", fallback=str(result))
def active_in_window(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window."""
return self._c._require_tab(
self._c._cmd("tabs.active_in_window", {"windowId": window_id}),
f"No active tab found for window {window_id}",
)
def filter(
self,
pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
) -> list[Tab]:
"""Return tabs filtered by URL pattern or a Python callable."""
if isinstance(pattern_or_filter, str):
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
return self._c._apply_tab_filter(pattern_or_filter)
def count(self, pattern: str | None = None) -> "int | BrowserCounts":
"""Count open tabs, optionally filtered by URL pattern.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self._c._multi_count("tabs.count", {"pattern": pattern})
def html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active (or specified) tab."""
return self._c._cmd("tabs.html", {"tabId": tab_id}) or ""
def dedupe(self, *, gentle_mode: str = "auto") -> int:
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
return self._c._field(self._c._cmd("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
self._c._cmd("tabs.sort", {"by": by, "gentleMode": gentle_mode})
def merge_windows(self, *, gentle_mode: str = "auto") -> int:
"""Move all tabs into the focused window. Returns count moved."""
return self._c._field(self._c._cmd("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
+24
View File
@@ -0,0 +1,24 @@
"""Windows namespace: ``b.windows.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
class WindowsNS(Namespace):
"""List, open, close, and rename browser windows."""
def list(self) -> list[dict]:
"""Return browser windows.
In implicit multi-browser mode each window dict includes a ``browser`` key.
"""
return self._c._multi_list("windows.list", {}, self._c._tag_browser)
def open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
return self._c._cmd("windows.open", {"url": url}) or {}
def close(self, window_id: int) -> None:
self._c._cmd("windows.close", {"windowId": window_id})
def rename(self, window_id: int, name: str) -> None:
self._c._cmd("windows.rename", {"windowId": window_id, "name": name})
+213
View File
@@ -0,0 +1,213 @@
"""Response payload encoding for the TCP serve <-> client leg.
The wire frame stays ``4-byte LE length + payload``. The payload is made
self-describing so old peers keep working unchanged:
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
format). Old clients and old servers only ever produce/consume this.
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
The tag's high nibble selects serialization, the low nibble compression::
tag = (serialization << 4) | compression
This is only ever emitted toward a peer that advertised support for it, so it
is fully backward compatible: clients announce what they can decode via the
``accept_encoding`` field in their request, and the server encodes the
response accordingly. Requests themselves stay plain JSON (they are tiny).
Compression is the big win response payloads (``extract.html``,
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
image as raw bytes instead of a base64 data URL (~33% smaller before
compression); the client transparently rebuilds the data URL so the SDK/CLI
API is unchanged.
"""
from __future__ import annotations
import base64
import gzip
import json
import re
import zlib
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
# ── codec ids ────────────────────────────────────────────────────────────────
SER_JSON = 0
SER_MSGPACK = 1
COMP_NONE = 0
COMP_ZLIB = 1
COMP_GZIP = 2
COMP_ZSTD = 3
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
_SER_ID = {v: k for k, v in _SER_NAME.items()}
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
DEFAULT_THRESHOLD = 512
# JSON top-level values always start with one of these bytes; a tag byte never does.
_JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
# ── compression primitives ────────────────────────────────────────────────────
def _compress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def _decompress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
# ── codec negotiation ──────────────────────────────────────────────────────────
def _choose(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
ser = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
ser = SER_MSGPACK
comp = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
comp = _COMP_ID[name]
break
return ser, comp
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
_B64_MARKER = "__b64__"
def _hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
m = _DATA_URL_RE.match(url)
if not m:
return obj
try:
raw = base64.b64decode(m.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
return {**obj, "data": new_data}
def _unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {k: _unhoist_binary(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_unhoist_binary(v) for v in obj]
return obj
# ── encode / decode ─────────────────────────────────────────────────────────────
def encode_response(obj, accept: dict | None = None, command: str | None = None,
threshold: int = DEFAULT_THRESHOLD) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+2 -2
View File
@@ -3,16 +3,16 @@ from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version("browser-cli")
except Exception:
return "0.0.0"
USER_AGENT = f"browser-cli/{get_installed_version()}"