add multi browser mode to arragate data from all browsers by tabs list, tabs count, group list, group count and windows list
Package Extension / package-extension (push) Successful in 12s
Build & Publish Package / publish (push) Successful in 22s

remove (unnamed) into the group names just leave it a empty string, remove Focused on windows how should the browser know what windows are focused
This commit is contained in:
2026-04-10 12:49:51 +02:00
parent 6979f2ef30
commit 61b774a7a4
14 changed files with 578 additions and 79 deletions
+21 -5
View File
@@ -122,7 +122,7 @@ browser-cli/
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`. All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --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`, `group list`, `group count`, and `windows list` are the only commands that 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 rename-profile --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.
@@ -302,27 +302,28 @@ b.forward(tab_id=1234)
b.focus_url("github") b.focus_url("github")
# Tabs # Tabs
tabs = b.tabs_list() # list of dicts: id, windowId, title, url, active, ... tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set
b.tabs_active(1234) b.tabs_active(1234)
b.tabs_close(1234) b.tabs_close(1234)
b.tabs_close_inactive() b.tabs_close_inactive()
b.tabs_close_duplicates() b.tabs_close_duplicates()
b.tabs_filter("youtube") # list of matching tabs b.tabs_filter("youtube") # list of matching tabs
b.tabs_query("pull request") b.tabs_query("pull request")
b.tabs_count("github") # int counts = b.tabs_count("github") # int, or BrowserCounts(total=..., by_browser=...) in multi-browser mode
html = b.tabs_html() # full HTML string of active tab html = b.tabs_html() # full HTML string of active tab
b.tabs_sort(by="domain") b.tabs_sort(by="domain")
b.tabs_merge_windows() b.tabs_merge_windows()
b.tabs_dedupe() b.tabs_dedupe()
# Tab groups # Tab groups
groups = b.group_list() # list of dicts: id, title, color, collapsed, tabCount groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set
b.group_open("research") # creates group, returns { id, name } b.group_open("research") # creates group, returns { id, name }
b.group_close(42) b.group_close(42)
b.group_tabs(42) # tabs inside a group b.group_tabs(42) # tabs inside a group
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
# Windows # Windows
windows = b.windows_list() windows = b.windows_list() # in multi-browser mode each dict has a "browser" key
b.windows_rename(1, "work") b.windows_rename(1, "work")
b.windows_open() b.windows_open()
b.windows_close(1) b.windows_close(1)
@@ -368,6 +369,21 @@ except RuntimeError as e:
print(f"Browser returned an error: {e}") print(f"Browser returned an error: {e}")
``` ```
```python
from browser_cli import BrowserCLI, BrowserCounts
b = BrowserCLI()
tabs = b.tabs_list()
for tab in tabs:
print(tab.browser, tab.title)
counts = b.tabs_count()
if isinstance(counts, BrowserCounts):
print(counts.total)
print(counts.by_browser)
```
--- ---
## Example scripts ## Example scripts
+95 -12
View File
@@ -17,11 +17,19 @@ Usage:
b = BrowserCLI(browser="brave") b = BrowserCLI(browser="brave")
""" """
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass
from browser_cli.client import BrowserNotConnected, send_command from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from browser_cli.models import Group, Tab from browser_cli.models import Group, Tab
__all__ = ["BrowserCLI", "BrowserNotConnected", "Tab", "Group"] __all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
@dataclass(frozen=True)
class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
class BrowserCLI: class BrowserCLI:
@@ -36,9 +44,35 @@ class BrowserCLI:
def _cmd(self, command: str, args: dict | None = None): def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser) return send_command(command, args, profile=self._browser)
def _multi_browser_targets(self):
if self._browser is not None:
return []
targets = active_browser_targets()
if len(targets) <= 1:
return []
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
for target in self._multi_browser_targets():
try:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if self._multi_browser_targets():
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
"Make sure the browser is running with the browser-cli extension enabled,\n"
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
)
return []
# ── Internal factories ──────────────────────────────────────────────── # ── Internal factories ────────────────────────────────────────────────
def _make_tab(self, data: dict) -> Tab: def _make_tab(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Tab:
tab = Tab( tab = Tab(
id=data["id"], id=data["id"],
window_id=data.get("windowId", 0), window_id=data.get("windowId", 0),
@@ -46,19 +80,21 @@ class BrowserCLI:
title=data.get("title") or "", title=data.get("title") or "",
url=data.get("url") or "", url=data.get("url") or "",
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
browser=browser_name,
) )
tab._browser = self tab._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
return tab return tab
def _make_group(self, data: dict) -> Group: def _make_group(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Group:
group = Group( group = Group(
id=data["id"], id=data["id"],
title=data.get("title") or "", title=data.get("title") or "",
color=data.get("color") or "", color=data.get("color") or "",
collapsed=data.get("collapsed", False), collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0), tab_count=data.get("tabCount", 0),
browser=browser_name,
) )
group._browser = self group._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
return group return group
# ── Navigation ──────────────────────────────────────────────────────── # ── Navigation ────────────────────────────────────────────────────────
@@ -99,7 +135,18 @@ class BrowserCLI:
# ── Tabs ────────────────────────────────────────────────────────────── # ── Tabs ──────────────────────────────────────────────────────────────
def tabs_list(self) -> list[Tab]: def tabs_list(self) -> list[Tab]:
"""Return all open tabs across all windows.""" """Return all open tabs across all windows.
When multiple browsers are active and no browser was specified, each Tab
includes ``tab.browser`` naming its source browser.
"""
multi_results = self._collect_multi_browser("tabs.list", {})
if multi_results:
return [
self._make_tab(tab, browser_profile=target.profile, browser_name=target.display_name)
for target, tabs in multi_results
for tab in (tabs or [])
]
return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])] return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])]
def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False) -> int: def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False) -> int:
@@ -127,8 +174,15 @@ class BrowserCLI:
return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])] return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
return self._apply_tab_filter(pattern_or_filter) return self._apply_tab_filter(pattern_or_filter)
def tabs_count(self, pattern: str | None = None) -> int: def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts:
"""Count open tabs, optionally filtered by URL pattern.""" """Count open tabs, optionally filtered by URL pattern.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
multi_results = self._collect_multi_browser("tabs.count", {"pattern": pattern})
if 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)
return self._cmd("tabs.count", {"pattern": pattern}) return self._cmd("tabs.count", {"pattern": pattern})
def tabs_query(self, search: str) -> list[Tab]: def tabs_query(self, search: str) -> list[Tab]:
@@ -166,15 +220,33 @@ class BrowserCLI:
# ── Tab Groups ──────────────────────────────────────────────────────── # ── Tab Groups ────────────────────────────────────────────────────────
def group_list(self) -> list[Group]: def group_list(self) -> list[Group]:
"""Return all tab groups.""" """Return all tab groups.
When multiple browsers are active and no browser was specified, each Group
includes ``group.browser`` naming its source browser.
"""
multi_results = self._collect_multi_browser("group.list", {})
if multi_results:
return [
self._make_group(group, browser_profile=target.profile, browser_name=target.display_name)
for target, groups in multi_results
for group in (groups or [])
]
return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])] return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])]
def group_tabs(self, group_id: int) -> list[Tab]: def group_tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group.""" """Return all tabs inside a group."""
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])] return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
def group_count(self) -> int: def group_count(self) -> int | BrowserCounts:
"""Return the number of tab groups.""" """Return the number of tab groups.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
multi_results = self._collect_multi_browser("group.count", {})
if 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)
return self._cmd("group.count", {}) return self._cmd("group.count", {})
def group_query(self, search: str) -> list[Group]: def group_query(self, search: str) -> list[Group]:
@@ -202,6 +274,17 @@ class BrowserCLI:
# ── Windows ─────────────────────────────────────────────────────────── # ── Windows ───────────────────────────────────────────────────────────
def windows_list(self) -> list[dict]: def windows_list(self) -> list[dict]:
"""Return browser windows.
In implicit multi-browser mode each window dict includes a ``browser`` key.
"""
multi_results = self._collect_multi_browser("windows.list", {})
if multi_results:
return [
{**window, "browser": target.display_name}
for target, windows in multi_results
for window in (windows or [])
]
return self._cmd("windows.list", {}) return self._cmd("windows.list", {})
def windows_rename(self, window_id: int, name: str) -> None: def windows_rename(self, window_id: int, name: str) -> None:
+5 -14
View File
@@ -21,7 +21,7 @@ from browser_cli.commands.dom import dom_group
from browser_cli.commands.extract import extract_group from browser_cli.commands.extract import extract_group
from browser_cli.commands.session import session_group from browser_cli.commands.session import session_group
from browser_cli.commands.search import search_group from browser_cli.commands.search import search_group
from browser_cli.client import send_command, BrowserNotConnected from browser_cli.client import send_command, BrowserNotConnected, REGISTRY_PATH, display_browser_name
console = Console() console = Console()
@@ -85,13 +85,6 @@ def _print_version(ctx, param, value):
click.echo(_project_version()) click.echo(_project_version())
ctx.exit() ctx.exit()
def _client_display_profile(profile_name: str, sock_path: str) -> str:
if profile_name != "default":
return profile_name
return Path(sock_path).stem or profile_name
@click.group() @click.group()
@click.option( @click.option(
"-V", "--version", "-V", "--version",
@@ -109,6 +102,8 @@ def _client_display_profile(profile_name: str, sock_path: str) -> str:
def main(ctx, browser): def main(ctx, browser):
"""Control your running browser from the terminal via a Chrome extension.""" """Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
if browser: if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser os.environ["BROWSER_CLI_PROFILE"] = browser
@@ -129,20 +124,16 @@ main.add_command(search_group)
@main.command("clients") @main.command("clients")
def cmd_clients(): def cmd_clients():
"""Show connected browser clients.""" """Show connected browser clients."""
import json as _json
from browser_cli.client import REGISTRY_PATH
# Build a map of profile → socket path from the registry
profiles: dict[str, str] = {} profiles: dict[str, str] = {}
if REGISTRY_PATH.exists(): if REGISTRY_PATH.exists():
try: try:
profiles = _json.loads(REGISTRY_PATH.read_text()) profiles = json.loads(REGISTRY_PATH.read_text())
except Exception: except Exception:
pass pass
all_clients = [] all_clients = []
for profile_name, sock_path in profiles.items(): for profile_name, sock_path in profiles.items():
display_profile = _client_display_profile(profile_name, sock_path) display_profile = display_browser_name(profile_name, sock_path)
try: try:
result = send_command("clients.list", profile=profile_name) result = send_command("clients.list", profile=profile_name)
for c in (result or []): for c in (result or []):
+30 -5
View File
@@ -13,6 +13,7 @@ import os
import socket import socket
import struct import struct
import uuid import uuid
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -24,11 +25,37 @@ class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available.""" """Raised when the native host socket is not available."""
@dataclass(frozen=True)
class BrowserTarget:
profile: str
display_name: str
socket_path: str
def _active_sockets(reg: dict) -> dict: def _active_sockets(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk.""" """Return only entries whose socket file exists on disk."""
return {k: v for k, v in reg.items() if Path(v).exists()} return {k: v for k, v in reg.items() if Path(v).exists()}
def display_browser_name(profile_name: str, sock_path: str) -> str:
if profile_name != "default":
return profile_name
return Path(sock_path).stem or profile_name
def active_browser_targets() -> list[BrowserTarget]:
if not REGISTRY_PATH.exists():
return []
try:
reg = json.loads(REGISTRY_PATH.read_text())
except Exception:
return []
return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_sockets(reg).items()
]
def _resolve_socket(profile: str | None = None) -> str: def _resolve_socket(profile: str | None = None) -> str:
"""Return the socket path for the given profile (or auto-detect).""" """Return the socket path for the given profile (or auto-detect)."""
target = profile or os.environ.get("BROWSER_CLI_PROFILE") target = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -45,19 +72,17 @@ def _resolve_socket(profile: str | None = None) -> str:
return str(SOCKET_DIR / f"{safe}.sock") return str(SOCKET_DIR / f"{safe}.sock")
# Auto-detect: error when multiple browser instances are active # Auto-detect: error when multiple browser instances are active
if REGISTRY_PATH.exists():
try: try:
reg = json.loads(REGISTRY_PATH.read_text()) active = active_browser_targets()
active = _active_sockets(reg)
if len(active) > 1: if len(active) > 1:
aliases = list(active.keys()) aliases = [target.profile for target in active]
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
raise BrowserNotConnected( raise BrowserNotConnected(
f"Multiple browser instances are active: {', '.join(aliases)}\n" f"Multiple browser instances are active: {', '.join(aliases)}\n"
f"Use --browser <alias> to select one:\n{examples}" f"Use --browser <alias> to select one:\n{examples}"
) )
if active: if active:
return next(iter(active.values())) return active[0].socket_path
except BrowserNotConnected: except BrowserNotConnected:
raise raise
except Exception: except Exception:
+62 -7
View File
@@ -1,14 +1,14 @@
import click import click
from browser_cli.client import send_command, BrowserNotConnected from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
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 _handle(command, args=None): def _handle(command, args=None, profile=None):
try: try:
return send_command(command, args or {}) return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e: except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}") console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
@@ -17,24 +17,45 @@ def _handle(command, args=None):
raise SystemExit(1) raise SystemExit(1)
def _print_groups(groups: list[dict]) -> None: def _handle_multi(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
return []
return targets
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
if not groups: if not groups:
console.print("[yellow]No groups found[/yellow]") console.print("[yellow]No groups found[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") 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("ID", style="dim", no_wrap=True)
table.add_column("Name") table.add_column("Name")
table.add_column("Color", width=10) table.add_column("Color", width=10)
table.add_column("Collapsed", width=10) table.add_column("Collapsed", width=10)
table.add_column("Tabs", width=6) table.add_column("Tabs", width=6)
for g in groups: for g in groups:
table.add_row( row = [
g.get("browser", "") if show_browser else None,
str(g.get("id", "")), str(g.get("id", "")),
g.get("title") or "(unnamed)", g.get("title") or "",
g.get("color") or "", g.get("color") or "",
"yes" if g.get("collapsed") else "no", "yes" if g.get("collapsed") else "no",
str(g.get("tabCount", "")), str(g.get("tabCount", "")),
) ]
table.add_row(*[value for value in row if value is not None])
console.print(table) console.print(table)
@@ -46,6 +67,19 @@ def group_group():
@group_group.command("list") @group_group.command("list")
def group_list(): def group_list():
"""List all tab groups.""" """List all tab groups."""
targets = _multi_browser_targets()
if targets:
groups = []
for target in targets:
result = _handle_multi("group.list", profile=target.profile)
if result is None:
continue
groups.extend({**group, "browser": target.display_name} for group in result)
if not groups:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_groups(groups, show_browser=True)
return
groups = _handle("group.list") groups = _handle("group.list")
_print_groups(groups or []) _print_groups(groups or [])
@@ -62,6 +96,27 @@ def group_tabs(group_id):
@group_group.command("count") @group_group.command("count")
def group_count(): def group_count():
"""Count all tab groups.""" """Count all tab groups."""
targets = _multi_browser_targets()
if targets:
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column("Groups", justify="right")
total = 0
rows = 0
for target in targets:
count = _handle_multi("group.count", profile=target.profile)
if count is None:
continue
count = int(count or 0)
total += count
rows += 1
table.add_row(target.display_name, str(count))
if rows == 0:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
table.add_row("Total", str(total))
console.print(table)
return
count = _handle("group.count") count = _handle("group.count")
console.print(f"[bold]{count}[/bold] group(s)") console.print(f"[bold]{count}[/bold] group(s)")
+61 -6
View File
@@ -1,14 +1,14 @@
import click import click
from browser_cli.client import send_command, BrowserNotConnected from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
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 _handle(command, args=None): def _handle(command, args=None, profile=None):
try: try:
return send_command(command, args or {}) return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e: except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}") console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
@@ -17,11 +17,30 @@ def _handle(command, args=None):
raise SystemExit(1) raise SystemExit(1)
def _print_tabs(tabs: list[dict]) -> None: def _handle_multi(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
return []
return targets
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
if not tabs: if not tabs:
console.print("[yellow]No tabs found[/yellow]") console.print("[yellow]No tabs found[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser", no_wrap=True)
table.add_column("ID", style="dim", no_wrap=True) table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True) table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7) table.add_column("Active", width=7)
@@ -29,13 +48,15 @@ def _print_tabs(tabs: list[dict]) -> None:
table.add_column("URL") table.add_column("URL")
for t in tabs: for t in tabs:
active = "[green]✓[/green]" if t.get("active") else "" active = "[green]✓[/green]" if t.get("active") else ""
table.add_row( row = [
t.get("browser", "") if show_browser else None,
str(t.get("id", "")), str(t.get("id", "")),
str(t.get("windowId", "")), str(t.get("windowId", "")),
active, active,
(t.get("title") or "")[:60], (t.get("title") or "")[:60],
(t.get("url") or "")[:80], (t.get("url") or "")[:80],
) ]
table.add_row(*[value for value in row if value is not None])
console.print(table) console.print(table)
@@ -47,6 +68,19 @@ def tabs_group():
@tabs_group.command("list") @tabs_group.command("list")
def tabs_list(): def tabs_list():
"""List all open tabs across all windows.""" """List all open tabs across all windows."""
targets = _multi_browser_targets()
if targets:
tabs = []
for target in targets:
result = _handle_multi("tabs.list", profile=target.profile)
if result is None:
continue
tabs.extend({**tab, "browser": target.display_name} for tab in result)
if not tabs:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_tabs(tabs, show_browser=True)
return
tabs = _handle("tabs.list") tabs = _handle("tabs.list")
_print_tabs(tabs or []) _print_tabs(tabs or [])
@@ -98,6 +132,27 @@ def tabs_filter(pattern):
@click.argument("pattern", required=False) @click.argument("pattern", required=False)
def tabs_count(pattern): def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN.""" """Count open tabs, optionally filtered by URL PATTERN."""
targets = _multi_browser_targets()
if targets:
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column("Tabs", justify="right")
total = 0
rows = 0
for target in targets:
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile)
if count is None:
continue
count = int(count or 0)
total += count
rows += 1
table.add_row(target.display_name, str(count))
if rows == 0:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
table.add_row("Total", str(total))
console.print(table)
return
count = _handle("tabs.count", {"pattern": pattern}) count = _handle("tabs.count", {"pattern": pattern})
label = f" matching '{pattern}'" if pattern else "" label = f" matching '{pattern}'" if pattern else ""
console.print(f"[bold]{count}[/bold] tab(s){label}") console.print(f"[bold]{count}[/bold] tab(s){label}")
+40 -9
View File
@@ -1,14 +1,14 @@
import click import click
from browser_cli.client import send_command, BrowserNotConnected from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
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 _handle(command, args=None): def _handle(command, args=None, profile=None):
try: try:
return send_command(command, args or {}) return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e: except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}") console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
@@ -17,25 +17,43 @@ def _handle(command, args=None):
raise SystemExit(1) raise SystemExit(1)
def _print_windows(windows: list[dict]) -> None: def _handle_multi(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
return []
return targets
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows: if not windows:
console.print("[yellow]No windows found[/yellow]") console.print("[yellow]No windows found[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") 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("ID", style="dim", no_wrap=True)
table.add_column("Alias", width=20) table.add_column("Alias", width=20)
table.add_column("Focused", width=8)
table.add_column("Tabs", width=6) table.add_column("Tabs", width=6)
table.add_column("State", width=12) table.add_column("State", width=12)
for w in windows: for w in windows:
focused = "[green]✓[/green]" if w.get("focused") else "" row = [
table.add_row( w.get("browser", "") if show_browser else None,
str(w.get("id", "")), str(w.get("id", "")),
w.get("alias") or "", w.get("alias") or "",
focused,
str(w.get("tabCount", "")), str(w.get("tabCount", "")),
w.get("state") or "", w.get("state") or "",
) ]
table.add_row(*[value for value in row if value is not None])
console.print(table) console.print(table)
@@ -47,6 +65,19 @@ def windows_group():
@windows_group.command("list") @windows_group.command("list")
def windows_list(): def windows_list():
"""List all browser windows.""" """List all browser windows."""
targets = _multi_browser_targets()
if targets:
windows = []
for target in targets:
result = _handle_multi("windows.list", profile=target.profile)
if result is None:
continue
windows.extend({**window, "browser": target.display_name} for window in result)
if not windows:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
_print_windows(windows, show_browser=True)
return
windows = _handle("windows.list") windows = _handle("windows.list")
_print_windows(windows or []) _print_windows(windows or [])
+2
View File
@@ -32,6 +32,7 @@ class Tab:
title: str title: str
url: str url: str
group_id: int | None = None group_id: int | None = None
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI: def _b(self) -> BrowserCLI:
@@ -103,6 +104,7 @@ class Group:
color: str color: str
collapsed: bool collapsed: bool
tab_count: int tab_count: int
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI: def _b(self) -> BrowserCLI:
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.5.2", "version": "0.5.4",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.5.2" version = "0.5.4"
description = "Control your real running browser from the terminal via a Chrome extension" description = "Control your real running browser from the terminal via a Chrome extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+104 -3
View File
@@ -5,8 +5,8 @@ These tests mock `send_command` so no live browser connection is required.
import pytest import pytest
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch, call
from browser_cli import BrowserCLI, Tab, Group from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
from browser_cli.client import BrowserNotConnected from browser_cli.client import BrowserNotConnected, BrowserTarget
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -36,7 +36,7 @@ def mock_send():
BrowserCLI._cmd calls the `send_command` name that was imported into BrowserCLI._cmd calls the `send_command` name that was imported into
browser_cli/__init__.py, so we must patch it there, not in the client module. browser_cli/__init__.py, so we must patch it there, not in the client module.
""" """
with patch("browser_cli.send_command") as m: with patch("browser_cli.send_command") as m, patch("browser_cli.active_browser_targets", return_value=[]):
yield m yield m
@@ -268,6 +268,48 @@ class TestTabs:
mock_send.return_value = 5 mock_send.return_value = 5
assert b.tabs_count() == 5 assert b.tabs_count() == 5
def test_tabs_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[TAB_DATA],
[{**TAB_DATA, "id": 11}],
None,
]
tabs = b.tabs_list()
tabs[1].close()
assert [tab.browser for tab in tabs] == ["uuid-1", "work"]
assert [tab.id for tab in tabs] == [10, 11]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work"),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [3, 4]
result = b.tabs_count("github")
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4})
assert mock_send.call_args_list == [
call("tabs.count", {"pattern": "github"}, profile="default"),
call("tabs.count", {"pattern": "github"}, profile="work"),
]
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")
@@ -330,6 +372,44 @@ class TestGroups:
mock_send.return_value = 7 mock_send.return_value = 7
assert b.group_count() == 7 assert b.group_count() == 7
def test_group_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[GROUP_DATA],
[{**GROUP_DATA, "id": 99, "title": "Later"}],
None,
]
groups = b.group_list()
groups[1].close()
assert [group.browser for group in groups] == ["uuid-1", "work"]
assert [group.id for group in groups] == [42, 99]
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work"),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [2, 5]
result = b.group_count()
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5})
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]
groups = b.group_query("Work") groups = b.group_query("Work")
@@ -371,6 +451,27 @@ class TestGroups:
) )
class TestWindows:
def test_windows_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[{"id": 1, "tabCount": 2, "state": "normal"}],
[{"id": 2, "tabCount": 3, "state": "maximized"}],
]
result = b.windows_list()
assert result == [
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"},
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"},
]
# ── Tab model ───────────────────────────────────────────────────────────────── # ── Tab model ─────────────────────────────────────────────────────────────────
class TestTabModel: class TestTabModel:
+120 -2
View File
@@ -4,6 +4,7 @@ from click.testing import CliRunner
from unittest.mock import patch from unittest.mock import patch
from browser_cli.cli import main, _project_version from browser_cli.cli import main, _project_version
from browser_cli.client import BrowserTarget
def _expected_version() -> str: def _expected_version() -> str:
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
@@ -50,7 +51,7 @@ def test_install_help_lists_supported_browsers():
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_clients_exits_cleanly_when_registry_is_missing(): def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 1 assert result.exit_code == 1
@@ -74,7 +75,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
assert command == "clients.list" assert command == "clients.list"
return responses[profile] return responses[profile]
with patch("browser_cli.client.REGISTRY_PATH", registry_path), patch( with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
"browser_cli.cli.send_command", side_effect=fake_send_command "browser_cli.cli.send_command", side_effect=fake_send_command
): ):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
@@ -85,6 +86,123 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
assert "Extension Version" in result.output assert "Extension Version" in result.output
assert "2.3.4" in result.output assert "2.3.4" in result.output
def test_tabs_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "tabs.list"
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
with patch(
"browser_cli.commands.tabs.active_browser_targets",
return_value=[
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
assert "Browser" in result.output
assert "550e8400-e29b-41d4-a716-446655440000" in result.output
assert "work" in result.output
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
with patch(
"browser_cli.commands.tabs.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
assert result.exit_code == 0
assert "Browser" not in result.output
send_command.assert_called_once_with("tabs.list", {}, profile=None)
def test_tabs_count_multi_browser_shows_total():
counts = {"default": 3, "work": 4}
def fake_send_command(command, args=None, profile=None):
assert command == "tabs.count"
assert args == {"pattern": "github"}
return counts[profile]
with patch(
"browser_cli.commands.tabs.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.tabs.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "count", "github"])
assert result.exit_code == 0
assert "Browser" in result.output
assert "Total" in result.output
assert "7" in result.output
def test_group_count_multi_browser_shows_total():
counts = {"default": 1, "work": 2}
def fake_send_command(command, args=None, profile=None):
assert command == "group.count"
return counts[profile]
with patch(
"browser_cli.commands.groups.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.groups.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["group", "count"])
assert result.exit_code == 0
assert "Browser" in result.output
assert "Total" in result.output
assert "3" in result.output
def test_group_list_leaves_unnamed_group_cell_empty():
with patch(
"browser_cli.commands.groups.send_command",
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
):
result = CliRunner().invoke(main, ["group", "list"])
assert result.exit_code == 0
assert "(unnamed)" not in result.output
assert "42" in result.output
assert "grey" in result.output
def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "windows.list"
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
with patch(
"browser_cli.commands.windows.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.windows.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["windows", "list"])
assert result.exit_code == 0
assert "Browser" in result.output
assert "Focused" not in result.output
assert "uuid-1" in result.output
assert "work" in result.output
def test_extract_markdown_command(): def test_extract_markdown_command():
with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command: with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown"]) result = CliRunner().invoke(main, ["extract", "markdown"])
+23 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
def test_resolve_socket_raises_when_registry_missing(monkeypatch): def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
@@ -38,3 +38,25 @@ def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_pat
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"): with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
_resolve_socket() _resolve_socket()
def test_display_browser_name_uses_uuid_stem_for_default():
assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == (
"550e8400-e29b-41d4-a716-446655440000"
)
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock"
active_socket.write_text("")
stale_socket = tmp_path / "stale.sock"
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].profile == "work"
assert targets[0].display_name == "work"
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.5.2" version = "0.5.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },