feat(sdk): improve Python SDK ergonomics
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 42s

- Position browser-cli as a CLI plus Python SDK in docs and package metadata.
- Add public target properties and a raw command escape hatch for unsupported commands.
- Add convenience helpers for opening, finding, closing, and accessing tabs.
- Add plural group aliases and a wait_for_selector DOM convenience alias.
- Extend bound Tab objects with screenshot, pin, refresh, load wait, and URL watch helpers.
- Preserve remote auth key configuration when binding remote Tab and Group objects.
- Bump project and extension versions to 0.9.9 and cover SDK additions with tests.
This commit is contained in:
2026-05-19 20:12:16 +02:00
parent eaa1469143
commit e1e4adbb25
7 changed files with 364 additions and 17 deletions
+28 -11
View File
@@ -1,19 +1,16 @@
# browser-cli
Control your real, running browser from the terminal or a Python script — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
---
## What it does
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small browser extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
---
## How it works
```
terminal / python script
@@ -79,9 +76,9 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
```text
browser-cli/
├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
│ ├── cli.py # Click CLI entry point
│ ├── client.py # Local IPC client used by CLI and API
│ ├── client.py # Local IPC client used by CLI and SDK
│ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/
@@ -99,7 +96,7 @@ browser-cli/
│ └── src/ # TypeScript source split by command area
│ └── index.ts # Builds generated extension/background.js
├── examples/
│ ├── demo.py # Python API walkthrough
│ ├── demo.py # Python SDK walkthrough
│ └── demo.sh # Bash CLI walkthrough
├── tests/
│ ├── conftest.py # shared pytest fixtures
@@ -285,7 +282,7 @@ browser-cli completion zsh --script # output raw completion script
---
## Python API
## Python SDK
```python
from browser_cli import BrowserCLI
@@ -293,11 +290,13 @@ from browser_cli import BrowserCLI
b = BrowserCLI()
```
Every CLI command has a corresponding method. The call blocks until the browser responds and returns the data directly as a Python object.
Every CLI command has a corresponding SDK method. The call blocks until the browser responds and returns the data directly as a Python object.
```python
# Navigation
b.open("https://example.com")
tab = b.open_tab("https://example.com") # returns a bound Tab object
tab = b.open_tab("https://example.com", wait=True, timeout=10)
b.open("https://example.com", background=True)
b.open("https://example.com", window="work")
b.reload()
@@ -308,8 +307,14 @@ b.focus_url("github")
# Tabs
tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set
tabs = b.tabs() # short alias for tabs_list()
active = b.active_tab() # active Tab object
tab = b.tab(1234) # tab by ID
tab = b.find_tab("github") # first matching tab or None
tabs = b.find_tabs("github") # alias for tabs_query()
b.tabs_active(1234)
b.tabs_close(1234)
b.close_tab(tab) # accepts Tab or tab ID
b.tabs_close_inactive()
b.tabs_close_duplicates()
b.tabs_filter("youtube") # list of matching tabs
@@ -320,9 +325,19 @@ b.tabs_sort(by="domain")
b.tabs_merge_windows()
b.tabs_dedupe()
# Bound Tab helpers
tab = b.active_tab()
tab.pin()
tab.screenshot()
tab.refresh()
tab.wait_for_load(timeout=10)
tab.watch_url(r"/done$")
# Tab groups
groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set
b.group_open("research") # creates group, returns { id, name }
groups = b.groups() # short alias for group_list()
b.groups_create("research") # plural alias for group_create()
b.group_create("research") # creates group, returns Group
b.group_close(42)
b.group_tabs(42) # tabs inside a group
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
@@ -341,6 +356,7 @@ attrs = b.dom_attr("a", "href") # list of strings
exists = b.dom_exists(".cookie-banner")# bool
b.dom_click(".accept-button")
b.dom_type("#search", "hello world")
b.wait_for_selector("#results", visible=True, timeout=10)
# Extract
links = b.extract_links() # list of { text, href }
@@ -359,6 +375,7 @@ b.session_auto_save(True)
# Misc
clients = b.clients()
raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw commands
```
**Error handling**
+109 -1
View File
@@ -1,5 +1,5 @@
"""
browser_cli — Python API for controlling your running browser.
browser_cli — Python SDK for controlling your running browser.
Usage:
from browser_cli import BrowserCLI
@@ -50,9 +50,32 @@ class BrowserCLI:
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 []
@@ -109,6 +132,7 @@ class BrowserCLI:
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return tab
@@ -131,6 +155,7 @@ class BrowserCLI:
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return group
@@ -139,6 +164,27 @@ class BrowserCLI:
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})
@@ -216,6 +262,32 @@ class BrowserCLI:
# ── 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.
@@ -370,6 +442,14 @@ class BrowserCLI:
# ── 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.
@@ -394,6 +474,10 @@ class BrowserCLI:
"""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.
@@ -405,6 +489,10 @@ class BrowserCLI:
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 [])]
@@ -413,6 +501,14 @@ class BrowserCLI:
"""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})
@@ -536,6 +632,18 @@ class BrowserCLI:
"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,
+24
View File
@@ -95,6 +95,30 @@ class Tab:
"""Return the full HTML source of this tab."""
return self._b()._cmd("tabs.html", {"tabId": self.id})
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)
def pin(self) -> None:
"""Pin this tab."""
self._b()._cmd("tabs.pin", {"tabId": self.id})
def unpin(self) -> None:
"""Unpin this tab."""
self._b()._cmd("tabs.unpin", {"tabId": self.id})
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
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)
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)
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url* in place."""
self._b().navigate_tab(self.id, url)
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.9.8",
"description": "Control your browser from the terminal via browser-cli",
"version": "0.9.9",
"description": "Control your browser from the terminal or Python SDK",
"permissions": [
"tabs",
"tabGroups",
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "browser-cli"
version = "0.9.8"
description = "Control your real running browser from the terminal via a browser extension"
version = "0.9.9"
description = "Control your real running browser from the terminal or Python SDK"
requires-python = ">=3.10"
dependencies = [
"click>=8",
+198
View File
@@ -69,6 +69,31 @@ class TestBrowserCLIInit:
assert b._browser == "work"
assert b._remote == "host:8765"
def test_public_target_properties(self):
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
assert b.browser == "work"
assert b.remote == "browser-host.example:443"
assert b.key == "agent"
def test_raw_command_escape_hatch(self, mock_send):
mock_send.return_value = {"ok": True}
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
result = b.command("custom.command", {"value": 1})
assert result == {"ok": True}
mock_send.assert_called_once_with(
"custom.command",
{"value": 1},
profile="work",
remote="browser-host.example:443",
key="agent",
)
def test_raw_command_defaults_args_to_empty_dict(self, b, mock_send):
b.command("custom.command")
mock_send.assert_called_once_with("custom.command", {}, profile=None, remote=None, key=None)
# ── Internal factories ────────────────────────────────────────────────────────
@@ -140,6 +165,41 @@ class TestNavigation:
b.open("https://x.com", group="Work")
assert mock_send.call_args[0][1]["group"] == "Work"
def test_open_tab_returns_bound_tab(self, b, mock_send):
mock_send.return_value = {"id": 123, "url": "https://example.com"}
tab = b.open_tab("https://example.com", background=True)
assert tab.id == 123
assert tab.url == "https://example.com"
assert tab._browser is b
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": True, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_open_tab_wait_uses_open_wait(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.open_tab("https://example.com", wait=True, timeout=1.5)
assert tab.id == 10
mock_send.assert_called_once_with(
"navigate.open_wait",
{"url": "https://example.com", "timeout": 1500, "background": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_open_tab_unexpected_response_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="navigate.open returned unexpected data"):
b.open_tab("https://example.com")
def test_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
@@ -340,6 +400,64 @@ class TestTabs:
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
]
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs_list()
tabs[0].close()
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
]
def test_tabs_alias_and_active_tab(self, b, mock_send):
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
tabs = b.tabs()
active = b.active_tab()
assert tabs[0].id == 10
assert active.id == 10
assert mock_send.call_args_list == [
call("tabs.list", {}, profile=None, remote=None, key=None),
call("tabs.status", {"tabId": None}, profile=None, remote=None, key=None),
]
def test_tab_find_and_close_helpers(self, b, mock_send):
mock_send.side_effect = [TAB_DATA, [TAB_DATA], [], {"closed": 1}, {"closed": 1}]
tab = b.tab(10)
found = b.find_tab("Example")
missing = b.find_tab("Missing")
closed_by_object = b.close_tab(tab)
closed_by_id = b.close_tab(10)
assert tab.id == 10
assert found and found.id == 10
assert missing is None
assert closed_by_object == 1
assert closed_by_id == 1
assert mock_send.call_args_list == [
call("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Missing"}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
]
def test_find_tabs_alias(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.find_tabs("Example")
assert [tab.id for tab in tabs] == [10]
mock_send.assert_called_once_with("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None)
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
@@ -463,6 +581,40 @@ class TestGroups:
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
]
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.group_list()
groups[0].close()
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
]
def test_group_aliases(self, b, mock_send):
mock_send.side_effect = [[GROUP_DATA], [GROUP_DATA], 1, [GROUP_DATA], GROUP_DATA, GROUP_DATA]
assert b.groups()[0].id == 42
assert b.groups_list()[0].id == 42
assert b.groups_count() == 1
assert b.groups_query("Work")[0].id == 42
assert b.groups_create("Work").id == 42
assert b.group_open("Work").id == 42
assert mock_send.call_args_list == [
call("group.list", {}, profile=None, remote=None, key=None),
call("group.list", {}, profile=None, remote=None, key=None),
call("group.count", {}, profile=None, remote=None, key=None),
call("group.query", {"search": "Work"}, profile=None, remote=None, key=None),
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
@@ -554,6 +706,22 @@ class TestWindows:
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestDomConvenience:
def test_wait_for_selector_alias(self, b, mock_send):
mock_send.return_value = {"selector": "#done", "found": True}
result = b.wait_for_selector("#done", timeout=2.5, visible=True, tab_id=10)
assert result == {"selector": "#done", "found": True}
mock_send.assert_called_once_with(
"dom.wait_for",
{"selector": "#done", "timeout": 2500, "visible": True, "hidden": False, "tabId": 10},
profile=None,
remote=None,
key=None,
)
class TestSession:
def test_session_list(self, b, mock_send):
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
@@ -633,6 +801,36 @@ class TestTabModel:
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
)
def test_screenshot(self, tab, mock_send):
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
assert tab.screenshot() == "data:image/png;base64,abc"
mock_send.assert_called_once_with(
"tabs.screenshot", {"tabId": 10, "format": "png", "quality": None}, profile=None, remote=None, key=None
)
def test_pin_unpin(self, tab, mock_send):
tab.pin()
tab.unpin()
assert mock_send.call_args_list == [
call("tabs.pin", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.unpin", {"tabId": 10}, profile=None, remote=None, key=None),
]
def test_refresh(self, tab, mock_send):
mock_send.return_value = {**TAB_DATA, "title": "Fresh"}
fresh = tab.refresh()
assert fresh.title == "Fresh"
mock_send.assert_called_once_with("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None)
def test_wait_for_load_and_watch_url(self, tab, mock_send):
mock_send.side_effect = [TAB_DATA, TAB_DATA]
tab.wait_for_load(timeout=1.5, ready_state="interactive")
tab.watch_url("example", timeout=2)
assert mock_send.call_args_list == [
call("navigate.wait", {"tabId": 10, "timeout": 1500, "readyState": "interactive"}, profile=None, remote=None, key=None),
call("tabs.watch_url", {"pattern": "example", "tabId": 10, "timeout": 2000}, profile=None, remote=None, key=None),
]
def test_open_background_changes_same_tab(self, tab, mock_send):
tab.open("https://new.example.com", background=True)
mock_send.assert_called_once_with(
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.9.8"
version = "0.9.9"
source = { editable = "." }
dependencies = [
{ name = "click" },