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
+95 -12
View File
@@ -17,11 +17,19 @@ Usage:
b = BrowserCLI(browser="brave")
"""
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
__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:
@@ -36,9 +44,35 @@ class BrowserCLI:
def _cmd(self, command: str, args: dict | None = None):
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 ────────────────────────────────────────────────
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(
id=data["id"],
window_id=data.get("windowId", 0),
@@ -46,19 +80,21 @@ class BrowserCLI:
title=data.get("title") or "",
url=data.get("url") or "",
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
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(
id=data["id"],
title=data.get("title") or "",
color=data.get("color") or "",
collapsed=data.get("collapsed", False),
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
# ── Navigation ────────────────────────────────────────────────────────
@@ -99,7 +135,18 @@ class BrowserCLI:
# ── Tabs ──────────────────────────────────────────────────────────────
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 [])]
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._apply_tab_filter(pattern_or_filter)
def tabs_count(self, pattern: str | None = None) -> int:
"""Count open tabs, optionally filtered by URL pattern."""
def tabs_count(self, pattern: str | None = None) -> int | BrowserCounts:
"""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})
def tabs_query(self, search: str) -> list[Tab]:
@@ -166,15 +220,33 @@ class BrowserCLI:
# ── Tab Groups ────────────────────────────────────────────────────────
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 [])]
def group_tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group."""
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
def group_count(self) -> int:
"""Return the number of tab groups."""
def group_count(self) -> int | BrowserCounts:
"""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", {})
def group_query(self, search: str) -> list[Group]:
@@ -202,6 +274,17 @@ class BrowserCLI:
# ── Windows ───────────────────────────────────────────────────────────
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", {})
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.session import session_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()
@@ -85,13 +85,6 @@ def _print_version(ctx, param, value):
click.echo(_project_version())
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.option(
"-V", "--version",
@@ -109,6 +102,8 @@ def _client_display_profile(profile_name: str, sock_path: str) -> str:
def main(ctx, browser):
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser
@@ -129,20 +124,16 @@ main.add_command(search_group)
@main.command("clients")
def cmd_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] = {}
if REGISTRY_PATH.exists():
try:
profiles = _json.loads(REGISTRY_PATH.read_text())
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
pass
all_clients = []
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:
result = send_command("clients.list", profile=profile_name)
for c in (result or []):
+42 -17
View File
@@ -13,6 +13,7 @@ import os
import socket
import struct
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -24,11 +25,37 @@ class BrowserNotConnected(Exception):
"""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:
"""Return only entries whose socket file exists on disk."""
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:
"""Return the socket path for the given profile (or auto-detect)."""
target = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -45,23 +72,21 @@ def _resolve_socket(profile: str | None = None) -> str:
return str(SOCKET_DIR / f"{safe}.sock")
# Auto-detect: error when multiple browser instances are active
if REGISTRY_PATH.exists():
try:
reg = json.loads(REGISTRY_PATH.read_text())
active = _active_sockets(reg)
if len(active) > 1:
aliases = list(active.keys())
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
raise BrowserNotConnected(
f"Multiple browser instances are active: {', '.join(aliases)}\n"
f"Use --browser <alias> to select one:\n{examples}"
)
if active:
return next(iter(active.values()))
except BrowserNotConnected:
raise
except Exception:
pass
try:
active = active_browser_targets()
if len(active) > 1:
aliases = [target.profile for target in active]
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
raise BrowserNotConnected(
f"Multiple browser instances are active: {', '.join(aliases)}\n"
f"Use --browser <alias> to select one:\n{examples}"
)
if active:
return active[0].socket_path
except BrowserNotConnected:
raise
except Exception:
pass
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
+62 -7
View File
@@ -1,14 +1,14 @@
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.table import Table
console = Console()
def _handle(command, args=None):
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {})
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
@@ -17,24 +17,45 @@ def _handle(command, args=None):
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:
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:
table.add_row(
row = [
g.get("browser", "") if show_browser else None,
str(g.get("id", "")),
g.get("title") or "(unnamed)",
g.get("title") or "",
g.get("color") or "",
"yes" if g.get("collapsed") else "no",
str(g.get("tabCount", "")),
)
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@@ -46,6 +67,19 @@ def group_group():
@group_group.command("list")
def group_list():
"""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")
_print_groups(groups or [])
@@ -62,6 +96,27 @@ def group_tabs(group_id):
@group_group.command("count")
def group_count():
"""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")
console.print(f"[bold]{count}[/bold] group(s)")
+61 -6
View File
@@ -1,14 +1,14 @@
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.table import Table
console = Console()
def _handle(command, args=None):
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {})
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
@@ -17,11 +17,30 @@ def _handle(command, args=None):
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:
console.print("[yellow]No tabs found[/yellow]")
return
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("Window", no_wrap=True)
table.add_column("Active", width=7)
@@ -29,13 +48,15 @@ def _print_tabs(tabs: list[dict]) -> None:
table.add_column("URL")
for t in tabs:
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("windowId", "")),
active,
(t.get("title") or "")[:60],
(t.get("url") or "")[:80],
)
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@@ -47,6 +68,19 @@ def tabs_group():
@tabs_group.command("list")
def tabs_list():
"""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")
_print_tabs(tabs or [])
@@ -98,6 +132,27 @@ def tabs_filter(pattern):
@click.argument("pattern", required=False)
def tabs_count(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})
label = f" matching '{pattern}'" if pattern else ""
console.print(f"[bold]{count}[/bold] tab(s){label}")
+40 -9
View File
@@ -1,14 +1,14 @@
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.table import Table
console = Console()
def _handle(command, args=None):
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {})
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
@@ -17,25 +17,43 @@ def _handle(command, args=None):
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:
console.print("[yellow]No windows 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("Alias", width=20)
table.add_column("Focused", width=8)
table.add_column("Tabs", width=6)
table.add_column("State", width=12)
for w in windows:
focused = "[green]✓[/green]" if w.get("focused") else ""
table.add_row(
row = [
w.get("browser", "") if show_browser else None,
str(w.get("id", "")),
w.get("alias") or "",
focused,
str(w.get("tabCount", "")),
w.get("state") or "",
)
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@@ -47,6 +65,19 @@ def windows_group():
@windows_group.command("list")
def windows_list():
"""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")
_print_windows(windows or [])
+2
View File
@@ -32,6 +32,7 @@ class Tab:
title: str
url: str
group_id: int | None = None
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI:
@@ -103,6 +104,7 @@ class Group:
color: str
collapsed: bool
tab_count: int
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI: