""" browser_cli — Python SDK for controlling your running browser. Usage: from browser_cli import BrowserCLI b = BrowserCLI() tabs = b.tabs_list() # list[Tab] tabs[0].close() tabs[0].move(forward=True) groups = b.group_list() # list[Group] groups[0].tabs() groups[0].add_tab("https://example.com") # When multiple browser instances are active, pass the alias: b = BrowserCLI(browser="brave") """ from collections.abc import Callable, Iterable from dataclasses import dataclass from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command from browser_cli.models import Group, Tab __all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"] @dataclass(frozen=True) class BrowserCounts: """Aggregated per-browser counts returned in implicit multi-browser mode.""" total: int by_browser: dict[str, int] class BrowserCLI: def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None): """ Args: browser: Profile alias to target. Required when multiple browser instances are active. Equivalent to ``--browser`` on the CLI. remote: Connect to a remote browser exposed via ``browser-cli serve``. Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``). Can be combined with ``browser`` to route to a specific remote profile. key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"`` to use a key from the SSH agent (YubiKey, gpg-agent, etc.). Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists. """ self._browser = browser self._remote = remote self._key = key if key else None @property def browser(self) -> str | None: """Target browser/profile alias, equivalent to ``--browser``.""" return self._browser @property def remote(self) -> str | None: """Remote endpoint used by this client, if any.""" return self._remote @property def key(self) -> str | None: """Ed25519 key spec used for remote auth, if explicitly configured.""" return self._key def _cmd(self, command: str, args: dict | None = None): return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key) def command(self, command: str, args: dict | None = None): """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. """ 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 / 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