refactor: modularize auth transport and markdown
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s

- Split auth into focused package modules for agent keys, file keys,
  signing, and post-quantum transport helpers while keeping the public
  browser_cli.auth import surface intact.
- Move transport encoding internals into a package with separate codec and
  binary-hoisting helpers, preserving browser_cli.transport compatibility.
- Extract remote TCP auth/socket helpers and serve challenge setup out of the
  runtime paths to make connection handling easier to reason about.
- Move the extension markdown extractor into a dedicated content/markdown
  folder with separate root selection, code normalization, renderer, and utils.
- Centralize CLI Rich rendering helpers for tab/window tree and table output,
  and add rendering tests for the shared builders.
- Remove local typing ignores in SDK/decorator/script plumbing and bump the
  package and extension version to 0.15.3.
This commit is contained in:
2026-06-15 01:23:57 +02:00
parent 0b43408a8d
commit 7cb2a8b618
34 changed files with 1502 additions and 1325 deletions
+138 -6
View File
@@ -2,11 +2,22 @@
from __future__ import annotations
import shutil
from collections.abc import Callable, Iterable, Sequence
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
Column = tuple[str, Callable[[Any], Any]]
def item_value(item: Any, name: str, default: Any = None) -> Any:
"""Read *name* from a dict-like or attribute object."""
if isinstance(item, dict):
return item.get(name, default)
return getattr(item, name, default)
def shorten(value: str | None, limit: int) -> str:
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
value = value or ""
@@ -38,18 +49,139 @@ def no_wrap_text() -> Text:
"""Text configured for one-line tree labels with edge ellipsis."""
return Text(no_wrap=True, overflow="ellipsis")
def tab_tree_label(tab, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
def tab_tree_label(tab: Any, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
"""Reusable one-line label for a browser tab in tree views."""
label = no_wrap_text()
label.append(f"[{tab.id}] ", style="dim")
label.append(shorten(tab.title or "(untitled)", title_limit))
if tab.active:
label.append(f"[{item_value(tab, 'id')}] ", style="dim")
label.append(shorten(item_value(tab, 'title') or "(untitled)", title_limit))
if item_value(tab, "active", False):
label.append(" *", style="green")
if show_urls and tab.url:
url = item_value(tab, "url")
if show_urls and url:
label.append("", style="dim")
label.append(shorten(tab.url, url_limit), style="dim")
label.append(shorten(url, url_limit), style="dim")
return label
def group_tree_label(group_id: int, group: Any, *, title_limit: int) -> Text:
"""Reusable one-line label for a browser tab group in tree views."""
title = item_value(group, "title", "") or f"Group {group_id}"
color = item_value(group, "color", "") or "group"
count = item_value(group, "tab_count", item_value(group, "tabCount", 0)) or 0
collapsed = bool(item_value(group, "collapsed", False))
label = no_wrap_text()
label.append(shorten(title, title_limit), style="bold")
meta = [color]
if count:
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
if collapsed:
meta.append("collapsed")
label.append(" (" + ", ".join(meta) + ")", style="dim")
return label
def tab_sort_key(tab: Any) -> tuple:
"""Stable tab ordering across multi-browser responses."""
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
return (
item_value(tab, "browser") or "",
item_value(tab, "window_id", item_value(tab, "windowId", 0)),
item_value(tab, "index", 0) or 0,
group_id if group_id is not None else -1,
item_value(tab, "id", 0),
)
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
"""Render a Rich tree using the detected full terminal width."""
Console(width=terminal_width(console)).print(tree)
def print_table_rows(
rows: Sequence[Any],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render a small Rich table from arbitrary row objects."""
if not rows:
console.print(empty_message)
return
table = Table(show_header=show_header, header_style=header_style)
for header, _getter in columns:
table.add_column(header)
for row in rows:
table.add_row(*[str(getter(row) or "") for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
def build_tabs_tree(
tabs: Iterable[Any],
groups: Iterable[Any],
*,
console: Console,
show_urls: bool = False,
) -> Tree:
"""Build a browser → window → group/tab tree from tab and group responses."""
tabs = sorted(tabs, key=tab_sort_key)
show_browser = any(item_value(tab, "browser") for tab in tabs)
title_limit = tree_title_limit(console=console, show_browser=show_browser, show_urls=show_urls)
url_limit = tree_url_limit(title_limit, console=console)
group_info = {
(
item_value(group, "browser") or "local",
item_value(group, "window_id", item_value(group, "windowId")),
item_value(group, "id"),
): group
for group in groups
}
root = Tree("[bold]Tabs[/bold]")
browser_nodes: dict[str, Tree] = {}
window_nodes: dict[tuple[str, int], Tree] = {}
group_nodes: dict[tuple[str, int, int], Tree] = {}
for tab in tabs:
browser_key = item_value(tab, "browser") or "local"
browser_node = browser_nodes.get(browser_key)
if browser_node is None:
browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root
browser_nodes[browser_key] = browser_node
window_id = item_value(tab, "window_id", item_value(tab, "windowId", 0))
window_key = (browser_key, window_id)
window_node = window_nodes.get(window_key)
if window_node is None:
window_node = browser_node.add(f"Window {window_id}")
window_nodes[window_key] = window_node
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
if group_id is None:
window_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
continue
group_key = (browser_key, window_id, group_id)
group_node = group_nodes.get(group_key)
if group_node is None:
group = group_info.get(group_key) or group_info.get((browser_key, None, group_id))
group_node = window_node.add(group_tree_label(group_id, group, title_limit=title_limit))
group_nodes[group_key] = group_node
group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
return root
def build_windows_tree(windows: Iterable[dict], tabs: Iterable[Any], *, console: Console) -> Tree:
"""Build a window → tab tree from window and tab responses."""
windows = list(windows)
tabs = list(tabs)
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
url_limit = tree_url_limit(title_limit, console=console)
root = Tree("[bold]Windows[/bold]")
for window in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
window_id = window.get("id")
label = f"Window {window_id}"
if window.get("alias"):
label += f" ({window['alias']})"
if window.get("browser"):
label = f"{window['browser']}: " + label
node = root.add(label)
window_tabs = [
tab for tab in tabs
if item_value(tab, "window_id", item_value(tab, "windowId")) == window_id
and (not window.get("browser") or item_value(tab, "browser") == window.get("browser"))
]
for tab in sorted(window_tabs, key=lambda item: item_value(item, "index", 0) or 0):
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
return root
+44 -42
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import importlib
import json
from pathlib import Path
from typing import Any, cast
import click
from rich.console import Console
@@ -12,25 +14,25 @@ from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
def _load_steps(path: Path):
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
try:
import yaml # type: ignore
except Exception as exc:
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
return yaml.safe_load(text)
return json.loads(text)
text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}:
try:
yaml = cast(Any, importlib.import_module("yaml"))
except Exception as exc:
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
return yaml.safe_load(text)
return json.loads(text)
def _parse_step(step):
if isinstance(step, str):
return step, {}
if isinstance(step, dict):
if "command" in step:
return step["command"], step.get("args") or {}
if len(step) == 1:
command, args = next(iter(step.items()))
return command, args or {}
raise click.ClickException(f"Invalid script step: {step!r}")
if isinstance(step, str):
return step, {}
if isinstance(step, dict):
if "command" in step:
return step["command"], step.get("args") or {}
if len(step) == 1:
command, args = next(iter(step.items()))
return command, args or {}
raise click.ClickException(f"Invalid script step: {step!r}")
@click.command("script")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@@ -41,28 +43,28 @@ def _parse_step(step):
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
@handle_errors
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
"""Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file)
if not isinstance(steps, list):
raise click.ClickException("Script root must be a list")
client = client_from_ctx()
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
results = []
for index, step in enumerate(steps, start=1):
command, args = _parse_step(step)
try:
assert_command_allowed(command, policy)
result = client.command(command, args)
results.append({"index": index, "command": command, "ok": True, "result": result})
if not json_output:
console.print(f"[green]✓[/green] {index}: {command}")
except Exception as exc:
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
if not continue_on_error:
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
raise
if not json_output:
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
"""Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file)
if not isinstance(steps, list):
raise click.ClickException("Script root must be a list")
client = client_from_ctx()
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
results = []
for index, step in enumerate(steps, start=1):
command, args = _parse_step(step)
try:
assert_command_allowed(command, policy)
result = client.command(command, args)
results.append({"index": index, "command": command, "ok": True, "result": result})
if not json_output:
console.print(f"[green]✓[/green] {index}: {command}")
except Exception as exc:
results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
if not continue_on_error:
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
raise
if not json_output:
console.print(f"[red]✗[/red] {index}: {command}: {exc}")
if json_output:
click.echo(json.dumps(results, indent=2, default=str))
+13 -85
View File
@@ -2,65 +2,25 @@ import base64
import binascii
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import no_wrap_text, print_tree, shorten, tab_tree_label, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree
from rich.console import Console
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
console = Console()
def _group_tree_label(group_id: int, group, *, title_limit: int) -> Text:
title = getattr(group, "title", "") or f"Group {group_id}"
color = getattr(group, "color", "") or "group"
count = getattr(group, "tab_count", 0) or 0
collapsed = bool(getattr(group, "collapsed", False))
label = no_wrap_text()
label.append(shorten(title, title_limit), style="bold")
meta = [color]
if count:
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
if collapsed:
meta.append("collapsed")
label.append(" (" + ", ".join(meta) + ")", style="dim")
return label
def _tab_sort_key(tab):
return (
tab.browser or "",
tab.window_id,
getattr(tab, "index", 0),
tab.group_id if tab.group_id is not None else -1,
tab.id,
)
def _print_tabs(tabs, *, 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")
columns = []
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)
table.add_column("Muted", width=7)
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [
(t.browser or "") if show_browser else None,
str(t.id),
str(t.window_id),
active,
muted,
(t.title or "")[:60],
(t.url or "")[:80],
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns.append(("Browser", lambda tab: tab.browser or ""))
columns.extend([
("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]),
("URL", lambda tab: (tab.url or "")[:80]),
])
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
@click.group("tabs")
def tabs_group():
@@ -79,39 +39,7 @@ def tabs_list():
def tabs_tree(show_urls):
"""Show tabs grouped as a window/group tree."""
client = client_from_ctx()
tabs = sorted(client.tabs.list(), key=_tab_sort_key)
title_limit = tree_title_limit(console=console, show_browser=any(t.browser for t in tabs), show_urls=show_urls)
url_limit = tree_url_limit(title_limit, console=console)
group_info = {
(group.browser or "local", group.id): group
for group in client.groups.list()
}
root = Tree("[bold]Tabs[/bold]")
browsers = {}
windows = {}
groups = {}
show_browser = any(t.browser for t in tabs)
for tab in tabs:
browser_key = tab.browser or "local"
browser_node = browsers.get(browser_key)
if browser_node is None:
browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root
browsers[browser_key] = browser_node
win_key = (browser_key, tab.window_id)
win_node = windows.get(win_key)
if win_node is None:
win_node = browser_node.add(f"Window {tab.window_id}")
windows[win_key] = win_node
if tab.group_id is None:
win_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
continue
group_key = (browser_key, tab.window_id, tab.group_id)
group_node = groups.get(group_key)
group = group_info.get((browser_key, tab.group_id))
if group_node is None:
group_node = win_node.add(_group_tree_label(tab.group_id, group, title_limit=title_limit))
groups[group_key] = group_node
group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
print_tree(root, console=console)
@tabs_group.command("close")
+11 -37
View File
@@ -1,33 +1,21 @@
import click
from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import print_tree, tab_tree_label, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering import build_windows_tree, print_table_rows, print_tree
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
console = Console()
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")
columns = []
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("Tabs", width=6)
table.add_column("State", width=12)
for w in windows:
row = [
w.get("browser", "") if show_browser else None,
str(w.get("id", "")),
w.get("alias") or "",
str(w.get("tabCount", "")),
w.get("state") or "",
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
columns.append(("Browser", lambda window: window.get("browser", "")))
columns.extend([
("ID", lambda window: window.get("id", "")),
("Alias", lambda window: window.get("alias") or ""),
("Tabs", lambda window: window.get("tabCount", "")),
("State", lambda window: window.get("state") or ""),
])
print_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
@click.group("windows")
def windows_group():
@@ -45,21 +33,7 @@ def windows_list():
def windows_tree():
"""Show windows and their tabs as a tree."""
client = client_from_ctx()
windows = client.windows.list()
tabs = client.tabs.list()
root = Tree("[bold]Windows[/bold]")
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
url_limit = tree_url_limit(title_limit, console=console)
for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
wid = w.get("id")
label = f"Window {wid}"
if w.get("alias"):
label += f" ({w['alias']})"
if w.get("browser"):
label = f"{w['browser']}: " + label
node = root.add(label)
for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: getattr(t, "index", 0)):
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
print_tree(root, console=console)
@windows_group.command("rename")