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
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user