From 8dece7800f26bfa7ec40682fcd87f4203bb64321 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 18 Jun 2026 00:52:04 +0200 Subject: [PATCH] feat: group multi-browser output by source - 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. --- README.md | 112 ++++++------ browser_cli/commands/__init__.py | 108 +++++++----- browser_cli/commands/clients.py | 33 +++- browser_cli/commands/groups.py | 32 +--- browser_cli/commands/remote.py | 26 ++- browser_cli/commands/rendering/__init__.py | 2 + browser_cli/commands/rendering/common.py | 53 ++++++ browser_cli/commands/session.py | 24 ++- browser_cli/commands/tabs.py | 11 +- browser_cli/commands/windows.py | 11 +- browser_cli/models.py | 1 + browser_cli/sdk/factories.py | 195 +++++++++++---------- browser_cli/sdk/routing.py | 7 +- extension/manifest.json | 2 +- justfile | 12 ++ pyproject.toml | 2 +- tests/test_api.py | 41 ++++- tests/test_cli.py | 136 +++++++++++++- uv.lock | 2 +- 19 files changed, 540 insertions(+), 270 deletions(-) diff --git a/README.md b/README.md index b2269d6..4f1b566 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # 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 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 ``` -terminal / python script +terminal / python script / remote client │ │ 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) │ @@ -33,7 +34,7 @@ terminal / python script 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. -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** @@ -53,7 +54,7 @@ Every response: **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox ### Install with uv -Once published on PyPI, install the CLI as a uv tool: +Install the CLI from PyPI as a uv tool: ```sh 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 browser-cli/ ├── browser_cli/ -│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point -│ ├── cli.py # Click CLI entry point -│ ├── client/ # Client-side command routing used by CLI and SDK -│ │ ├── core.py # send_command and remote command routing -│ │ ├── targets.py # Browser target discovery and socket resolution -│ │ ├── auth.py # Remote auth fields and key lookup -│ │ └── messages.py # Request/response helpers -│ ├── models.py # Tab and Group helper models -│ ├── native/ # Native messaging host internals -│ │ ├── host.py # Browser-launched native host entry point -│ │ ├── local_server.py # Local CLI IPC server -│ │ └── protocol.py # Chrome Native Messaging framing -│ ├── remote/ # Client-side remote browser support -│ │ ├── 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 +│ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring +│ ├── async_sdk.py # AsyncBrowserCLI +│ ├── cli.py # Click root command and native-host entry point +│ ├── client/ # send_command path, local/remote routing, message helpers +│ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ... +│ ├── commands/ # CLI presentation layer over the SDK namespaces +│ ├── native/ # Browser-launched Native Messaging host + local IPC server +│ ├── remote/ # TCP remote client transport and saved endpoint registry +│ ├── serve/ # Authenticated TCP server runtime +│ ├── transport/ # JSON/msgpack response encoding and compression helpers +│ ├── markdown/ # HTML-to-Markdown extraction helpers +│ ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX +│ └── models.py # Tab, Group, BrowserCounts dataclasses ├── extension/ -│ ├── manifest.json # MV3 extension manifest -│ ├── content.js # Content-script helpers -│ └── src/ # TypeScript source split by command area -│ ├── index.ts # Builds generated extension/background.js -│ └── content/ # Builds generated extension/content-dispatch.js -├── examples/ -│ ├── demo.py # Python SDK walkthrough -│ └── demo.sh # Bash CLI walkthrough -├── tests/ -│ ├── conftest.py # shared pytest fixtures -│ ├── test_api.py -│ ├── test_cli.py -│ ├── test_dom.py -│ ├── 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 +│ ├── manifest.json # Chromium MV3 manifest +│ └── src/ # TypeScript WebExtension source +│ ├── index.ts # Background/service-worker bundle entry +│ ├── content-dispatch.ts +│ ├── commands/ # Browser-side command implementations +│ ├── content/ # DOM/extract/Markdown logic injected into pages +│ └── core/ # Shared extension helpers +├── examples/ # Python and shell walkthroughs +├── scripts/ # Packaging and release helper scripts +├── tests/ # pytest suite +├── package.json # Extension build/test/package scripts +├── pyproject.toml # Python package metadata +└── uv.lock # locked Python dependencies ``` --- ## CLI reference -All commands are run with `uv run browser-cli [--browser ALIAS] `. +During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] `. 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 `. 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 `. 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. @@ -315,6 +296,27 @@ browser-cli completion zsh # print setup instructions 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 " 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 @@ -325,7 +327,7 @@ from browser_cli import AsyncBrowserCLI, 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 # Navigation ── b.nav @@ -480,6 +482,7 @@ counts = b.tabs.count() if isinstance(counts, BrowserCounts): print(counts.total) 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: ```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:webstore # Chrome Web Store zip, strips manifest.key npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads diff --git a/browser_cli/commands/__init__.py b/browser_cli/commands/__init__.py index e6a6b2f..a409d34 100644 --- a/browser_cli/commands/__init__.py +++ b/browser_cli/commands/__init__.py @@ -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 diff --git a/browser_cli/commands/clients.py b/browser_cli/commands/clients.py index 20235a7..9c8f36b 100644 --- a/browser_cli/commands/clients.py +++ b/browser_cli/commands/clients.py @@ -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", ""), diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index 0918b42..0f7ef3d 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -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(): diff --git a/browser_cli/commands/remote.py b/browser_cli/commands/remote.py index 7fb0fdd..5a8c2ec 100644 --- a/browser_cli/commands/remote.py +++ b/browser_cli/commands/remote.py @@ -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") diff --git a/browser_cli/commands/rendering/__init__.py b/browser_cli/commands/rendering/__init__.py index 624cd6a..d70a1a1 100644 --- a/browser_cli/commands/rendering/__init__.py +++ b/browser_cli/commands/rendering/__init__.py @@ -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", diff --git a/browser_cli/commands/rendering/common.py b/browser_cli/commands/rendering/common.py index 5f4ba36..5a5de54 100644 --- a/browser_cli/commands/rendering/common.py +++ b/browser_cli/commands/rendering/common.py @@ -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) diff --git a/browser_cli/commands/session.py b/browser_cli/commands/session.py index 7312b82..7e1ebac 100644 --- a/browser_cli/commands/session.py +++ b/browser_cli/commands/session.py @@ -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") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index ba3ff19..4fd76b2 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -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(): diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py index 4f3367c..adf0438 100644 --- a/browser_cli/commands/windows.py +++ b/browser_cli/commands/windows.py @@ -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(): diff --git a/browser_cli/models.py b/browser_cli/models.py index 34eb7b2..98d1d8c 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -31,6 +31,7 @@ class BrowserCounts: """Aggregated per-browser counts returned in implicit multi-browser mode.""" total: int by_browser: dict[str, int] + browser_groups: dict[str, str] = field(default_factory=dict) # ── Tab ─────────────────────────────────────────────────────────────────────── diff --git a/browser_cli/sdk/factories.py b/browser_cli/sdk/factories.py index 244afc5..f285909 100644 --- a/browser_cli/sdk/factories.py +++ b/browser_cli/sdk/factories.py @@ -11,107 +11,114 @@ from typing import Any, Protocol, cast 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): - _key: str | None + _key: str | None class FactoryMixin: - """Turn raw response dicts into bound ``Tab``/``Group`` objects. + """Turn raw response dicts into bound ``Tab``/``Group`` objects. - Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing - ``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``. - """ + Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing + ``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``. + """ - def tab_from( - self, - data: dict, - *, - browser_profile: str | None = None, - browser_name: str | None = None, - browser_remote: str | None = None, - browser_type: str | None = None, - browser_group: str | None = None, - ) -> Tab: - tab = Tab( - id=data["id"], - window_id=data.get("windowId", 0), - active=data.get("active", False), - muted=data.get("muted", False), - title=data.get("title") or "", - url=data.get("url") or "", - group_id=data.get("groupId") or None, - index=data.get("index", 0) or 0, - browser=browser_name, - browser_name=browser_type, - browser_group=browser_group, - ) - client = cast(_FactoryClient, self) - tab._browser = self if browser_profile is None else cast(Any, type(self))( - browser=browser_profile, - remote=browser_remote, - key=client._key, - _command_sender=getattr(self, "_command_sender", None), - ) - return tab + def tab_from( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, + ) -> Tab: + tab = Tab( + id=data["id"], + window_id=data.get("windowId", 0), + active=data.get("active", False), + muted=data.get("muted", False), + title=data.get("title") or "", + url=data.get("url") or "", + group_id=data.get("groupId") or None, + index=data.get("index", 0) or 0, + browser=browser_name, + browser_name=browser_type, + browser_group=browser_group, + ) + client = cast(_FactoryClient, self) + tab._browser = self if browser_profile is None else cast(Any, type(self))( + browser=browser_profile, + remote=browser_remote, + key=client._key, + _command_sender=getattr(self, "_command_sender", None), + ) + return tab - def require_tab_response(self, data, error: str) -> Tab: - """Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``.""" - if not isinstance(data, dict) or "id" not in data: - raise RuntimeError(error) - return self.tab_from(data) + def require_tab_response(self, data, error: str) -> Tab: + """Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``.""" + if not isinstance(data, dict) or "id" not in data: + raise RuntimeError(error) + return self.tab_from(data) - def group_from( - self, - data: dict, - *, - browser_profile: str | None = None, - browser_name: str | None = None, - browser_remote: str | None = None, - browser_type: str | None = None, - browser_group: str | None = None, - ) -> Group: - group = Group( - id=data["id"], - title=data.get("title") or "", - color=data.get("color") or "", - collapsed=data.get("collapsed", False), - tab_count=data.get("tabCount", 0), - window_id=data.get("windowId"), - browser=browser_name, - browser_name=browser_type, - browser_group=browser_group, - ) - client = cast(_FactoryClient, self) - group._browser = self if browser_profile is None else cast(Any, type(self))( - browser=browser_profile, - remote=browser_remote, - key=client._key, - _command_sender=getattr(self, "_command_sender", None), - ) - return group + def group_from( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, + ) -> Group: + group = Group( + id=data["id"], + title=data.get("title") or "", + color=data.get("color") or "", + collapsed=data.get("collapsed", False), + tab_count=data.get("tabCount", 0), + window_id=data.get("windowId"), + browser=browser_name, + browser_name=browser_type, + browser_group=browser_group, + ) + client = cast(_FactoryClient, self) + group._browser = self if browser_profile is None else cast(Any, type(self))( + browser=browser_profile, + remote=browser_remote, + key=client._key, + _command_sender=getattr(self, "_command_sender", None), + ) + return group - def tab_from_target(self, data: dict, target) -> Tab: - """Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local).""" - return self.tab_from( - data, - browser_profile=target.profile if target else None, - browser_name=target.display_name if target else None, - browser_remote=target.remote 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, - ) + def tab_from_target(self, data: dict, target) -> Tab: + """Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local).""" + return self.tab_from( + data, + browser_profile=target.profile if target else None, + browser_name=target.display_name if target else None, + browser_remote=target.remote if target else None, + browser_type=getattr(target, "browser_name", None) if target else None, + browser_group=_target_group(target), + ) - def group_from_target(self, data: dict, target) -> Group: - """Build a Group, tagging it with *target* in multi-browser mode (``None`` = local).""" - return self.group_from( - data, - browser_profile=target.profile if target else None, - browser_name=target.display_name if target else None, - browser_remote=target.remote 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, - ) + def group_from_target(self, data: dict, target) -> Group: + """Build a Group, tagging it with *target* in multi-browser mode (``None`` = local).""" + return self.group_from( + data, + browser_profile=target.profile if target else None, + browser_name=target.display_name if target else None, + browser_remote=target.remote if target else None, + browser_type=getattr(target, "browser_name", None) if target else None, + browser_group=_target_group(target), + ) - @staticmethod - def tag_browser(item: dict, target) -> dict: - """Return *item* as-is locally, or with a ``browser`` key in multi-browser mode.""" - return item if target is None else {**item, "browser": target.display_name} + @staticmethod + def tag_browser(item: dict, target) -> dict: + """Return *item* as-is locally, or with browser metadata in multi-browser mode.""" + if target is None: + return item + return {**item, "browser": target.display_name, "browserGroup": _target_group(target)} diff --git a/browser_cli/sdk/routing.py b/browser_cli/sdk/routing.py index 6da2354..3b863b3 100644 --- a/browser_cli/sdk/routing.py +++ b/browser_cli/sdk/routing.py @@ -126,7 +126,12 @@ class RoutingMixin: if not multi_results: return self._client.dispatch(command, args or {}) 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): """List command, flattening per-browser results in multi-browser mode. diff --git a/extension/manifest.json b/extension/manifest.json index 8155351..f13d26f 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.15.5", + "version": "0.15.6", "description": "Control your browser from the terminal or Python SDK", "browser_specific_settings": { "gecko": { diff --git a/justfile b/justfile index e78a41f..df56b19 100644 --- a/justfile +++ b/justfile @@ -74,6 +74,18 @@ version-check: 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 +# 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 ────────────────────────────────────────────────────────────── # Run the Python SDK demo diff --git a/pyproject.toml b/pyproject.toml index 1f27c34..7d322f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "real-browser-cli" -version = "0.15.5" +version = "0.15.6" description = "Control your real running browser from the terminal or Python SDK" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_api.py b/tests/test_api.py index 0d8718c..9cefafc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -559,12 +559,37 @@ class TestTabs: mock_send.side_effect = [3, 4] 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 == [ call("tabs.count", {"pattern": "github"}, profile="default"), 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): mock_send.return_value = [TAB_DATA] result = b.tabs.query("example") @@ -713,7 +738,11 @@ class TestGroups: mock_send.side_effect = [2, 5] 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): mock_send.return_value = [GROUP_DATA] @@ -772,8 +801,8 @@ class TestWindows: result = b.windows.list() assert result == [ - {"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"}, - {"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"}, + {"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"}, + {"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"}, ] def test_windows_open_without_url(self, b, mock_send): @@ -907,8 +936,8 @@ class TestSession: result = b.session.list() assert result == [ - {"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"}, - {"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"}, + {"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"}, + {"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"}, ] assert mock_send.call_args_list == [ call("session.list", {}, profile="default"), diff --git a/tests/test_cli.py b/tests/test_cli.py index ea7d45f..8cfbe24 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.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): 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 active_targets.assert_called_once_with(suppress_pq_warning=True) + assert "local" 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 "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 "Browser" in result.output + assert "local" in result.output assert "550e8400-e29b-41d4-a716-446655440000" 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(): with patch( "browser_cli.active_browser_targets", @@ -495,9 +529,36 @@ def test_tabs_count_multi_browser_shows_total(): assert result.exit_code == 0 assert "Browser" in result.output + assert "local" in result.output assert "Total" 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(): counts = {"default": 1, "work": 2} @@ -519,6 +580,29 @@ def test_group_count_multi_browser_shows_total(): assert "Total" 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(): with patch( "browser_cli.send_command", @@ -567,10 +651,34 @@ def test_windows_list_multi_browser_shows_browser_column(): assert result.exit_code == 0 assert "Browser" in result.output + assert "local" in result.output assert "Focused" not in result.output assert "uuid-1" 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 fake_send_command(command, args=None, profile=None): assert command == "session.list" @@ -587,11 +695,37 @@ def test_session_list_multi_browser_shows_browser_column(): assert result.exit_code == 0 assert "Browser" in result.output + assert "local" in result.output assert "uuid-1" in result.output assert "work" in result.output assert "default-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(): with patch( "browser_cli.active_browser_targets", diff --git a/uv.lock b/uv.lock index a69b2ee..572b8da 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.15.5" +version = "0.15.6" source = { editable = "." } dependencies = [ { name = "click" },