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
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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"" if alt else f""
|
||||
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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 ""))
|
||||
|
||||
Reference in New Issue
Block a user