feat: group multi-browser output by source
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s

- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
This commit is contained in:
2026-06-18 00:52:04 +02:00
parent 479a0f1964
commit 8dece7800f
19 changed files with 540 additions and 270 deletions
+58 -54
View File
@@ -1,20 +1,21 @@
# browser-cli # browser-cli
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. Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
--- ---
## What it does ## 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. 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 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. 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, or through the optional authenticated TCP remote bridge.
--- ---
## How it works ## How it works
``` ```
terminal / python script terminal / python script / remote client
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows) │ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
│ or TCP remote bridge (Ed25519 auth, optional compression)
Native Messaging Host (Python process, launched by the browser) Native Messaging Host (Python process, launched by the browser)
@@ -33,7 +34,7 @@ terminal / python script
4. CLI commands connect to that socket, send a JSON command, and wait for the result. 4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI. 5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
No server needs to be running beforehand. The browser manages the native host's lifecycle. No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format** **Message format**
@@ -53,7 +54,7 @@ Every response:
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
### Install with uv ### Install with uv
Once published on PyPI, install the CLI as a uv tool: Install the CLI from PyPI as a uv tool:
```sh ```sh
uv tool install real-browser-cli uv tool install real-browser-cli
@@ -100,62 +101,42 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
```text ```text
browser-cli/ browser-cli/
├── browser_cli/ ├── browser_cli/
│ ├── __init__.py # Python SDK BrowserCLI class and SDK entry point │ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
│ ├── cli.py # Click CLI entry point │ ├── async_sdk.py # AsyncBrowserCLI
│ ├── client/ # Client-side command routing used by CLI and SDK │ ├── cli.py # Click root command and native-host entry point
│ ├── core.py # send_command and remote command routing │ ├── client/ # send_command path, local/remote routing, message helpers
│ ├── targets.py # Browser target discovery and socket resolution │ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
│ ├── auth.py # Remote auth fields and key lookup │ ├── commands/ # CLI presentation layer over the SDK namespaces
│ └── messages.py # Request/response helpers ├── native/ # Browser-launched Native Messaging host + local IPC server
│ ├── models.py # Tab and Group helper models │ ├── remote/ # TCP remote client transport and saved endpoint registry
│ ├── native/ # Native messaging host internals │ ├── serve/ # Authenticated TCP server runtime
│ ├── host.py # Browser-launched native host entry point │ ├── transport/ # JSON/msgpack response encoding and compression helpers
│ ├── local_server.py # Local CLI IPC server │ ├── markdown/ # HTML-to-Markdown extraction helpers
│ └── protocol.py # Chrome Native Messaging framing ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
── remote/ # Client-side remote browser support ── models.py # Tab, Group, BrowserCounts dataclasses
│ │ ├── transport.py # TCP/TLS remote transport
│ │ └── registry.py # Saved remote endpoints/keys
│ └── commands/
│ ├── navigate.py # nav open/reload/back/forward/focus
│ ├── search.py # search engine shortcuts
│ ├── tabs.py # tab management
│ ├── groups.py # tab group management
│ ├── windows.py # window management
│ ├── dom.py # DOM querying and interaction
│ ├── extract.py # content extraction
│ └── session.py # session save/load
├── extension/ ├── extension/
│ ├── manifest.json # MV3 extension manifest │ ├── manifest.json # Chromium MV3 manifest
── content.js # Content-script helpers ── src/ # TypeScript WebExtension source
└── src/ # TypeScript source split by command area ├── index.ts # Background/service-worker bundle entry
│ ├── index.ts # Builds generated extension/background.js │ ├── content-dispatch.ts
── content/ # Builds generated extension/content-dispatch.js ── commands/ # Browser-side command implementations
├── examples/ │ ├── content/ # DOM/extract/Markdown logic injected into pages
├── demo.py # Python SDK walkthrough └── core/ # Shared extension helpers
│ └── demo.sh # Bash CLI walkthrough ├── examples/ # Python and shell walkthroughs
├── tests/ ├── scripts/ # Packaging and release helper scripts
│ ├── conftest.py # shared pytest fixtures ├── tests/ # pytest suite
│ ├── test_api.py ├── package.json # Extension build/test/package scripts
│ ├── test_cli.py ├── pyproject.toml # Python package metadata
│ ├── test_dom.py └── uv.lock # locked Python dependencies
│ ├── test_extract.py
│ ├── test_groups.py
│ ├── test_nav.py
│ ├── test_session.py
│ ├── test_tabs.py
│ └── test_windows.py
├── com.browsercli.host.json # native messaging manifest template
├── pyproject.toml # package metadata and CLI entry point
└── uv.lock # locked dependencies for uv
``` ```
--- ---
## CLI reference ## CLI reference
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`. During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically. If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
@@ -315,6 +296,27 @@ browser-cli completion zsh # print setup instructions
browser-cli completion zsh --script # output raw completion script browser-cli completion zsh --script # output raw completion script
``` ```
### Remote control, auth, and gateways
```sh
# On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
browser-cli auth trust "$PUBKEY"
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
# From another machine
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
browser-cli --remote browser-host.example:8765 clients
# Local HTTP JSON gateway for small integrations
browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
--- ---
## Python SDK ## Python SDK
@@ -325,7 +327,7 @@ from browser_cli import AsyncBrowserCLI, BrowserCLI
b = BrowserCLI() b = BrowserCLI()
``` ```
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport. Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
```python ```python
# Navigation ── b.nav # Navigation ── b.nav
@@ -480,6 +482,7 @@ counts = b.tabs.count()
if isinstance(counts, BrowserCounts): if isinstance(counts, BrowserCounts):
print(counts.total) print(counts.total)
print(counts.by_browser) print(counts.by_browser)
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
``` ```
--- ---
@@ -515,6 +518,7 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
Packaging: Packaging:
```bash ```bash
just publish # build to /tmp/dist-browser-cli and publish with .env credentials
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
+15 -1
View File
@@ -39,9 +39,23 @@ def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
""" """
if isinstance(result, BrowserCounts): if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser") table.add_column("Browser", no_wrap=True)
table.add_column(f"{noun.capitalize()}s", justify="right") table.add_column(f"{noun.capitalize()}s", justify="right")
rendered_groups: set[str] = set()
for name, count in result.by_browser.items(): for name, count in result.by_browser.items():
group = result.browser_groups.get(name)
if group:
if group not in rendered_groups:
group_total = sum(
browser_count
for browser_name, browser_count in result.by_browser.items()
if result.browser_groups.get(browser_name) == group
)
table.add_row(f"[bold]{group}[/bold]", str(group_total))
rendered_groups.add(group)
display_name = name.removeprefix(f"{group}:")
table.add_row(f" {display_name}", str(count))
else:
table.add_row(name, str(count)) table.add_row(name, str(count))
table.add_row("Total", str(result.total)) table.add_row("Total", str(result.total))
_console.print(table) _console.print(table)
+28 -5
View File
@@ -36,7 +36,7 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
if alias in profiles and alias != target_profile: if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists") raise click.ClickException(f"Browser alias '{alias}' already exists")
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False): def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
"""Query clients.list for one target and append each, tagged with *label*.""" """Query clients.list for one target and append each, tagged with *label*."""
if quiet_remote_warning: if quiet_remote_warning:
result = send_command( result = send_command(
@@ -50,6 +50,8 @@ def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_r
result = send_command("clients.list", profile=profile, remote=remote, key=key) result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []): for c in (result or []):
c["profile"] = label c["profile"] = label
if profile_group:
c["profileGroup"] = profile_group
into.append(c) into.append(c)
@click.group("clients", invoke_without_command=True) @click.group("clients", invoke_without_command=True)
@@ -89,7 +91,14 @@ def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) ->
sys.exit(1) sys.exit(1)
for target in targets: for target in targets:
try: try:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key) _append_clients(
all_clients,
target.display_name,
profile=target.profile,
remote=resolved.remote,
key=key,
profile_group=target.display_group,
)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
continue continue
@@ -109,10 +118,11 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
for profile_name, sock_path in profiles.items(): for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path) display_profile = display_browser_name(profile_name, sock_path)
try: try:
_append_clients(all_clients, display_profile, profile=profile_name) _append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
all_clients.append({ all_clients.append({
"profile": display_profile, "profile": display_profile,
"profileGroup": "local",
"name": "", "name": "",
"version": "", "version": "",
"extensionVersion": "disconnected", "extensionVersion": "disconnected",
@@ -130,6 +140,7 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profile=target.profile, profile=target.profile,
remote=target.remote, remote=target.remote,
quiet_remote_warning=True, quiet_remote_warning=True,
profile_group=target.display_group,
) )
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
continue continue
@@ -137,13 +148,25 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
def _print_clients(all_clients: list) -> None: def _print_clients(all_clients: list) -> None:
from rich.table import Table from rich.table import Table
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile") table.add_column("Profile", no_wrap=True)
table.add_column("Browser") table.add_column("Browser")
table.add_column("Version") table.add_column("Version")
table.add_column("Extension Version") table.add_column("Extension Version")
rendered_groups: set[str] = set()
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
grouped = bool(groups and groups != {"local"})
for c in all_clients: for c in all_clients:
group = c.get("profileGroup") if grouped else None
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", "", "", "")
rendered_groups.add(group)
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
profile = f" {profile}"
else:
profile = c.get("profile", "")
table.add_row( table.add_row(
c.get("profile", ""), profile,
c.get("name", ""), c.get("name", ""),
c.get("version", ""), c.get("version", ""),
c.get("extensionVersion", ""), c.get("extensionVersion", ""),
+8 -22
View File
@@ -1,33 +1,19 @@
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from rich.console import Console from rich.console import Console
from rich.table import Table
console = Console() console = Console()
def _print_groups(groups, *, show_browser: bool = False) -> None: def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups: columns = [
console.print("[yellow]No groups found[/yellow]") ("ID", lambda g: g.id),
return ("Name", lambda g: g.title or ""),
table = Table(show_header=True, header_style="bold cyan") ("Color", lambda g: g.color or ""),
if show_browser: ("Collapsed", lambda g: "yes" if g.collapsed else "no"),
table.add_column("Browser") ("Tabs", lambda g: g.tab_count),
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Name")
table.add_column("Color", width=10)
table.add_column("Collapsed", width=10)
table.add_column("Tabs", width=6)
for g in groups:
row = [
(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]) print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
console.print(table)
@click.group("groups") @click.group("groups")
def group_group(): def group_group():
+18 -8
View File
@@ -8,6 +8,7 @@ from rich.table import Table
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
console = Console() console = Console()
@@ -23,14 +24,23 @@ def remote_group():
def remote_status(endpoint, key): def remote_status(endpoint, key):
"""Probe a remote endpoint and show server/client status.""" """Probe a remote endpoint and show server/client status."""
client = BrowserCLI(remote=endpoint, key=key) client = BrowserCLI(remote=endpoint, key=key)
clients = client.clients() clients = [
table = Table(show_header=True, header_style="bold cyan") {**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
table.add_column("Profile") for item in client.clients()
table.add_column("Browser") ]
table.add_column("Extension") columns = [
for item in clients: ("Browser", lambda item: item.get("name", "")),
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", ""))) ("Extension", lambda item: item.get("extensionVersion", "")),
console.print(table) ]
print_browser_grouped_table_rows(
clients,
columns,
console=console,
empty_message="[yellow]No browser clients found[/yellow]",
browser_getter=lambda item: item.get("profileLabel", ""),
group_getter=lambda item: item.get("profileGroup", ""),
browser_header="Profile",
)
@remote_group.command("trust") @remote_group.command("trust")
@click.argument("endpoint") @click.argument("endpoint")
@@ -2,6 +2,7 @@
from browser_cli.commands.rendering.common import ( from browser_cli.commands.rendering.common import (
Column, Column,
item_value, item_value,
print_browser_grouped_table_rows,
print_table_rows, print_table_rows,
print_tree, print_tree,
shorten, shorten,
@@ -44,6 +45,7 @@ __all__ = [
"group_tree_label", "group_tree_label",
"item_value", "item_value",
"no_wrap_text", "no_wrap_text",
"print_browser_grouped_table_rows",
"print_table_rows", "print_table_rows",
"print_tree", "print_tree",
"scoped_browser_label", "scoped_browser_label",
+53
View File
@@ -82,3 +82,56 @@ def print_table_rows(
for row in rows: for row in rows:
table.add_row(*[text_value(getter(row)) for _header, getter in columns]) table.add_row(*[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table) Console(width=terminal_width(console)).print(table)
def print_browser_grouped_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
browser_header: str = "Browser",
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render rows with optional local/remote browser grouping.
Rows without a browser label are rendered as a normal table. Rows with
``browser_group``/``browserGroup`` get a group header (for example ``local``
or a remote host) and a short indented profile label below it.
"""
if not rows:
console.print(empty_message)
return
show_browser = any(bool(browser_getter(row)) for row in rows)
if not show_browser:
print_table_rows(
rows,
columns,
console=console,
empty_message=empty_message,
show_header=show_header,
header_style=header_style,
)
return
table = Table(show_header=show_header, header_style=header_style)
table.add_column(browser_header, no_wrap=True)
for header, _getter in columns:
table.add_column(header)
rendered_groups: set[str] = set()
for row in rows:
browser = text_value(browser_getter(row))
group = text_value(group_getter(row))
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
rendered_groups.add(group)
browser = browser.removeprefix(f"{group}:")
browser = f" {browser}"
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
+10 -14
View File
@@ -105,24 +105,20 @@ def session_diff(name_a, name_b):
def session_list(): def session_list():
"""List all saved sessions.""" """List all saved sessions."""
from datetime import datetime from datetime import datetime
from rich.table import Table from browser_cli.commands.rendering import print_browser_grouped_table_rows
sessions = client_from_ctx().session.list() sessions = client_from_ctx().session.list()
if not sessions: if not sessions:
console.print("[yellow]No saved sessions[/yellow]") console.print("[yellow]No saved sessions[/yellow]")
return return
show_browser = any("browser" in s for s in sessions) def saved_at(session):
table = Table(show_header=True, header_style="bold cyan") return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
if show_browser:
table.add_column("Browser") columns = [
table.add_column("Name") ("Name", lambda session: session["name"]),
table.add_column("Tabs", width=6) ("Tabs", lambda session: session["tabs"]),
table.add_column("Saved at") ("Saved at", saved_at),
for s in sessions: ]
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else "" print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
@session_group.command("remove") @session_group.command("remove")
@click.argument("name") @click.argument("name")
+4 -7
View File
@@ -2,25 +2,22 @@ import base64
import binascii import binascii
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
console = Console() console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None: def _print_tabs(tabs, *, show_browser: bool = False) -> None:
columns = [] columns = [
if show_browser:
columns.append(("Browser", lambda tab: tab.browser or ""))
columns.extend([
("ID", lambda tab: tab.id), ("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id), ("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""), ("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""), ("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]), ("Title", lambda tab: (tab.title or "")[:60]),
("URL", lambda tab: (tab.url or "")[:80]), ("URL", lambda tab: (tab.url or "")[:80]),
]) ]
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]") print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
@click.group("tabs") @click.group("tabs")
def tabs_group(): def tabs_group():
+4 -7
View File
@@ -1,21 +1,18 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console from rich.console import Console
console = Console() console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
columns = [] columns = [
if show_browser:
columns.append(("Browser", lambda window: window.get("browser", "")))
columns.extend([
("ID", lambda window: window.get("id", "")), ("ID", lambda window: window.get("id", "")),
("Alias", lambda window: window.get("alias") or ""), ("Alias", lambda window: window.get("alias") or ""),
("Tabs", lambda window: window.get("tabCount", "")), ("Tabs", lambda window: window.get("tabCount", "")),
("State", lambda window: window.get("state") or ""), ("State", lambda window: window.get("state") or ""),
]) ]
print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]") print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
@click.group("windows") @click.group("windows")
def windows_group(): def windows_group():
+1
View File
@@ -31,6 +31,7 @@ class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode.""" """Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int total: int
by_browser: dict[str, int] by_browser: dict[str, int]
browser_groups: dict[str, str] = field(default_factory=dict)
# ── Tab ─────────────────────────────────────────────────────────────────────── # ── Tab ───────────────────────────────────────────────────────────────────────
+11 -4
View File
@@ -11,6 +11,11 @@ from typing import Any, Protocol, cast
from browser_cli.models import Group, Tab from browser_cli.models import Group, Tab
def _target_group(target) -> str | None:
if target is None:
return None
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
class _FactoryClient(Protocol): class _FactoryClient(Protocol):
_key: str | None _key: str | None
@@ -97,7 +102,7 @@ class FactoryMixin:
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None, browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None, browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=getattr(target, "display_group", None) if target else None, browser_group=_target_group(target),
) )
def group_from_target(self, data: dict, target) -> Group: def group_from_target(self, data: dict, target) -> Group:
@@ -108,10 +113,12 @@ class FactoryMixin:
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None, browser_remote=target.remote if target else None,
browser_type=getattr(target, "browser_name", None) if target else None, browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=getattr(target, "display_group", None) if target else None, browser_group=_target_group(target),
) )
@staticmethod @staticmethod
def tag_browser(item: dict, target) -> dict: def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode.""" """Return *item* as-is locally, or with browser metadata in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name} if target is None:
return item
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
+6 -1
View File
@@ -126,7 +126,12 @@ class RoutingMixin:
if not multi_results: if not multi_results:
return self._client.dispatch(command, args or {}) return self._client.dispatch(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in 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) browser_groups = {
target.display_name: target.display_group or "local"
for target, _count in multi_results
if target.display_group or target.remote is None
}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
def multi_list(self, command: str, args: dict | None, mapper): def multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode. """List command, flattening per-browser results in multi-browser mode.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.5", "version": "0.15.6",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+12
View File
@@ -74,6 +74,18 @@ version-check:
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \ ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
# Build into /tmp/dist-browser-cli and publish using credentials from .env
publish:
#!/usr/bin/env bash
set -euo pipefail
set -a
[ ! -f .env ] || source .env
set +a
rm -rf /tmp/dist-browser-cli
mkdir -p /tmp/dist-browser-cli
uv build --out-dir /tmp/dist-browser-cli
uv publish /tmp/dist-browser-cli/*
# ── Demos ────────────────────────────────────────────────────────────── # ── Demos ──────────────────────────────────────────────────────────────
# Run the Python SDK demo # Run the Python SDK demo
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.5" version = "0.15.6"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+35 -6
View File
@@ -559,12 +559,37 @@ class TestTabs:
mock_send.side_effect = [3, 4] mock_send.side_effect = [3, 4]
result = b.tabs.count("github") result = b.tabs.count("github")
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4}) assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 3, "work": 4},
browser_groups={"uuid-1": "local", "work": "local"},
)
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.count", {"pattern": "github"}, profile="default"), call("tabs.count", {"pattern": "github"}, profile="default"),
call("tabs.count", {"pattern": "github"}, profile="work"), call("tabs.count", {"pattern": "github"}, profile="work"),
] ]
def test_tabs_count_multi_browser_keeps_remote_display_groups(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
],
):
mock_send.side_effect = [1, 2]
result = b.tabs.count()
assert result == BrowserCounts(
total=3,
by_browser={"browser-host.example:main": 1, "browser-host.example:work": 2},
browser_groups={"browser-host.example:main": "browser-host.example", "browser-host.example:work": "browser-host.example"},
)
assert mock_send.call_args_list == [
call("tabs.count", {"pattern": None}, profile="main", remote="browser-host.example:8765", key=None),
call("tabs.count", {"pattern": None}, profile="work", remote="browser-host.example:8765", key=None),
]
def test_tabs_query(self, b, mock_send): def test_tabs_query(self, b, mock_send):
mock_send.return_value = [TAB_DATA] mock_send.return_value = [TAB_DATA]
result = b.tabs.query("example") result = b.tabs.query("example")
@@ -713,7 +738,11 @@ class TestGroups:
mock_send.side_effect = [2, 5] mock_send.side_effect = [2, 5]
result = b.groups.count() result = b.groups.count()
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5}) assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 2, "work": 5},
browser_groups={"uuid-1": "local", "work": "local"},
)
def test_group_query(self, b, mock_send): def test_group_query(self, b, mock_send):
mock_send.return_value = [GROUP_DATA] mock_send.return_value = [GROUP_DATA]
@@ -772,8 +801,8 @@ class TestWindows:
result = b.windows.list() result = b.windows.list()
assert result == [ assert result == [
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"}, {"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"},
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"}, {"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"},
] ]
def test_windows_open_without_url(self, b, mock_send): def test_windows_open_without_url(self, b, mock_send):
@@ -907,8 +936,8 @@ class TestSession:
result = b.session.list() result = b.session.list()
assert result == [ assert result == [
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"}, {"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"},
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"}, {"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"},
] ]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("session.list", {}, profile="default"), call("session.list", {}, profile="default"),
+135 -1
View File
@@ -217,7 +217,13 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8") registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8")
remote_target = BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765") remote_target = BrowserTarget(
"work",
"browser-host.example:work",
"",
remote="browser-host.example:8765",
display_group="browser-host.example",
)
def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False): def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False):
assert command == "clients.list" assert command == "clients.list"
@@ -239,7 +245,11 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
assert result.exit_code == 0 assert result.exit_code == 0
active_targets.assert_called_once_with(suppress_pq_warning=True) active_targets.assert_called_once_with(suppress_pq_warning=True)
assert "local" in result.output
assert "Chrome" in result.output assert "Chrome" in result.output
assert "browser-host.example" in result.output
assert "browser-host.example:work" not in result.output
assert "work" in result.output
assert "Remote Chrome" in result.output assert "Remote Chrome" in result.output
assert "post-quantum" not in result.output assert "post-quantum" not in result.output
@@ -362,9 +372,33 @@ def test_tabs_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "550e8400-e29b-41d4-a716-446655440000" in result.output assert "550e8400-e29b-41d4-a716-446655440000" in result.output
assert "work" in result.output assert "work" in result.output
def test_tabs_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "tabs.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_tabs_list_with_remote_uses_only_remote_targets(): def test_tabs_list_with_remote_uses_only_remote_targets():
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
@@ -495,9 +529,36 @@ def test_tabs_count_multi_browser_shows_total():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "Total" in result.output assert "Total" in result.output
assert "7" in result.output assert "7" in result.output
def test_tabs_count_unscoped_groups_remote_targets_by_host():
counts = {"main": 1, "work": 2}
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "tabs.count"
assert remote == "browser-host.example:8765"
return counts[profile]
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
],
), patch("browser_cli.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "count"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
assert "Total" in result.output
assert "3" in result.output
def test_group_count_multi_browser_shows_total(): def test_group_count_multi_browser_shows_total():
counts = {"default": 1, "work": 2} counts = {"default": 1, "work": 2}
@@ -519,6 +580,29 @@ def test_group_count_multi_browser_shows_total():
assert "Total" in result.output assert "Total" in result.output
assert "3" in result.output assert "3" in result.output
def test_groups_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "group.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "title": profile, "color": "blue", "collapsed": False, "tabCount": 2}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["groups", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_group_list_leaves_unnamed_group_cell_empty(): def test_group_list_leaves_unnamed_group_cell_empty():
with patch( with patch(
"browser_cli.send_command", "browser_cli.send_command",
@@ -567,10 +651,34 @@ def test_windows_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "Focused" not in result.output assert "Focused" not in result.output
assert "uuid-1" in result.output assert "uuid-1" in result.output
assert "work" in result.output assert "work" in result.output
def test_windows_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "windows.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["windows", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_session_list_multi_browser_shows_browser_column(): def test_session_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None): def fake_send_command(command, args=None, profile=None):
assert command == "session.list" assert command == "session.list"
@@ -587,11 +695,37 @@ def test_session_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "uuid-1" in result.output assert "uuid-1" in result.output
assert "work" in result.output assert "work" in result.output
assert "default-session" in result.output assert "default-session" in result.output
assert "work-session" in result.output assert "work-session" in result.output
def test_session_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "session.list"
assert remote == "browser-host.example:8765"
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["session", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
assert "main-session" in result.output
assert "work-session" in result.output
def test_session_list_with_explicit_browser_does_not_show_browser_column(): def test_session_list_with_explicit_browser_does_not_show_browser_column():
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.5" version = "0.15.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },