add multi browser mode to arragate data from all browsers by tabs list, tabs count, group list, group count and windows list
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:
@@ -122,7 +122,7 @@ browser-cli/
|
||||
|
||||
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.
|
||||
|
||||
@@ -302,27 +302,28 @@ b.forward(tab_id=1234)
|
||||
b.focus_url("github")
|
||||
|
||||
# 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_close(1234)
|
||||
b.tabs_close_inactive()
|
||||
b.tabs_close_duplicates()
|
||||
b.tabs_filter("youtube") # list of matching tabs
|
||||
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
|
||||
b.tabs_sort(by="domain")
|
||||
b.tabs_merge_windows()
|
||||
b.tabs_dedupe()
|
||||
|
||||
# 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_close(42)
|
||||
b.group_tabs(42) # tabs inside a group
|
||||
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
|
||||
|
||||
# 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_open()
|
||||
b.windows_close(1)
|
||||
@@ -368,6 +369,21 @@ except RuntimeError as 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
|
||||
|
||||
+95
-12
@@ -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
@@ -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 []):
|
||||
|
||||
+30
-5
@@ -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,19 +72,17 @@ 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)
|
||||
active = active_browser_targets()
|
||||
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)
|
||||
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()))
|
||||
return active[0].socket_path
|
||||
except BrowserNotConnected:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.4",
|
||||
"description": "Control your browser from the terminal via browser-cli",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "browser-cli"
|
||||
version = "0.5.2"
|
||||
version = "0.5.4"
|
||||
description = "Control your real running browser from the terminal via a Chrome extension"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
+104
-3
@@ -5,8 +5,8 @@ These tests mock `send_command` so no live browser connection is required.
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
from browser_cli import BrowserCLI, Tab, Group
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
|
||||
from browser_cli.client import BrowserNotConnected, BrowserTarget
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -36,7 +36,7 @@ def mock_send():
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -268,6 +268,48 @@ class TestTabs:
|
||||
mock_send.return_value = 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):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
result = b.tabs_query("example")
|
||||
@@ -330,6 +372,44 @@ class TestGroups:
|
||||
mock_send.return_value = 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):
|
||||
mock_send.return_value = [GROUP_DATA]
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTabModel:
|
||||
|
||||
+120
-2
@@ -4,6 +4,7 @@ from click.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
|
||||
from browser_cli.cli import main, _project_version
|
||||
from browser_cli.client import BrowserTarget
|
||||
|
||||
def _expected_version() -> str:
|
||||
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
|
||||
|
||||
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"])
|
||||
|
||||
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"
|
||||
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
|
||||
):
|
||||
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 "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():
|
||||
with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
+23
-1
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
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):
|
||||
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"):
|
||||
_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"
|
||||
|
||||
Reference in New Issue
Block a user