diff --git a/README.md b/README.md index 2ad49ba..6c234cb 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 832d8fb..027e89e 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -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, diff --git a/browser_cli/models.py b/browser_cli/models.py index 9371fd8..c6d465d 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -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) diff --git a/extension/manifest.json b/extension/manifest.json index e07ecd0..360c492 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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", diff --git a/pyproject.toml b/pyproject.toml index d15b67b..01ee8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_api.py b/tests/test_api.py index 3d3315f..6494619 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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( diff --git a/uv.lock b/uv.lock index e5d41cb..9a6eb2a 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },