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
+61 -47
View File
@@ -22,60 +22,74 @@ tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab I
def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result.
"""Render a count result.
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items():
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser", no_wrap=True)
table.add_column(f"{noun.capitalize()}s", justify="right")
rendered_groups: set[str] = set()
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("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options.
"""Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
return wrapper
return wrapper
+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:
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*."""
if quiet_remote_warning:
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)
for c in (result or []):
c["profile"] = label
if profile_group:
c["profileGroup"] = profile_group
into.append(c)
@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)
for target in targets:
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):
continue
@@ -109,10 +118,11 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
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):
all_clients.append({
"profile": display_profile,
"profileGroup": "local",
"name": "",
"version": "",
"extensionVersion": "disconnected",
@@ -130,6 +140,7 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profile=target.profile,
remote=target.remote,
quiet_remote_warning=True,
profile_group=target.display_group,
)
except (BrowserNotConnected, RuntimeError):
continue
@@ -137,13 +148,25 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
def _print_clients(all_clients: list) -> None:
from rich.table import Table
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("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:
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(
c.get("profile", ""),
profile,
c.get("name", ""),
c.get("version", ""),
c.get("extensionVersion", ""),
+9 -23
View File
@@ -1,33 +1,19 @@
import click
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.table import Table
console = Console()
def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
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])
console.print(table)
columns = [
("ID", lambda g: g.id),
("Name", lambda g: g.title or ""),
("Color", lambda g: g.color or ""),
("Collapsed", lambda g: "yes" if g.collapsed else "no"),
("Tabs", lambda g: g.tab_count),
]
print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
@click.group("groups")
def group_group():
+18 -8
View File
@@ -8,6 +8,7 @@ from rich.table import Table
from browser_cli import BrowserCLI
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
console = Console()
@@ -23,14 +24,23 @@ def remote_group():
def remote_status(endpoint, key):
"""Probe a remote endpoint and show server/client status."""
client = BrowserCLI(remote=endpoint, key=key)
clients = client.clients()
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Extension")
for item in clients:
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
console.print(table)
clients = [
{**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
for item in client.clients()
]
columns = [
("Browser", lambda item: item.get("name", "")),
("Extension", lambda item: item.get("extensionVersion", "")),
]
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")
@click.argument("endpoint")
@@ -2,6 +2,7 @@
from browser_cli.commands.rendering.common import (
Column,
item_value,
print_browser_grouped_table_rows,
print_table_rows,
print_tree,
shorten,
@@ -44,6 +45,7 @@ __all__ = [
"group_tree_label",
"item_value",
"no_wrap_text",
"print_browser_grouped_table_rows",
"print_table_rows",
"print_tree",
"scoped_browser_label",
+53
View File
@@ -82,3 +82,56 @@ def print_table_rows(
for row in rows:
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
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():
"""List all saved sessions."""
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()
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
show_browser = any("browser" in s for s in sessions)
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("Name")
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
def saved_at(session):
return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
columns = [
("Name", lambda session: session["name"]),
("Tabs", lambda session: session["tabs"]),
("Saved at", saved_at),
]
print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
@session_group.command("remove")
@click.argument("name")
+4 -7
View File
@@ -2,25 +2,22 @@ import base64
import binascii
import click
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.table import Table
console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
columns = []
if show_browser:
columns.append(("Browser", lambda tab: tab.browser or ""))
columns.extend([
columns = [
("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]),
("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")
def tabs_group():
+4 -7
View File
@@ -1,21 +1,18 @@
import click
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
console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
columns = []
if show_browser:
columns.append(("Browser", lambda window: window.get("browser", "")))
columns.extend([
columns = [
("ID", lambda window: window.get("id", "")),
("Alias", lambda window: window.get("alias") or ""),
("Tabs", lambda window: window.get("tabCount", "")),
("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")
def windows_group():