refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s

Restructure the Python API and internals around composable namespaces and
a standalone transport/endpoint layer. Bump to 0.12.0.

Python API:
- Replace flat methods (b.tabs_list(), b.group_list()) with namespaces:
  b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage,
  b.cookies, b.session, b.perf, b.extension.
- Shrink browser_cli/__init__.py to a thin composition root; move all
  behaviour into browser_cli/sdk/ (one module per namespace + factories,
  base, routing).

Internals:
- Add browser_cli/transport.py and remote_transport.py to isolate IPC from
  command logic; client.py now delegates instead of owning transport.
- Add browser_cli/endpoints.py for endpoint resolution and
  browser_cli/errors.py for shared error types.
- Extract markdown rendering into browser_cli/markdown.py (out of extract).
- Add USER_AGENT to version_manager.

Tooling & tests:
- Add justfile with common dev tasks.
- Update CLI commands and demo to the namespaced API.
- Rework tests for the new layout; add test_transport.py and
  test_refactor_boundaries.py to lock in module boundaries.

BREAKING CHANGE: flat API methods are removed in favour of namespaces
(e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
2026-06-11 13:58:41 +02:00
parent 0813ae2de9
commit fd5447cbb9
52 changed files with 3344 additions and 2348 deletions
+68 -29
View File
@@ -1,40 +1,79 @@
"""Shared helpers for the Click CLI command modules.
Every CLI command is a thin presentation layer over the Python SDK: it builds a
:class:`~browser_cli.BrowserCLI` from the global ``--browser/--remote/--key``
options, calls the matching SDK namespace method, and renders the result. The
SDK is the single source of truth for command strings, argument shapes, and
multi-browser routing.
"""
import functools
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
from rich.table import Table
from browser_cli import BrowserCLI, BrowserCounts
from browser_cli.client import BrowserNotConnected
_console = Console()
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option(
"--gentle-mode",
type=click.Choice(GENTLE_MODES),
default="auto",
show_default=True,
help=help_text,
)
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result.
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a
per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
"""
if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser")
table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items():
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests).
"""
obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1)
return wrapper
+12 -15
View File
@@ -1,23 +1,22 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
@click.group("cookies")
def cookies_group():
"""Manage browser cookies."""
@cookies_group.command("list")
@click.option("--url", default=None, help="Filter by URL")
@click.option("--domain", default=None, help="Filter by domain")
@click.option("--name", default=None, help="Filter by cookie name")
@handle_errors
def cookies_list(url, domain, name):
"""List cookies, optionally filtered by URL, domain, or name."""
cookies = _handle("cookies.list", {"url": url, "domain": domain, "name": name}) or []
cookies = client_from_ctx().cookies.list(url=url, domain=domain, name=name)
if not cookies:
console.print("[yellow]No cookies found[/yellow]")
return
@@ -39,19 +38,18 @@ def cookies_list(url, domain, name):
)
console.print(table)
@cookies_group.command("get")
@click.argument("url")
@click.argument("name")
@handle_errors
def cookies_get(url, name):
"""Get the value of a single cookie by URL and NAME."""
cookie = _handle("cookies.get", {"url": url, "name": name})
cookie = client_from_ctx().cookies.get(url, name)
if cookie is None:
console.print(f"[yellow]Cookie '{name}' not found for {url}[/yellow]")
raise SystemExit(1)
console.print(cookie.get("value", ""))
@cookies_group.command("set")
@click.argument("url")
@click.argument("name")
@@ -62,14 +60,13 @@ def cookies_get(url, name):
@click.option("--http-only", "http_only", is_flag=True)
@click.option("--expires", "expiration_date", type=float, default=None, help="Unix timestamp")
@click.option("--same-site", type=click.Choice(["no_restriction", "lax", "strict"]), default=None)
@handle_errors
def cookies_set(url, name, value, domain, path, secure, http_only, expiration_date, same_site):
"""Set a cookie on URL."""
_handle("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure or None,
"httpOnly": http_only or None,
"expirationDate": expiration_date,
"sameSite": same_site,
})
client_from_ctx().cookies.set(
url, name, value,
domain=domain, path=path,
secure=secure or None, http_only=http_only or None,
expiration_date=expiration_date, same_site=same_site,
)
console.print(f"[green]Set cookie:[/green] {name}={value!r} on {url}")
+40 -57
View File
@@ -1,22 +1,21 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
from rich.table import Table
import json
console = Console()
@click.group("dom")
def dom_group():
"""Query and interact with page DOM elements."""
@dom_group.command("query")
@click.argument("selector")
@handle_errors
def dom_query(selector):
"""Return elements matching CSS SELECTOR (like mini DevTools)."""
elements = _handle("dom.query", {"selector": selector})
elements = client_from_ctx().dom.query(selector)
if not elements:
console.print("[yellow]No elements found[/yellow]")
return
@@ -29,180 +28,164 @@ def dom_query(selector):
table.add_row(el.get("tag", ""), (el.get("text") or "")[:60], attrs[:80])
console.print(table)
@dom_group.command("click")
@click.argument("selector")
@handle_errors
def dom_click(selector):
"""Click the first element matching CSS SELECTOR."""
_handle("dom.click", {"selector": selector})
client_from_ctx().dom.click(selector)
console.print(f"[green]Clicked:[/green] {selector}")
@dom_group.command("type")
@click.argument("selector")
@click.argument("text")
@handle_errors
def dom_type(selector, text):
"""Type TEXT into the element matching CSS SELECTOR."""
_handle("dom.type", {"selector": selector, "text": text})
client_from_ctx().dom.type(selector, text)
console.print(f"[green]Typed into:[/green] {selector}")
@dom_group.command("attr")
@click.argument("selector")
@click.argument("attr_name")
@handle_errors
def dom_attr(selector, attr_name):
"""Get attribute ATTR_NAME from elements matching CSS SELECTOR."""
values = _handle("dom.attr", {"selector": selector, "attr": attr_name})
for v in (values or []):
for v in client_from_ctx().dom.attr(selector, attr_name):
console.print(v)
@dom_group.command("text")
@click.argument("selector")
@handle_errors
def dom_text(selector):
"""Get text content of elements matching CSS SELECTOR."""
values = _handle("dom.text", {"selector": selector})
for v in (values or []):
for v in client_from_ctx().dom.text(selector):
console.print(v)
@dom_group.command("exists")
@click.argument("selector")
@handle_errors
def dom_exists(selector):
"""Check if an element matching CSS SELECTOR exists on the page."""
exists = _handle("dom.exists", {"selector": selector})
if exists:
if client_from_ctx().dom.exists(selector):
console.print(f"[green]exists[/green]: {selector}")
else:
console.print(f"[red]not found[/red]: {selector}")
raise SystemExit(1)
@dom_group.command("scroll")
@click.argument("selector", required=False)
@click.option("--x", type=int, default=None, help="Horizontal scroll position (px)")
@click.option("--y", type=int, default=None, help="Vertical scroll position (px)")
@handle_errors
def dom_scroll(selector, x, y):
"""Scroll to a CSS SELECTOR or to an X/Y coordinate."""
_handle("dom.scroll", {"selector": selector, "x": x, "y": y})
client_from_ctx().dom.scroll(selector, x=x, y=y)
target = selector or f"({x or 0}, {y or 0})"
console.print(f"[green]Scrolled to:[/green] {target}")
@dom_group.command("select")
@click.argument("selector")
@click.argument("value")
@handle_errors
def dom_select(selector, value):
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
_handle("dom.select", {"selector": selector, "value": value})
client_from_ctx().dom.select(selector, value)
console.print(f"[green]Selected '{value}' in:[/green] {selector}")
@dom_group.command("eval")
@click.argument("code")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@handle_errors
def dom_eval(code, tab_id):
"""Evaluate JavaScript CODE in the page and print the result."""
result = _handle("dom.eval", {"code": code, "tabId": tab_id})
result = client_from_ctx().dom.eval(code, tab_id)
if result is None:
console.print("[dim]null[/dim]")
else:
console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result))
@dom_group.command("wait-for")
@click.argument("selector")
@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait")
@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)")
@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@handle_errors
def dom_wait_for(selector, timeout, visible, hidden, tab_id):
"""Wait until CSS SELECTOR appears (or disappears) in the DOM."""
_handle("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
client_from_ctx().dom.wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
state = "hidden" if hidden else ("visible" if visible else "present")
console.print(f"[green]Ready ({state}):[/green] {selector}")
@dom_group.command("key")
@click.argument("key")
@click.option("--selector", default=None, help="CSS selector to target (default: focused element)")
@handle_errors
def dom_key(key, selector):
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
_handle("dom.key", {"key": key, "selector": selector})
client_from_ctx().dom.key(key, selector)
target = selector or "active element"
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
@dom_group.command("hover")
@click.argument("selector")
@handle_errors
def dom_hover(selector):
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
_handle("dom.hover", {"selector": selector})
client_from_ctx().dom.hover(selector)
console.print(f"[green]Hovered:[/green] {selector}")
@dom_group.command("check")
@click.argument("selector")
@handle_errors
def dom_check(selector):
"""Check a checkbox matching CSS SELECTOR."""
_handle("dom.check", {"selector": selector})
client_from_ctx().dom.check(selector)
console.print(f"[green]Checked:[/green] {selector}")
@dom_group.command("uncheck")
@click.argument("selector")
@handle_errors
def dom_uncheck(selector):
"""Uncheck a checkbox matching CSS SELECTOR."""
_handle("dom.uncheck", {"selector": selector})
client_from_ctx().dom.uncheck(selector)
console.print(f"[green]Unchecked:[/green] {selector}")
@dom_group.command("clear")
@click.argument("selector")
@handle_errors
def dom_clear(selector):
"""Clear the value of an input matching CSS SELECTOR."""
_handle("dom.clear", {"selector": selector})
client_from_ctx().dom.clear(selector)
console.print(f"[green]Cleared:[/green] {selector}")
@dom_group.command("focus")
@click.argument("selector")
@handle_errors
def dom_focus(selector):
"""Focus the element matching CSS SELECTOR."""
_handle("dom.focus", {"selector": selector})
client_from_ctx().dom.focus(selector)
console.print(f"[green]Focused:[/green] {selector}")
@dom_group.command("submit")
@click.argument("selector")
@handle_errors
def dom_submit(selector):
"""Submit the form that contains the element matching CSS SELECTOR."""
_handle("dom.submit", {"selector": selector})
client_from_ctx().dom.submit(selector)
console.print(f"[green]Submitted form for:[/green] {selector}")
@dom_group.command("poll")
@click.argument("selector")
@click.argument("pattern")
@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@handle_errors
def dom_poll(selector, pattern, attr, timeout, interval, tab_id):
"""Poll SELECTOR until its text/value matches regex PATTERN."""
result = _handle("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
result = client_from_ctx().dom.poll(selector, pattern, attr=attr, timeout=timeout, interval=interval, tab_id=tab_id)
value = result.get("value", "") if isinstance(result, dict) else ""
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
+3 -3
View File
@@ -1,7 +1,6 @@
import time
import click
from rich.console import Console
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
@@ -10,6 +9,7 @@ def extension_group():
"""Manage the browser-cli browser extension."""
@extension_group.command("reload")
@handle_errors
def extension_reload():
"""Reload the browser-cli extension service worker.
@@ -17,5 +17,5 @@ def extension_reload():
The command returns immediately; the extension restarts ~200 ms later.
Re-connects automatically via the keepalive alarm within ~25 seconds.
"""
_handle("extension.reload")
client_from_ctx().extension.reload()
console.print("[green]Extension reloading…[/green] reconnects automatically")
+16 -435
View File
@@ -1,437 +1,24 @@
import json
import re
from html.parser import HTMLParser
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
# Re-exported for backward compatibility: the HTML→Markdown engine now lives in
# browser_cli.markdown and is applied by the SDK (ExtractNS.markdown).
from browser_cli.markdown import _clean_markdown_output, _convert_html_to_markdown # noqa: F401
from rich.console import Console
from rich.table import Table
console = Console()
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return _clean_markdown_output(markdown)
@click.group("extract")
def extract_group():
"""Extract content from the active tab."""
@extract_group.command("links")
@handle_errors
def extract_links():
"""Extract all links from the active tab."""
links = _handle("extract.links")
links = client_from_ctx().extract.links()
if not links:
console.print("[yellow]No links found[/yellow]")
return
@@ -442,11 +29,11 @@ def extract_links():
table.add_row((lnk.get("text") or "")[:60], lnk.get("href") or "")
console.print(table)
@extract_group.command("images")
@handle_errors
def extract_images():
"""Extract all images from the active tab."""
images = _handle("extract.images")
images = client_from_ctx().extract.images()
if not images:
console.print("[yellow]No images found[/yellow]")
return
@@ -457,36 +44,30 @@ def extract_images():
table.add_row((img.get("alt") or "")[:40], img.get("src") or "")
console.print(table)
@extract_group.command("text")
@handle_errors
def extract_text():
"""Extract all visible text from the active tab."""
text = _handle("extract.text")
console.print(text or "")
console.print(client_from_ctx().extract.text())
@extract_group.command("json")
@click.argument("selector")
@handle_errors
def extract_json(selector):
"""Parse and pretty-print JSON content inside SELECTOR."""
data = _handle("extract.json", {"selector": selector})
data = client_from_ctx().extract.json(selector)
console.print_json(json.dumps(data))
@extract_group.command("html")
@handle_errors
def extract_html():
"""Print the full HTML of the active tab to stdout."""
html = _handle("extract.html")
click.echo(html or "")
click.echo(client_from_ctx().extract.html())
@extract_group.command("markdown")
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
@handle_errors
def extract_markdown(selector):
"""Extract the page's main content as Markdown."""
markdown = _handle("extract.markdown", {"selector": selector})
if (markdown or "").lstrip().startswith("<"):
markdown = _convert_html_to_markdown(markdown)
else:
markdown = _clean_markdown_output(markdown or "")
markdown = client_from_ctx().extract.markdown(selector)
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
+27 -68
View File
@@ -1,12 +1,11 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from rich.console import Console
from rich.table import Table
console = Console()
def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups:
console.print("[yellow]No groups found[/yellow]")
return
@@ -20,128 +19,88 @@ def _print_groups(groups: list[dict], *, show_browser: bool = False) -> None:
table.add_column("Tabs", width=6)
for g in groups:
row = [
g.get("browser", "") if show_browser else None,
str(g.get("id", "")),
g.get("title") or "",
g.get("color") or "",
"yes" if g.get("collapsed") else "no",
str(g.get("tabCount", "")),
(g.browser or "") if show_browser else None,
str(g.id),
g.title or "",
g.color or "",
"yes" if g.collapsed else "no",
str(g.tab_count),
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("groups")
def group_group():
"""Manage tab groups."""
@group_group.command("list")
@handle_errors
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, remote=target.remote)
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 [])
groups = client_from_ctx().groups.list()
_print_groups(groups, show_browser=any(g.browser for g in groups))
@group_group.command("tabs")
@click.argument("group_id", type=int)
@handle_errors
def group_tabs(group_id):
"""List tabs inside a group."""
from browser_cli.commands.tabs import _print_tabs
tabs = _handle("group.tabs", {"groupId": group_id})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().groups.tabs(group_id))
@group_group.command("count")
@handle_errors
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, remote=target.remote)
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)")
print_counts(client_from_ctx().groups.count(), "group")
@group_group.command("query")
@click.argument("search")
@handle_errors
def group_query(search):
"""Search groups by name."""
groups = _handle("group.query", {"search": search})
_print_groups(groups or [])
_print_groups(client_from_ctx().groups.query(search))
@group_group.command("close")
@click.argument("group_id", type=int)
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.")
@gentle_mode_option("Throttle mode for large group operations.")
@handle_errors
def group_close(group_id, gentle_mode):
"""Close (ungroup and optionally close) a tab group."""
_handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
client_from_ctx().groups.close(group_id, gentle_mode=gentle_mode)
console.print(f"[green]Group {group_id} closed[/green]")
@group_group.command("create")
@click.argument("name")
@handle_errors
def group_create(name):
"""Create a new tab group with NAME."""
result = _handle("group.open", {"name": name})
gid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Created group '{name}'[/green] (id: {gid})")
group = client_from_ctx().groups.create(name)
console.print(f"[green]Created group '{name}'[/green] (id: {group.id})")
@group_group.command("add-tab")
@click.argument("group")
@click.argument("url", required=False)
@handle_errors
def group_add_tab(group, url):
"""Open a new tab (optionally at URL) inside GROUP (name or ID)."""
result = _handle("group.add_tab", {"group": group, "url": url})
tab_id = result.get("tabId") if isinstance(result, dict) else result
tab_id = client_from_ctx().groups.add_tab(group, url)
label = url or "new tab"
console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})")
@group_group.command("move")
@click.argument("group")
@click.option("-f", "--forward", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move group one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move group one position to the left")
@handle_errors
def group_move(group, forward, backward):
"""Move a tab group forward/backward or right/left (name or ID)."""
if not forward and not backward:
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
raise SystemExit(1)
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
result = client_from_ctx().groups.move(group, forward=forward, backward=backward)
if isinstance(result, dict) and not result.get("moved"):
console.print(f"[yellow]Group '{group}' is already at the {'end' if forward else 'start'}[/yellow]")
else:
+20 -25
View File
@@ -1,23 +1,22 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
console = Console()
@click.group("nav")
def nav_group():
"""Navigate — open URLs, reload, go back/forward, focus tabs."""
@nav_group.command("open")
@click.argument("url")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
@handle_errors
def cmd_open(url, bg, window_name, group_name):
"""Open URL in a new tab."""
_handle("navigate.open", {"url": url, "background": bg, "window": window_name, "group": group_name})
client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name)
suffix = ""
if group_name:
suffix = f" in group '{group_name}'"
@@ -25,71 +24,67 @@ def cmd_open(url, bg, window_name, group_name):
suffix = f" in window '{window_name}'"
console.print(f"[green]Opened:[/green] {url}{suffix}")
@nav_group.command("reload")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_reload(tab_id):
"""Reload the active (or specified) tab."""
_handle("navigate.reload", {"tabId": tab_id})
client_from_ctx().nav.reload(tab_id)
console.print("[green]Reloaded[/green]")
@nav_group.command("hard-reload")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_hard_reload(tab_id):
"""Hard reload (bypass cache) the active (or specified) tab."""
_handle("navigate.hard_reload", {"tabId": tab_id})
client_from_ctx().nav.hard_reload(tab_id)
console.print("[green]Hard reloaded[/green]")
@nav_group.command("back")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_back(tab_id):
"""Navigate back in the active (or specified) tab."""
_handle("navigate.back", {"tabId": tab_id})
client_from_ctx().nav.back(tab_id)
console.print("[green]Navigated back[/green]")
@nav_group.command("forward")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def cmd_forward(tab_id):
"""Navigate forward in the active (or specified) tab."""
_handle("navigate.forward", {"tabId": tab_id})
client_from_ctx().nav.forward(tab_id)
console.print("[green]Navigated forward[/green]")
@nav_group.command("focus")
@click.argument("pattern")
@handle_errors
def cmd_focus(pattern):
"""Jump to a tab by URL pattern or tab ID."""
result = _handle("navigate.focus", {"pattern": pattern})
result = client_from_ctx().nav.focus(pattern)
if result:
console.print(f"[green]Focused:[/green] {result.get('url', result)}")
else:
console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
@nav_group.command("open-wait")
@click.argument("url")
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window_name", default=None, help="Open in named window")
@click.option("--group", "group_name", default=None, help="Open in tab group")
@handle_errors
def cmd_open_wait(url, timeout, bg, window_name, group_name):
"""Open URL in a new tab and wait until fully loaded."""
result = _handle("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": bg, "window": window_name, "group": group_name,
})
title = result.get("title", "") if isinstance(result, dict) else ""
console.print(f"[green]Loaded:[/green] {url}" + (f"{title}" if title else ""))
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name)
console.print(f"[green]Loaded:[/green] {url}" + (f"{tab.title}" if tab.title else ""))
@nav_group.command("wait")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
@handle_errors
def cmd_wait(tab_id, timeout, ready_state):
"""Wait until tab finishes loading."""
result = _handle("navigate.wait", {"tabId": tab_id, "timeout": int(timeout * 1000), "readyState": ready_state})
console.print(f"[green]Ready:[/green] {result.get('url', '')}{result.get('title', '')}")
tab = client_from_ctx().tabs.wait_for_load(tab_id, timeout=timeout, ready_state=ready_state)
console.print(f"[green]Ready:[/green] {tab.url}{tab.title}")
+3 -4
View File
@@ -1,20 +1,19 @@
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
@click.group("page")
def page_group():
"""Inspect current page metadata."""
@page_group.command("info")
@handle_errors
def page_info():
"""Show title, URL, readyState, language, and meta tags of the active tab."""
info = _handle("page.info") or {}
info = client_from_ctx().page.info()
table = Table(show_header=False)
table.add_column("Field", style="bold cyan", no_wrap=True)
table.add_column("Value")
+5 -3
View File
@@ -1,7 +1,7 @@
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
console = Console()
@@ -10,9 +10,10 @@ def perf_group():
"""Inspect and tune browser-cli performance behavior."""
@perf_group.command("status")
@handle_errors
def perf_status():
"""Show performance profile, throttle and running jobs."""
result = _handle("perf.status") or {}
result = client_from_ctx().perf.status()
console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
throttle = result.get("throttle") or {}
@@ -39,7 +40,8 @@ def perf_status():
@perf_group.command("profile")
@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
@handle_errors
def perf_profile(profile):
"""Set global performance profile."""
result = _handle("perf.set_profile", {"profile": profile}) or {}
result = client_from_ctx().perf.set_profile(profile)
console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
+3 -6
View File
@@ -1,6 +1,5 @@
import click
from urllib.parse import quote_plus
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
console = Console()
@@ -61,23 +60,21 @@ _SUBCOMMANDS = [
def search_group():
"""Search the web — open a query in a search engine."""
def _build_command(engine_key: str, help_text: str) -> click.Command:
@click.command(engine_key, help=help_text)
@click.argument("query", nargs=-1, required=True)
@click.option("--bg", is_flag=True, help="Open in background (no focus)")
@click.option("--window", "window", default=None, help="Open in named window")
@click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
@handle_errors
def _cmd(query, bg, window, group):
terms = " ".join(query)
url = ENGINES[engine_key].format(query=quote_plus(terms))
_handle("navigate.open", {"url": url, "background": bg, "window": window, "group": group})
client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group)
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
return _cmd
for _name, _help in _SUBCOMMANDS:
search_group.add_command(_build_command(_name, _help))
+35 -17
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from rich.console import Console
from browser_cli import transport
from browser_cli.client import _recv_exact, _recv_all
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
@@ -12,7 +13,6 @@ _UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
console = Console()
def _framed_send(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
@@ -25,11 +25,12 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
def _send_payload(data: bytes) -> None:
if response_secret is not None:
@@ -38,16 +39,17 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
# errors stay plain JSON: tiny, and safe for any client
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload) -> None:
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
def _send_ok(msg_id, payload, command=None) -> None:
obj = {"id": msg_id, "success": True, "data": payload}
try:
_send_payload(out)
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
except OSError:
pass
@@ -141,13 +143,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
return
response_secret = pq_shared_secret if transport_encrypted else None
# client advertises what response encodings it can decode (signed, then stripped)
accept_encoding = msg.get("accept_encoding")
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
_send_ok(msg_id, targets)
_send_ok(msg_id, targets, command)
_log(addr, command, None, "OK")
return
@@ -158,7 +163,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
_send_ok(msg_id, entries)
_send_ok(msg_id, entries, command)
_log(addr, command, None, "OK")
return
@@ -176,14 +181,14 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
_send_ok(msg_id, {"added": added})
_send_ok(msg_id, {"added": added}, command)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
@@ -203,16 +208,19 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
pipe.send_bytes(clean_payload)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
_send_payload(resp_payload)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
_send_payload(resp_payload)
# parse once: drives both the access log and (re-)encoding for the client
resp_data = json.loads(resp_payload)
if compress:
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
else:
_send_payload(resp_payload)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
@@ -221,7 +229,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None", compress:bool=True) -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
@@ -253,7 +261,7 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_framed_send(client_sock, challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
finally:
_CONN_LIMIT.release()
@@ -264,10 +272,13 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
help="Disable response compression / msgpack even for clients that support it.")
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
if host in ("0.0.0.0", "::"):
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
@@ -302,18 +313,25 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
if compress:
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
else:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
console.print("Ctrl-C to stop.\n")
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
+33 -34
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
from rich.console import Console
console = Console()
@@ -10,41 +10,47 @@ def session_group():
@session_group.command("save")
@click.argument("name")
@handle_errors
def session_save(name):
"""Save all current tabs as session NAME."""
result = _handle("session.save", {"name": name})
result = client_from_ctx().session.save(name)
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
@session_group.command("load")
@click.argument("name")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.")
@gentle_mode_option("Throttle mode for large restores.")
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
@handle_errors
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
"""Restore session NAME (opens all saved tabs)."""
result = _handle("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": background_job,
})
if background_job and isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
b = client_from_ctx()
if background_job:
result = b.session.load_background(
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
lazy=lazy, eager_tabs=eager_tabs,
)
if isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
else:
result = b.session.load(
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
lazy=lazy, eager_tabs=eager_tabs,
)
count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
@session_group.command("diff")
@click.argument("name_a")
@click.argument("name_b")
@handle_errors
def session_diff(name_a, name_b):
"""Show tabs added/removed between two saved sessions."""
diff = _handle("session.diff", {"nameA": name_a, "nameB": name_b})
diff = client_from_ctx().session.diff(name_a, name_b)
if not diff:
console.print("[yellow]No diff data returned[/yellow]")
return
@@ -66,26 +72,16 @@ def session_diff(name_a, name_b):
console.print("[green]Sessions are identical[/green]")
@session_group.command("list")
@handle_errors
def session_list():
"""List all saved sessions."""
from datetime import datetime
from rich.table import Table
targets = _multi_browser_targets()
show_browser = bool(targets)
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
if not sessions:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
else:
sessions = _handle("session.list")
sessions = client_from_ctx().session.list()
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
show_browser = any("browser" in s for s in sessions)
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
@@ -93,7 +89,6 @@ def session_list():
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
from datetime import datetime
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
@@ -102,16 +97,18 @@ def session_list():
@session_group.command("remove")
@click.argument("name")
@handle_errors
def session_remove(name):
"""Delete a saved session."""
_handle("session.remove", {"name": name})
client_from_ctx().session.remove(name)
console.print(f"[green]Session '{name}' removed[/green]")
@session_group.command("job-status")
@click.argument("job_id")
@handle_errors
def session_job_status(job_id):
"""Show status for a background session job."""
result = _handle("jobs.status", {"jobId": job_id}) or {}
result = client_from_ctx().perf.job_status(job_id)
status = result.get("status", "unknown")
console.print(f"[bold]{job_id}[/bold]: {status}")
if result.get("error"):
@@ -121,15 +118,17 @@ def session_job_status(job_id):
@session_group.command("job-cancel")
@click.argument("job_id")
@handle_errors
def session_job_cancel(job_id):
"""Cancel a running background job."""
_handle("jobs.cancel", {"jobId": job_id})
client_from_ctx().perf.job_cancel(job_id)
console.print(f"[green]Cancel requested for {job_id}[/green]")
@session_group.command("auto-save")
@click.argument("state", type=click.Choice(["on", "off"]))
@handle_errors
def session_auto_save(state):
"""Enable or disable automatic session saving."""
enabled = state == "on"
_handle("session.auto_save", {"enabled": enabled})
client_from_ctx().session.auto_save(enabled)
console.print(f"[green]Auto-save {state}[/green]")
+7 -8
View File
@@ -1,23 +1,22 @@
import json
import click
from browser_cli.commands import _handle
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
from rich.console import Console
console = Console()
@click.group("storage")
def storage_group():
"""Read and write the page's localStorage / sessionStorage."""
@storage_group.command("get")
@click.argument("key", required=False)
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@handle_errors
def storage_get(key, store_type, tab_id):
"""Get a localStorage KEY (or dump all keys if omitted)."""
result = _handle("storage.get", {"key": key, "type": store_type, "tabId": tab_id})
result = client_from_ctx().storage.get(key, type=store_type, tab_id=tab_id)
if result is None:
console.print("[dim]null[/dim]")
elif isinstance(result, dict):
@@ -25,13 +24,13 @@ def storage_get(key, store_type, tab_id):
else:
console.print(str(result))
@storage_group.command("set")
@click.argument("key")
@click.argument("value")
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@handle_errors
def storage_set(key, value, store_type, tab_id):
"""Set localStorage KEY to VALUE."""
_handle("storage.set", {"key": key, "value": value, "type": store_type, "tabId": tab_id})
client_from_ctx().storage.set(key, value, type=store_type, tab_id=tab_id)
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
+62 -111
View File
@@ -1,14 +1,13 @@
import base64
import binascii
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from rich.console import Console
from rich.table import Table
console = Console()
def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs:
console.print("[yellow]No tabs found[/yellow]")
return
@@ -22,58 +21,42 @@ def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.get("active") else ""
muted = "[yellow]✓[/yellow]" if t.get("muted") else ""
active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [
t.get("browser", "") if show_browser else None,
str(t.get("id", "")),
str(t.get("windowId", "")),
(t.browser or "") if show_browser else None,
str(t.id),
str(t.window_id),
active,
muted,
(t.get("title") or "")[:60],
(t.get("url") or "")[:80],
(t.title or "")[:60],
(t.url or "")[:80],
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("tabs")
def tabs_group():
"""Manage browser tabs."""
@tabs_group.command("list")
@handle_errors
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, remote=target.remote)
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 [])
tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("close")
@click.argument("tab_id", type=int, required=False)
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.")
@gentle_mode_option("Throttle mode for large close operations.")
@handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs."""
result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 1
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
@@ -83,180 +66,148 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
@click.option("--index", type=int, default=None, help="Absolute position index in target")
@handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
_handle("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index,
)
console.print("[green]Tab moved[/green]")
@tabs_group.command("active")
@click.argument("tab_id", type=int)
@handle_errors
def tabs_active(tab_id):
"""Switch browser focus to a tab."""
_handle("tabs.active", {"tabId": tab_id})
client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = _handle("tabs.status", {"tabId": tab_id}) or {}
tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.get("id", "")))
table.add_row("Window", str(tab.get("windowId", "")))
table.add_row("Active", "yes" if tab.get("active") else "no")
table.add_row("Muted", "yes" if tab.get("muted") else "no")
table.add_row("Title", tab.get("title") or "")
table.add_row("URL", tab.get("url") or "")
table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
@handle_errors
def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN."""
tabs = _handle("tabs.filter", {"pattern": pattern})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count")
@click.argument("pattern", required=False)
@handle_errors
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, remote=target.remote)
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}")
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query")
@click.argument("search")
@handle_errors
def tabs_query(search):
"""Search tabs by URL or title."""
tabs = _handle("tabs.query", {"search": search})
_print_tabs(tabs or [])
_print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_html(tab_id):
"""Print the full HTML of a tab."""
html = _handle("tabs.html", {"tabId": tab_id})
console.print(html or "")
console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.")
@gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL)."""
result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 0
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.")
@gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window."""
_handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows")
@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.")
@gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window."""
result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
count = result.get("moved", 0) if isinstance(result, dict) else 0
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_mute(tab_id):
"""Mute the active tab or a specific tab."""
result = _handle("tabs.mute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab."""
result = _handle("tabs.unmute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_pin(tab_id):
"""Pin the active tab or a specific tab."""
result = _handle("tabs.pin", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab."""
result = _handle("tabs.unpin", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url")
@click.argument("pattern")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors
def tabs_watch_url(pattern, tab_id, timeout):
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
result = _handle("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
url = result.get("url", "") if isinstance(result, dict) else ""
console.print(f"[green]URL matched:[/green] {url}")
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.url}")
@tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE")
@click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
@tab_option
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
@handle_errors
def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL.
"""
result = _handle("tabs.screenshot", {"tabId": tab_id, "format": fmt, "quality": quality})
data_url = result.get("dataUrl", "") if isinstance(result, dict) else ""
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output:
header = f"data:image/{fmt};base64,"
if not data_url.startswith(header):
+10 -25
View File
@@ -1,11 +1,10 @@
import click
from browser_cli.commands import _handle, _handle_multi, _multi_browser_targets
from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console
from rich.table import Table
console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows:
console.print("[yellow]No windows found[/yellow]")
@@ -28,53 +27,39 @@ def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("windows")
def windows_group():
"""Manage browser windows."""
@windows_group.command("list")
@handle_errors
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, remote=target.remote)
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 [])
windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows))
@windows_group.command("rename")
@click.argument("window_id", type=int)
@click.argument("name")
@handle_errors
def windows_rename(window_id, name):
"""Give a window a local alias NAME (stored in native host)."""
_handle("windows.rename", {"windowId": window_id, "name": name})
client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
@windows_group.command("close")
@click.argument("window_id", type=int)
@handle_errors
def windows_close(window_id):
"""Close a browser window."""
_handle("windows.close", {"windowId": window_id})
client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]")
@windows_group.command("open")
@click.argument("url", required=False)
@handle_errors
def windows_open(url):
"""Open a new browser window."""
result = _handle("windows.open", {"url": url})
result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))