diff --git a/.gitea/workflows/package-extension.yml b/.gitea/workflows/package-extension.yml index 13ad427..d047963 100644 --- a/.gitea/workflows/package-extension.yml +++ b/.gitea/workflows/package-extension.yml @@ -39,14 +39,10 @@ jobs: )" echo "version=$version" >> "$GITHUB_OUTPUT" - - name: Build extension archive + - name: Build extension archives run: | - rm -rf extension-package - mkdir -p dist extension-package - cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/ - cp -R extension/icons extension-package/icons - cd extension-package - zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" . + python scripts/package_extension.py --out "dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" + python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${{ steps.version.outputs.version }}.zip" - name: Publish extension release asset env: diff --git a/README.md b/README.md index 2164bbc..1bbee4e 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ These commands run on the **active tab**. The tab must be on a regular `http://` browser-cli dom query "h1" # return elements matching CSS selector browser-cli dom text "h1" # get text content of matching elements browser-cli dom attr "a" href # get attribute value from elements -browser-cli dom exists ".cookie-banner" # exits 0 if found, 1 if not +browser-cli dom exists ".modal-banner" # exits 0 if found, 1 if not browser-cli dom click ".accept-button" # click an element browser-cli dom type "#search" "hello" # type text into an input ``` @@ -363,7 +363,7 @@ b.windows.close(1) elements = b.dom.query("h2") # list of { tag, text, attrs } texts = b.dom.text(".article p") # list of strings attrs = b.dom.attr("a", "href") # list of strings -exists = b.dom.exists(".cookie-banner")# bool +exists = b.dom.exists(".modal-banner") # bool b.dom.click(".accept-button") b.dom.type("#search", "hello world") b.dom.wait_for("#results", visible=True, timeout=10) @@ -376,11 +376,10 @@ text = b.extract.text() # string data = b.extract.json("#app-data") # parsed Python object md = b.extract.markdown("article") -# Page / storage / cookies +# Page / storage info = b.page.info() b.storage.set("token", "abc") val = b.storage.get("token") -cookies = b.cookies.list(domain="example.com") # Sessions ── b.session b.session.save("before-meeting") @@ -489,6 +488,15 @@ npm run check:extension The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally. +Packaging: + +```bash +npm run package:extension # local/unpacked zip, keeps manifest.key for stable native-messaging ID +npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key +``` + +Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. + --- ## Limitations diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index d3798e4..a03b0b0 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -29,7 +29,6 @@ Commands are grouped into namespaces on the client: b.extract content extraction (links, images, text, json, markdown) b.page page info b.storage localStorage / sessionStorage - b.cookies cookies (list, get, set) b.session sessions (save, load, list, diff, ...) b.perf performance profile + background jobs b.extension control the extension itself @@ -41,7 +40,6 @@ from browser_cli.client import active_browser_targets, remote_browser_targets, s from browser_cli.errors import BrowserNotConnected from browser_cli.models import BrowserCounts, Group, Tab from browser_cli.sdk import ( - CookiesNS, DecoratorsNS, DomNS, ExtensionNS, @@ -85,7 +83,6 @@ class BrowserCLI(FactoryMixin, RoutingMixin): extract: ExtractNS page: PageNS storage: StorageNS - cookies: CookiesNS session: SessionNS perf: PerfNS extension: ExtensionNS diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 734589a..f8aceff 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -20,7 +20,6 @@ from browser_cli.commands.session import session_group from browser_cli.commands.search import search_group from browser_cli.commands.page import page_group from browser_cli.commands.storage import storage_group -from browser_cli.commands.cookies import cookies_group from browser_cli.commands.perf import perf_group from browser_cli.commands.extension import extension_group from browser_cli.commands.serve import cmd_serve @@ -29,6 +28,14 @@ from browser_cli.commands.auth import auth_group from browser_cli.commands.clients import clients_group from browser_cli.commands.completion import cmd_completion from browser_cli.commands.install import cmd_install +from browser_cli.commands.doctor import cmd_doctor +from browser_cli.commands.events import cmd_events +from browser_cli.commands.remote import remote_group +from browser_cli.commands.script import cmd_script +from browser_cli.commands.serve_http import cmd_serve_http +from browser_cli.commands.watch import watch_group +from browser_cli.commands.workspace import workspace_group +from browser_cli.commands.raw import cmd_command console = Console() @@ -118,7 +125,6 @@ main.add_command(session_group) main.add_command(search_group) main.add_command(page_group) main.add_command(storage_group) -main.add_command(cookies_group) main.add_command(perf_group) main.add_command(extension_group) main.add_command(cmd_serve) @@ -126,6 +132,14 @@ main.add_command(cmd_link_serve) main.add_command(clients_group) main.add_command(cmd_completion) main.add_command(cmd_install) +main.add_command(cmd_doctor) +main.add_command(cmd_events) +main.add_command(remote_group) +main.add_command(cmd_script) +main.add_command(cmd_serve_http) +main.add_command(watch_group) +main.add_command(workspace_group) +main.add_command(cmd_command) # ── native-host (hidden, called by Chrome via native messaging) ──────────────── diff --git a/browser_cli/command_security.py b/browser_cli/command_security.py new file mode 100644 index 0000000..22ea8b5 --- /dev/null +++ b/browser_cli/command_security.py @@ -0,0 +1,119 @@ +"""Safety policy for generic command execution surfaces. + +Dedicated first-party CLI/SDK methods keep their normal behavior. This module +only gates raw surfaces where a single string can trigger arbitrary browser +capabilities: ``browser-cli command``, ``browser-cli script``, and the HTTP +``/command`` endpoint. +""" +from __future__ import annotations + +from dataclasses import dataclass + +SAFE_COMMANDS = { + "clients.list", + "extension.capabilities", + "extension.info", + "group.list", + "group.query", + "group.tabs", + "page.info", + "perf.status", + "tabs.active_in_window", + "tabs.count", + "tabs.filter", + "tabs.list", + "tabs.query", + "tabs.status", + "windows.list", +} + +READ_PAGE_COMMANDS = { + "dom.attr", + "dom.exists", + "dom.query", + "dom.text", + "extract.html", + "extract.images", + "extract.json", + "extract.links", + "extract.markdown", + "extract.text", + "tabs.html", +} + +CONTROL_PREFIXES = ( + "navigate.", + "nav.", + "group.", + "session.", + "tabs.", + "windows.", +) +CONTROL_COMMANDS = { + "dom.check", + "dom.clear", + "dom.click", + "dom.focus", + "dom.hover", + "dom.key", + "dom.poll", + "dom.scroll", + "dom.select", + "dom.submit", + "dom.type", + "dom.uncheck", + "dom.wait_for", + "extension.reload", +} + +DANGEROUS_COMMANDS = { + "dom.eval", + "tabs.screenshot", +} +DANGEROUS_PREFIXES = ( + "storage.", +) + +@dataclass(frozen=True) +class CommandPolicy: + allow_read_page: bool = False + allow_control: bool = False + allow_dangerous: bool = False + + @classmethod + def unrestricted(cls) -> "CommandPolicy": + return cls(allow_read_page=True, allow_control=True, allow_dangerous=True) + +def _is_control(command: str) -> bool: + if command in CONTROL_COMMANDS: + return True + if any(command.startswith(prefix) for prefix in CONTROL_PREFIXES): + return command not in SAFE_COMMANDS and command not in READ_PAGE_COMMANDS and command not in DANGEROUS_COMMANDS + return False + +def command_category(command: str) -> str: + name = str(command or "") + if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES): + return "dangerous" + if name in READ_PAGE_COMMANDS: + return "read-page" + if name in SAFE_COMMANDS: + return "safe" + if _is_control(name): + return "control" + return "unknown" + +def assert_command_allowed(command: str, policy: CommandPolicy) -> None: + category = command_category(command) + if category == "safe": + return + if category == "read-page" and policy.allow_read_page: + return + if category == "control" and policy.allow_control: + return + if category == "dangerous" and policy.allow_dangerous: + return + raise PermissionError( + f"Raw command '{command}' is {category} and blocked by default; " + "use --allow-read-page, --allow-control, or --allow-dangerous explicitly" + ) diff --git a/browser_cli/commands/__init__.py b/browser_cli/commands/__init__.py index 0dca4af..e6a6b2f 100644 --- a/browser_cli/commands/__init__.py +++ b/browser_cli/commands/__init__.py @@ -71,6 +71,9 @@ def handle_errors(fn): except BrowserNotConnected as e: _console.print(f"[red]Error:[/red] {e}") raise SystemExit(1) + except PermissionError as e: + _console.print(f"[red]Blocked:[/red] {e}") + raise SystemExit(1) except RuntimeError as e: _console.print(f"[red]Browser error:[/red] {e}") raise SystemExit(1) diff --git a/browser_cli/commands/cookies.py b/browser_cli/commands/cookies.py deleted file mode 100644 index df2f4a8..0000000 --- a/browser_cli/commands/cookies.py +++ /dev/null @@ -1,72 +0,0 @@ -import click -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 = client_from_ctx().cookies.list(url=url, domain=domain, name=name) - if not cookies: - console.print("[yellow]No cookies found[/yellow]") - return - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Name") - table.add_column("Value") - table.add_column("Domain") - table.add_column("Path") - table.add_column("Secure", width=7) - table.add_column("HttpOnly", width=9) - for c in cookies: - table.add_row( - c.get("name", ""), - (c.get("value") or "")[:60], - c.get("domain", ""), - c.get("path", ""), - "[green]✓[/green]" if c.get("secure") else "", - "[green]✓[/green]" if c.get("httpOnly") else "", - ) - 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 = 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") -@click.argument("value") -@click.option("--domain", default=None) -@click.option("--path", default=None) -@click.option("--secure", is_flag=True) -@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.""" - 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}") diff --git a/browser_cli/commands/doctor.py b/browser_cli/commands/doctor.py new file mode 100644 index 0000000..79072e5 --- /dev/null +++ b/browser_cli/commands/doctor.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re +import shutil +from importlib.metadata import PackageNotFoundError, version as package_version +from pathlib import Path + +import click +from rich.console import Console +from rich.table import Table + +from browser_cli.commands import handle_errors, client_from_ctx +from browser_cli.client import active_browser_targets +from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME +from browser_cli.platform import is_windows + +console = Console() + +def _project_version() -> str: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + try: + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + if match: + return match.group(1) + except OSError: + pass + try: + return package_version("browser-cli") + except PackageNotFoundError: + return "unknown" + +def _status(ok: bool) -> str: + return "[green]OK[/green]" if ok else "[red]FAIL[/red]" + +@click.command("doctor") +@click.option("--remote", "check_remote", is_flag=True, help="Also probe the configured remote endpoint") +@handle_errors +def cmd_doctor(check_remote): + """Diagnose browser-cli installation, extension, and connection health.""" + rows: list[tuple[str, bool, str]] = [] + version = _project_version() + rows.append(("Python package", version != "unknown", version)) + rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH")) + + manifest_notes = [] + if not is_windows(): + import sys + platform = "darwin" if sys.platform == "darwin" else "linux" + for browser, by_platform in NATIVE_HOST_DIRS.items(): + for directory in by_platform.get(platform, []): + path = directory / f"{NATIVE_HOST_NAME}.json" + if path.exists(): + manifest_notes.append(f"{browser}: {path}") + rows.append(("Native host manifest", bool(manifest_notes), "; ".join(manifest_notes) or "not found for common browsers")) + + try: + targets = active_browser_targets(include_remotes=check_remote) + rows.append(("Browser registry", bool(targets), f"{len(targets)} active target(s)")) + except Exception as exc: + rows.append(("Browser registry", False, str(exc))) + + client = client_from_ctx() + try: + clients = client.clients() + rows.append(("Connection", True, f"{len(clients)} client(s) responded")) + ext_versions = sorted({str(c.get("extensionVersion", "unknown")) for c in clients if isinstance(c, dict)}) + if ext_versions: + rows.append(("Extension version", version in ext_versions, ", ".join(ext_versions))) + except Exception as exc: + rows.append(("Connection", False, str(exc))) + + try: + info = client.extension.info() + caps = info.get("capabilities") or [] + rows.append(("Extension info", True, f"v{info.get('version', 'unknown')} · {len(caps)} capabilities")) + except Exception as exc: + rows.append(("Extension info", False, f"not available ({exc})")) + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Check") + table.add_column("Status") + table.add_column("Details") + for name, ok, detail in rows: + table.add_row(name, _status(ok), detail) + console.print(table) + + failed = [name for name, ok, _ in rows if not ok and name in {"Connection", "Browser registry"}] + if failed: + raise SystemExit(1) diff --git a/browser_cli/commands/events.py b/browser_cli/commands/events.py new file mode 100644 index 0000000..d886a26 --- /dev/null +++ b/browser_cli/commands/events.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import time +from dataclasses import asdict, is_dataclass + +import click +from rich.console import Console + +from browser_cli.commands import client_from_ctx, handle_errors + +console = Console() + +def _snapshot(client): + tabs = client.tabs.list() + return {str(t.id): asdict(t) if is_dataclass(t) else dict(t) for t in tabs} + +def _emit(event, json_output: bool): + if json_output: + click.echo(json.dumps(event, default=str), flush=True) + else: + kind = event.get("type") + tab = event.get("tab") or {} + console.print(f"[cyan]{kind}[/cyan] {tab.get('id', '')} {tab.get('title') or ''} [dim]{tab.get('url') or ''}[/dim]") + +@click.command("events") +@click.option("--interval", type=float, default=1.0, show_default=True, help="Polling interval in seconds") +@click.option("--once", is_flag=True, help="Emit initial snapshot and exit") +@click.option("--json", "json_output", is_flag=True, default=True, help="Emit JSON Lines (default)") +@click.option("--pretty", is_flag=True, help="Render human-readable events instead of JSON") +@handle_errors +def cmd_events(interval: float, once: bool, json_output: bool, pretty: bool): + """Stream tab events as JSON Lines using a lightweight polling watcher.""" + json_output = json_output and not pretty + client = client_from_ctx() + previous = _snapshot(client) + for tab in previous.values(): + _emit({"type": "tabs.snapshot", "tab": tab}, json_output) + if once: + return + while True: + time.sleep(interval) + current = _snapshot(client) + for tab_id, tab in current.items(): + if tab_id not in previous: + _emit({"type": "tabs.created", "tab": tab}, json_output) + elif tab != previous[tab_id]: + _emit({"type": "tabs.updated", "tab": tab, "previous": previous[tab_id]}, json_output) + for tab_id, tab in previous.items(): + if tab_id not in current: + _emit({"type": "tabs.closed", "tab": tab}, json_output) + previous = current diff --git a/browser_cli/commands/extension.py b/browser_cli/commands/extension.py index 6a87291..3f75842 100644 --- a/browser_cli/commands/extension.py +++ b/browser_cli/commands/extension.py @@ -8,6 +8,27 @@ console = Console() def extension_group(): """Manage the browser-cli browser extension.""" +@extension_group.command("info") +@handle_errors +def extension_info(): + """Show extension version and advertised capabilities.""" + info = client_from_ctx().extension.info() + for key in ("name", "version", "id", "platform"): + if key in info: + console.print(f"[bold]{key}:[/bold] {info[key]}") + caps = info.get("capabilities") or [] + if caps: + console.print("[bold]capabilities:[/bold]") + for cap in caps: + console.print(f" - {cap}") + +@extension_group.command("capabilities") +@handle_errors +def extension_capabilities(): + """Print extension feature capability strings.""" + for cap in client_from_ctx().extension.capabilities(): + console.print(cap) + @extension_group.command("reload") @handle_errors def extension_reload(): diff --git a/browser_cli/commands/link_serve.py b/browser_cli/commands/link_serve.py index f6bf0f1..d3099d2 100644 --- a/browser_cli/commands/link_serve.py +++ b/browser_cli/commands/link_serve.py @@ -133,19 +133,19 @@ _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} @click.option("--token", default=None, metavar="SECRET", help="Shared bearer token required from callers (sent as 'Authorization: Bearer ...').") @click.option("--insecure", is_flag=True, default=False, - help="Run with NO token. Grants full browser control (cookies, pages) to anyone who can reach the port.") + help="Run with NO token. Grants full browser control to anyone who can reach the port.") @click.pass_context def cmd_link_serve(ctx, host, port, token, insecure): """Serve this browser to the ServiceLink mesh over HTTP /rpc. - Exposes the running browser (open/scrape pages, read cookies and storage), so + Exposes the running browser (open/scrape pages, read storage), so a token is required by default. Bind to loopback and keep the port off the public network. """ if not token and not insecure: raise click.ClickException( "Refusing to start without --token (this endpoint can control your browser " - "and read its cookies). Pass --insecure to override on a trusted host." + "and read page/storage data). Pass --insecure to override on a trusted host." ) if host not in _LOOPBACK_HOSTS: click.echo( diff --git a/browser_cli/commands/navigate.py b/browser_cli/commands/navigate.py index 662c9d5..acbc925 100644 --- a/browser_cli/commands/navigate.py +++ b/browser_cli/commands/navigate.py @@ -13,10 +13,13 @@ def nav_group(): @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front") @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)") +@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL") +@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain") +@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT") @handle_errors -def cmd_open(url, focus, window_name, group_name): +def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title): """Open URL in a new tab without stealing focus by default.""" - client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name) + client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title) suffix = "" if group_name: suffix = f" in group '{group_name}'" @@ -73,10 +76,13 @@ def cmd_focus(pattern): @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front") @click.option("--window", "window_name", default=None, help="Open in named window") @click.option("--group", "group_name", default=None, help="Open in tab group") +@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL") +@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain") +@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT") @handle_errors -def cmd_open_wait(url, timeout, focus, window_name, group_name): +def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title): """Open URL in a new tab and wait until fully loaded.""" - tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name) + tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title) console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else "")) @nav_group.command("wait") diff --git a/browser_cli/commands/raw.py b/browser_cli/commands/raw.py new file mode 100644 index 0000000..9573f24 --- /dev/null +++ b/browser_cli/commands/raw.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json + +import click + +from browser_cli.command_security import CommandPolicy, assert_command_allowed +from browser_cli.commands import client_from_ctx, handle_errors + +@click.command("command") +@click.argument("name") +@click.argument("args_json", required=False, default="{}") +@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text") +@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click") +@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots") +@handle_errors +def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous): + """Send a raw browser-cli wire command and print JSON.""" + policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous) + assert_command_allowed(name, policy) + args = json.loads(args_json) if args_json else {} + result = client_from_ctx().command(name, args) + click.echo(json.dumps(result, indent=2, default=str)) diff --git a/browser_cli/commands/remote.py b/browser_cli/commands/remote.py new file mode 100644 index 0000000..7fb0fdd --- /dev/null +++ b/browser_cli/commands/remote.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json + +import click +from rich.console import Console +from rich.table import Table + +from browser_cli import BrowserCLI +from browser_cli.commands import handle_errors +from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key + +console = Console() + +@click.group("remote") +def remote_group(): + """Manage remembered browser-cli remote endpoints.""" + +@remote_group.command("status") +@click.argument("endpoint") +@click.option("--key", default=None, help="Key spec/path to use for this probe") +@handle_errors +def remote_status(endpoint, key): + """Probe a remote endpoint and show server/client status.""" + client = BrowserCLI(remote=endpoint, key=key) + clients = client.clients() + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Profile") + table.add_column("Browser") + table.add_column("Extension") + for item in clients: + table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", ""))) + console.print(table) + +@remote_group.command("trust") +@click.argument("endpoint") +@click.argument("key_spec") +def remote_trust(endpoint, key_spec): + """Remember which key spec to use for ENDPOINT.""" + save_remote_key(endpoint, key_spec) + console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]") + +@remote_group.command("keys") +def remote_keys(): + """List remembered remote key specs.""" + remotes = load_remotes() + if not remotes: + console.print("[yellow]No remembered remotes[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Endpoint") + table.add_column("Key") + for endpoint, cfg in sorted(remotes.items()): + table.add_row(endpoint, str(cfg.get("key", ""))) + console.print(table) + +@remote_group.command("revoke") +@click.argument("endpoint") +def remote_revoke(endpoint): + """Remove remembered key/config for ENDPOINT.""" + remotes = load_remotes() + if endpoint not in remotes: + console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]") + return + del remotes[endpoint] + REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True) + REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8") + console.print(f"[green]Revoked {endpoint}[/green]") diff --git a/browser_cli/commands/script.py b/browser_cli/commands/script.py new file mode 100644 index 0000000..9a64349 --- /dev/null +++ b/browser_cli/commands/script.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import click +from rich.console import Console + +from browser_cli.command_security import CommandPolicy, assert_command_allowed +from browser_cli.commands import client_from_ctx, handle_errors + +console = Console() + +def _load_steps(path: Path): + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + try: + import yaml # type: ignore + except Exception as exc: + raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc + return yaml.safe_load(text) + return json.loads(text) + +def _parse_step(step): + if isinstance(step, str): + return step, {} + if isinstance(step, dict): + if "command" in step: + return step["command"], step.get("args") or {} + if len(step) == 1: + command, args = next(iter(step.items())) + return command, args or {} + raise click.ClickException(f"Invalid script step: {step!r}") + +@click.command("script") +@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON") +@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps") +@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text") +@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click") +@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots") +@handle_errors +def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool): + """Run a JSON/YAML batch script of browser-cli wire commands.""" + steps = _load_steps(file) + if not isinstance(steps, list): + raise click.ClickException("Script root must be a list") + client = client_from_ctx() + policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous) + results = [] + for index, step in enumerate(steps, start=1): + command, args = _parse_step(step) + try: + assert_command_allowed(command, policy) + result = client.command(command, args) + results.append({"index": index, "command": command, "ok": True, "result": result}) + if not json_output: + console.print(f"[green]✓[/green] {index}: {command}") + except Exception as exc: + results.append({"index": index, "command": command, "ok": False, "error": str(exc)}) + if not continue_on_error: + if json_output: + click.echo(json.dumps(results, indent=2, default=str)) + raise + if not json_output: + console.print(f"[red]✗[/red] {index}: {command}: {exc}") + if json_output: + click.echo(json.dumps(results, indent=2, default=str)) diff --git a/browser_cli/commands/serve.py b/browser_cli/commands/serve.py index b8ce2ea..64611dc 100644 --- a/browser_cli/commands/serve.py +++ b/browser_cli/commands/serve.py @@ -87,7 +87,7 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_po if rpc and not rpc_token and not rpc_insecure: console.print( "[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your " - "browser and read its cookies). Use --rpc-insecure to override on a trusted host." + "browser and read page/storage data). Use --rpc-insecure to override on a trusted host." ) sys.exit(1) diff --git a/browser_cli/commands/serve_http.py b/browser_cli/commands/serve_http.py new file mode 100644 index 0000000..09b8e38 --- /dev/null +++ b/browser_cli/commands/serve_http.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import secrets +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +import click +from rich.console import Console + +from browser_cli import BrowserCLI +from browser_cli.command_security import CommandPolicy, assert_command_allowed + +console = Console() + +def _is_loopback(host: str) -> bool: + return host in {"127.0.0.1", "localhost", "::1"} + +class _Handler(BaseHTTPRequestHandler): + client: BrowserCLI + token: str | None = None + policy: CommandPolicy = CommandPolicy() + + def _authorized(self) -> bool: + if self.token is None: + return True + if self.headers.get("Authorization", "") == f"Bearer {self.token}": + return True + return self.headers.get("X-Browser-CLI-Token") == self.token + + def _require_auth(self) -> bool: + if self._authorized(): + return True + self._send(401, {"error": "missing or invalid token"}) + return False + + def _send(self, status: int, payload): + raw = json.dumps(payload, default=str).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + path = urlparse(self.path).path + try: + if path != "/health" and not self._require_auth(): + return + if path == "/tabs": + self._send(200, [t.__dict__ for t in self.client.tabs.list()]) + elif path == "/clients": + self._send(200, self.client.clients()) + elif path == "/health": + self._send(200, {"ok": True}) + else: + self._send(404, {"error": "not found"}) + except Exception as exc: + self._send(500, {"error": str(exc)}) + + def do_POST(self): + path = urlparse(self.path).path + try: + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + if path == "/command": + if not self._require_auth(): + return + command = body.get("command") + assert_command_allowed(command, self.policy) + self._send(200, {"result": self.client.command(command, body.get("args") or {})}) + else: + self._send(404, {"error": "not found"}) + except PermissionError as exc: + self._send(403, {"error": str(exc)}) + except Exception as exc: + self._send(500, {"error": str(exc)}) + + def log_message(self, fmt, *args): + console.print(f"[dim]http[/dim] {self.address_string()} {fmt % args}") + +@click.command("serve-http") +@click.option("--host", default="127.0.0.1", show_default=True) +@click.option("--port", type=int, default=8766, show_default=True) +@click.option("--browser", default=None, help="Browser alias to target") +@click.option("--remote", default=None, help="Remote endpoint to target") +@click.option("--key", default=None, help="Remote auth key spec") +@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)") +@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)") +@click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands") +@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands") +@click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands") +def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous): + """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command). + + Auth is enabled by default. Pass the printed token as either + ``Authorization: Bearer `` or ``X-Browser-CLI-Token: ``. + """ + if no_auth and not _is_loopback(host): + raise click.ClickException("--no-auth is only allowed on loopback hosts") + auth_token = None if no_auth else (token or secrets.token_urlsafe(32)) + policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous) + handler = type( + "BrowserCLIHTTPHandler", + (_Handler,), + {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy}, + ) + server = ThreadingHTTPServer((host, port), handler) + console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]") + if auth_token: + console.print(f"[yellow]Token:[/yellow] {auth_token}") + try: + server.serve_forever() + except KeyboardInterrupt: + console.print("\n[yellow]Stopping HTTP gateway[/yellow]") diff --git a/browser_cli/commands/session.py b/browser_cli/commands/session.py index 44e5b89..7312b82 100644 --- a/browser_cli/commands/session.py +++ b/browser_cli/commands/session.py @@ -1,3 +1,5 @@ +import json +from pathlib import Path import click from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors from rich.console import Console @@ -44,6 +46,33 @@ def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, b 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("export") +@click.argument("name", required=False) +@click.option("-o", "output", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Write JSON to file instead of stdout") +@handle_errors +def session_export(name, output): + """Export one saved session, or all sessions as JSON.""" + data = client_from_ctx().session.export(name) + text = json.dumps(data, indent=2, sort_keys=True) + if output: + output.write_text(text + "\n", encoding="utf-8") + console.print(f"[green]Exported session data to {output}[/green]") + else: + click.echo(text) + +@session_group.command("import") +@click.argument("name") +@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--overwrite", is_flag=True, help="Replace an existing saved session") +@handle_errors +def session_import(name, file, overwrite): + """Import a saved session JSON file.""" + payload = json.loads(file.read_text(encoding="utf-8")) + session = payload.get("session", payload) if isinstance(payload, dict) else payload + result = client_from_ctx().session.import_(name, session, overwrite=overwrite) + count = result.get("tabs", 0) if isinstance(result, dict) else 0 + console.print(f"[green]Imported session '{name}'[/green] ({count} tabs)") + @session_group.command("diff") @click.argument("name_a") @click.argument("name_b") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index 00277e2..455d707 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -4,6 +4,7 @@ import click 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 +from rich.tree import Tree console = Console() @@ -46,6 +47,34 @@ def tabs_list(): tabs = client_from_ctx().tabs.list() _print_tabs(tabs, show_browser=any(t.browser for t in tabs)) +@tabs_group.command("tree") +@handle_errors +def tabs_tree(): + """Show tabs grouped as a window/group tree.""" + tabs = sorted(client_from_ctx().tabs.list(), key=lambda t: ((t.browser or ""), t.window_id, t.group_id if t.group_id is not None else -1, t.index)) + root = Tree("[bold]Tabs[/bold]") + browsers = {} + windows = {} + groups = {} + show_browser = any(t.browser for t in tabs) + for tab in tabs: + browser_key = tab.browser or "local" + browser_node = browsers.setdefault(browser_key, root.add(f"[bold cyan]{browser_key}[/bold cyan]") if show_browser else root) + win_key = (browser_key, tab.window_id) + win_node = windows.get(win_key) + if win_node is None: + win_node = browser_node.add(f"Window {tab.window_id}") + windows[win_key] = win_node + group_label = f"Group {tab.group_id}" if tab.group_id is not None else "Ungrouped" + group_key = (browser_key, tab.window_id, group_label) + group_node = groups.get(group_key) + if group_node is None: + group_node = win_node.add(group_label) + groups[group_key] = group_node + active = " [green]*[/green]" if tab.active else "" + group_node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") + console.print(root) + @tabs_group.command("close") @click.argument("tab_id", type=int, required=False) @click.option("--inactive", is_flag=True, help="Close all inactive tabs") diff --git a/browser_cli/commands/watch.py b/browser_cli/commands/watch.py new file mode 100644 index 0000000..6499563 --- /dev/null +++ b/browser_cli/commands/watch.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import time + +import click + +from browser_cli.commands import client_from_ctx, handle_errors + +@click.group("watch") +def watch_group(): + """Watch browser state and print changes.""" + +@watch_group.command("tabs") +@click.option("--interval", type=float, default=1.0, show_default=True) +@click.option("--once", is_flag=True) +@handle_errors +def watch_tabs(interval, once): + """Watch the tab list as JSON snapshots.""" + client = client_from_ctx() + previous = None + while True: + current = [t.__dict__ for t in client.tabs.list()] + if current != previous: + click.echo(json.dumps({"type": "tabs", "tabs": current}, default=str), flush=True) + previous = current + if once: + return + time.sleep(interval) + +@watch_group.command("page") +@click.option("--field", default=None, help="Only print a single page.info field") +@click.option("--interval", type=float, default=1.0, show_default=True) +@handle_errors +def watch_page(field, interval): + """Watch page.info for the active tab.""" + client = client_from_ctx() + previous = object() + while True: + info = client.page.info() + current = info.get(field) if field else info + if current != previous: + click.echo(json.dumps({"type": "page", "field": field, "value": current}, default=str), flush=True) + previous = current + time.sleep(interval) + +@watch_group.command("dom") +@click.argument("selector") +@click.option("--interval", type=float, default=1.0, show_default=True) +@handle_errors +def watch_dom(selector, interval): + """Watch textContent for a selector.""" + client = client_from_ctx() + previous = object() + while True: + current = client.dom.text(selector) + if current != previous: + click.echo(json.dumps({"type": "dom", "selector": selector, "text": current}, default=str), flush=True) + previous = current + time.sleep(interval) diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py index 1ad33c4..7e43c80 100644 --- a/browser_cli/commands/windows.py +++ b/browser_cli/commands/windows.py @@ -2,6 +2,7 @@ import click from browser_cli.commands import client_from_ctx, handle_errors from rich.console import Console from rich.table import Table +from rich.tree import Tree console = Console() @@ -38,6 +39,27 @@ def windows_list(): windows = client_from_ctx().windows.list() _print_windows(windows, show_browser=any("browser" in w for w in windows)) +@windows_group.command("tree") +@handle_errors +def windows_tree(): + """Show windows and their tabs as a tree.""" + client = client_from_ctx() + windows = client.windows.list() + tabs = client.tabs.list() + root = Tree("[bold]Windows[/bold]") + for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): + wid = w.get("id") + label = f"Window {wid}" + if w.get("alias"): + label += f" ({w['alias']})" + if w.get("browser"): + label = f"{w['browser']}: " + label + node = root.add(label) + for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: t.index): + active = " [green]*[/green]" if tab.active else "" + node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") + console.print(root) + @windows_group.command("rename") @click.argument("window_id", type=int) @click.argument("name") diff --git a/browser_cli/commands/workspace.py b/browser_cli/commands/workspace.py new file mode 100644 index 0000000..e666484 --- /dev/null +++ b/browser_cli/commands/workspace.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import click +from rich.console import Console +from rich.table import Table + +from browser_cli.commands import client_from_ctx, handle_errors +from browser_cli.constants import CONFIG_DIR + +console = Console() +WORKSPACES_PATH = CONFIG_DIR / "workspaces.json" + +def _load() -> dict: + try: + return json.loads(WORKSPACES_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + +def _save(data: dict) -> None: + WORKSPACES_PATH.parent.mkdir(parents=True, exist_ok=True) + WORKSPACES_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + +@click.group("workspace") +def workspace_group(): + """Named browser workspaces built on top of sessions.""" + +@workspace_group.command("save") +@click.argument("name") +@click.option("--session", "session_name", default=None, help="Session name to save/use (default: workspace name)") +@click.option("--profile", default=None, help="Performance profile to remember") +@handle_errors +def workspace_save(name, session_name, profile): + session_name = session_name or name + result = client_from_ctx().session.save(session_name) + data = _load() + data[name] = {"session": session_name, "profile": profile} + _save(data) + console.print(f"[green]Workspace '{name}' saved[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)") + +@workspace_group.command("load") +@click.argument("name") +@click.option("--lazy", is_flag=True, help="Lazy-restore tabs") +@click.option("--eager-tabs", type=int, default=10, show_default=True) +@handle_errors +def workspace_load(name, lazy, eager_tabs): + data = _load() + ws = data.get(name) + if not ws: + raise click.ClickException(f"Workspace '{name}' not found") + client = client_from_ctx() + if ws.get("profile"): + client.perf.set_profile(ws["profile"]) + result = client.session.load(ws["session"], lazy=lazy, eager_tabs=eager_tabs) + console.print(f"[green]Workspace '{name}' loaded[/green] ({result.get('tabs', 0) if isinstance(result, dict) else 0} tabs)") + +@workspace_group.command("switch") +@click.argument("name") +@click.option("--lazy", is_flag=True) +@handle_errors +def workspace_switch(name, lazy): + """Load a workspace. Alias for workspace load.""" + ctx = click.get_current_context() + ctx.invoke(workspace_load, name=name, lazy=lazy, eager_tabs=10) + +@workspace_group.command("list") +def workspace_list(): + """List configured workspaces.""" + data = _load() + if not data: + console.print("[yellow]No workspaces[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Name") + table.add_column("Session") + table.add_column("Profile") + for name, ws in sorted(data.items()): + table.add_row(name, ws.get("session", ""), ws.get("profile") or "") + console.print(table) + +@workspace_group.command("remove") +@click.argument("name") +def workspace_remove(name): + data = _load() + if name not in data: + raise click.ClickException(f"Workspace '{name}' not found") + del data[name] + _save(data) + console.print(f"[green]Workspace '{name}' removed[/green]") diff --git a/browser_cli/constants.py b/browser_cli/constants.py index 8cc12b9..231a82f 100644 --- a/browser_cli/constants.py +++ b/browser_cli/constants.py @@ -39,7 +39,6 @@ PAGEABLE_COMMANDS = { "extract.links", "extract.images", "extract.json", - "cookies.list", "session.list", } diff --git a/browser_cli/sdk/__init__.py b/browser_cli/sdk/__init__.py index c8a86f6..b4db265 100644 --- a/browser_cli/sdk/__init__.py +++ b/browser_cli/sdk/__init__.py @@ -4,7 +4,7 @@ Each namespace groups related browser commands under a short accessor on the client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups in the browser extension. """ -from browser_cli.sdk.browser_data import CookiesNS, StorageNS +from browser_cli.sdk.browser_data import StorageNS from browser_cli.sdk.decorators import DecoratorsNS from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS from browser_cli.sdk.extension import ExtensionNS @@ -24,7 +24,6 @@ NAMESPACE_SPECS = ( ("extract", ExtractNS), ("page", PageNS), ("storage", StorageNS), - ("cookies", CookiesNS), ("session", SessionNS), ("perf", PerfNS), ("extension", ExtensionNS), @@ -40,7 +39,6 @@ __all__ = [ "ExtractNS", "PageNS", "StorageNS", - "CookiesNS", "SessionNS", "PerfNS", "ExtensionNS", diff --git a/browser_cli/sdk/browser_data.py b/browser_cli/sdk/browser_data.py index 0b04e0e..8929c4c 100644 --- a/browser_cli/sdk/browser_data.py +++ b/browser_cli/sdk/browser_data.py @@ -1,4 +1,4 @@ -"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``.""" +"""Storage namespace: ``b.storage.*``.""" from __future__ import annotations from browser_cli.sdk.base import Namespace, sdk_command @@ -35,51 +35,3 @@ class StorageNS(Namespace): tab_id: int | None = None, ) -> None: """Set a localStorage/sessionStorage entry.""" - -class CookiesNS(Namespace): - """List, get, and set cookies.""" - - @sdk_command("cookies.list", lambda self, *, url=None, domain=None, name=None: { - "url": url, - "domain": domain, - "name": name, - }, default=[]) - def list( - self, - *, - url: str | None = None, - domain: str | None = None, - name: str | None = None, - ) -> list[dict]: - """List cookies, optionally filtered by url, domain, or name.""" - - @sdk_command("cookies.get", lambda self, url, name: {"url": url, "name": name}) - def get(self, url: str, name: str) -> dict | None: - """Get a single cookie by url and name.""" - - @sdk_command("cookies.set", lambda self, url, name, value, *, domain=None, path=None, secure=None, - http_only=None, expiration_date=None, same_site=None: { - "url": url, - "name": name, - "value": value, - "domain": domain, - "path": path, - "secure": secure, - "httpOnly": http_only, - "expirationDate": expiration_date, - "sameSite": same_site, - }) - def set( - self, - url: str, - name: str, - value: str, - *, - domain: str | None = None, - path: str | None = None, - secure: bool | None = None, - http_only: bool | None = None, - expiration_date: float | None = None, - same_site: str | None = None, - ) -> dict: - """Set a cookie. Returns the created cookie dict.""" diff --git a/browser_cli/sdk/extension.py b/browser_cli/sdk/extension.py index 9758b6f..b1bba95 100644 --- a/browser_cli/sdk/extension.py +++ b/browser_cli/sdk/extension.py @@ -6,6 +6,14 @@ from browser_cli.sdk.base import Namespace, sdk_command class ExtensionNS(Namespace): """Control the browser-cli extension itself.""" + @sdk_command("extension.info", default={}) + def info(self) -> dict: + """Return extension version, runtime metadata, and capabilities.""" + + @sdk_command("extension.capabilities", default=[]) + def capabilities(self) -> list[str]: + """Return feature capability strings advertised by the extension.""" + @sdk_command("extension.reload") def reload(self) -> None: """Reload the browser-cli extension service worker. diff --git a/browser_cli/sdk/navigation.py b/browser_cli/sdk/navigation.py index a85c47a..6678cbf 100644 --- a/browser_cli/sdk/navigation.py +++ b/browser_cli/sdk/navigation.py @@ -4,7 +4,7 @@ from __future__ import annotations from browser_cli.models import Tab from browser_cli.sdk.base import Namespace, sdk_command -def _open_args(self, url, *, background=False, focus=False, window=None, group=None): +def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored): return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group} def _tab_args(self, tab_id=None): @@ -13,7 +13,6 @@ def _tab_args(self, tab_id=None): class NavigationNS(Namespace): """Open URLs, navigate history, and focus tabs.""" - @sdk_command("navigate.open", _open_args) def open( self, url: str, @@ -22,8 +21,23 @@ class NavigationNS(Namespace): focus: bool = False, window: str | None = None, group: str | None = None, + reuse: bool = False, + reuse_domain: bool = False, + reuse_title: str | None = None, ) -> None: - """Open *url* in a new tab without stealing OS focus by default.""" + """Open *url* in a new tab without stealing OS focus by default. + + ``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab + instead of creating a new one. + """ + tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title) + if tab is not None: + self.to(tab.id, url) + if focus: + self._c.tabs.activate(tab.id) + return None + self.command("navigate.open", _open_args(self, url, background=background, focus=focus, window=window, group=group)) + return None def open_wait( self, @@ -34,8 +48,17 @@ class NavigationNS(Namespace): focus: bool = False, window: str | None = None, group: str | None = None, + reuse: bool = False, + reuse_domain: bool = False, + reuse_title: str | None = None, ) -> Tab: """Open *url* in a new tab and block until fully loaded. Returns the Tab.""" + tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title) + if tab is not None: + self.to(tab.id, url) + if focus: + self._c.tabs.activate(tab.id) + return self._c.tabs.wait_for_load(tab.id, timeout=timeout) return self.require_tab( self.command("navigate.open_wait", { "url": url, "timeout": int(timeout * 1000), @@ -68,6 +91,23 @@ class NavigationNS(Namespace): def to(self, tab_id: int, url: str) -> None: """Navigate a specific tab to *url* in place.""" + def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None): + if not (reuse or reuse_domain or reuse_title): + return None + from urllib.parse import urlparse + wanted = urlparse(url) + wanted_host = wanted.netloc.lower() + for tab in self._c.tabs.list(): + tab_url = tab.url or "" + parsed = urlparse(tab_url) + if reuse and tab_url == url: + return tab + if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host: + return tab + if reuse_title and reuse_title.lower() in (tab.title or "").lower(): + return tab + return None + def search( self, engine: str, query: str, *, background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None, diff --git a/browser_cli/sdk/session.py b/browser_cli/sdk/session.py index 3b16a40..b884633 100644 --- a/browser_cli/sdk/session.py +++ b/browser_cli/sdk/session.py @@ -48,6 +48,14 @@ class SessionNS(Namespace): def diff(self, name_a: str, name_b: str) -> dict: """Diff two saved sessions.""" + @sdk_command("session.export", lambda self, name=None: {"name": name}, default={}) + def export(self, name: str | None = None) -> dict: + """Export one saved session, or all sessions when *name* is omitted.""" + + @sdk_command("session.import", lambda self, name, session, overwrite=False: {"name": name, "session": session, "overwrite": overwrite}, default={}) + def import_(self, name: str, session: dict, *, overwrite: bool = False) -> dict: + """Import a saved session payload under *name*.""" + def list(self) -> list[dict]: """Return saved sessions. diff --git a/extension/manifest.json b/extension/manifest.json index 07758a6..653eea8 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.12.3", + "version": "0.14.1", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", @@ -10,8 +10,7 @@ "windows", "storage", "alarms", - "nativeMessaging", - "cookies" + "nativeMessaging" ], "host_permissions": [ "" diff --git a/extension/src/classes/CommandGroup.ts b/extension/src/classes/CommandGroup.ts index e291f9d..1f401a0 100644 --- a/extension/src/classes/CommandGroup.ts +++ b/extension/src/classes/CommandGroup.ts @@ -11,7 +11,7 @@ export interface CommandContext { jobs: JobManager; } /** * A command group bundles a set of related subcommands. `commands` is keyed by * the FULL command id (e.g. "tabs.close") so groups spanning multiple - * namespaces (dom/extract/page, storage/cookies, session/clients) register + * namespaces (dom/extract/page, storage, session/clients) register * uniformly. `namespace` is documentation/grouping only. */ export abstract class CommandGroup { diff --git a/extension/src/commands/browser-data.ts b/extension/src/commands/browser-data.ts index 75f9e76..6da0534 100644 --- a/extension/src/commands/browser-data.ts +++ b/extension/src/commands/browser-data.ts @@ -1,16 +1,13 @@ import { executeScript, isScriptableUrl, resolveTabUrl } from '../core'; import { CommandGroup } from '../classes/CommandGroup'; import type { CommandEntry } from '../classes/CommandGroup'; -import type { Json, StorageGetArgs, StorageSetArgs, CookiesListArgs, CookiesGetArgs, CookiesSetArgs } from '../types'; +import type { Json, StorageGetArgs, StorageSetArgs } from '../types'; export class BrowserDataCommands extends CommandGroup { readonly namespace = "storage"; readonly commands: Record = { "storage.get": (a: StorageGetArgs) => this.storageGet(a), "storage.set": (a: StorageSetArgs) => this.storageSet(a), - "cookies.list": (a: CookiesListArgs) => this.cookiesList(a), - "cookies.get": (a: CookiesGetArgs) => this.cookiesGet(a), - "cookies.set": (a: CookiesSetArgs) => this.cookiesSet(a), }; private async storageGet({ key, type = "local", tabId }: StorageGetArgs = {}) { @@ -49,26 +46,4 @@ export class BrowserDataCommands extends CommandGroup { return results[0]?.result ?? false; } - private async cookiesList({ url, domain, name }: CookiesListArgs = {}) { - const details: chrome.cookies.GetAllDetails = {}; - if (url) details.url = url; - if (domain) details.domain = domain; - if (name) details.name = name; - return await chrome.cookies.getAll(details); - } - - private async cookiesGet({ url, name }: CookiesGetArgs) { - return await chrome.cookies.get({ url, name }); - } - - private async cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite }: CookiesSetArgs = {}) { - const details: chrome.cookies.SetDetails = { url, name, value }; - if (domain != null) details.domain = domain; - if (path != null) details.path = path; - if (secure != null) details.secure = secure; - if (httpOnly != null) details.httpOnly = httpOnly; - if (expirationDate != null) details.expirationDate = expirationDate; - if (sameSite != null) details.sameSite = sameSite; - return await chrome.cookies.set(details); - } } diff --git a/extension/src/commands/extension.ts b/extension/src/commands/extension.ts index feea565..9fad573 100644 --- a/extension/src/commands/extension.ts +++ b/extension/src/commands/extension.ts @@ -8,5 +8,36 @@ export class ExtensionCommands extends CommandGroup { setTimeout(() => chrome.runtime.reload(), 200); return { reloading: true }; }, + "extension.info": () => this.extensionInfo(), + "extension.capabilities": () => this.capabilities(), }; + + private capabilities() { + return [ + "extension.info", + "extension.capabilities", + "navigate.open.focus", + "navigate.open.background", + "tabs.close.tabIds", + "tabs.merge_windows.audibleAware", + "session.export", + "session.import", + "jobs.progress", + "jobs.cancel", + "content-dispatch.bundle", + ]; + } + + private extensionInfo() { + const manifest = chrome.runtime.getManifest(); + return { + id: chrome.runtime.id, + name: manifest.name, + version: manifest.version, + manifestVersion: manifest.manifest_version, + browser: navigator.userAgent, + platform: navigator.platform, + capabilities: this.capabilities(), + }; + } } diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts index 78806f6..66fa619 100644 --- a/extension/src/commands/session.ts +++ b/extension/src/commands/session.ts @@ -3,7 +3,7 @@ import { CommandGroup } from '../classes/CommandGroup'; import { AutoSaveManager } from './autosave'; import { captureCurrentSession } from './session-snapshot'; import type { CommandEntry } from '../classes/CommandGroup'; -import type { LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs } from '../types'; +import type { Json, LazySessionMap, SessionSaveArgs, SessionRemoveArgs, SessionDiffArgs, SessionImportArgs, SessionLoadArgs, SessionAutoSaveArgs, ClientsRenameProfileArgs, StoredSession } from '../types'; function lazyPlaceholderUrl(url: string) { const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch])); @@ -19,6 +19,8 @@ export class SessionCommands extends CommandGroup { "session.list": () => this.sessionList(), "session.remove": (a: SessionRemoveArgs) => this.sessionRemove(a), "session.diff": (a: SessionDiffArgs) => this.sessionDiff(a), + "session.export": (a: SessionSaveArgs) => this.sessionExport(a), + "session.import": (a: SessionImportArgs) => this.sessionImport(a), "session.auto_save": (a: SessionAutoSaveArgs) => this.autoSaveManager.setEnabled(Boolean(a.enabled)), "clients.list": () => this.clientsList(), "clients.rename_profile": (a: ClientsRenameProfileArgs) => this.clientsRenameProfile(a), @@ -131,6 +133,31 @@ export class SessionCommands extends CommandGroup { }; } + private async sessionExport({ name }: SessionSaveArgs) { + const sessions = await getSessions(); + if (!name) return { sessions }; + const session = sessions[name]; + if (!session) throw new Error(`Session '${name}' not found`); + return { name, session }; + } + + private isSession(value: Json | undefined): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as object as StoredSession; + return Array.isArray(candidate.tabs) || Array.isArray(candidate.urls); + } + + private async sessionImport({ name, session, overwrite = false }: SessionImportArgs) { + if (!name) throw new Error("Session name is required"); + if (!this.isSession(session)) throw new Error("Session payload must contain tabs or urls"); + const sessions = await getSessions(); + if (!overwrite && sessions[name]) throw new Error(`Session '${name}' already exists`); + const stored = session as object as StoredSession; + sessions[name] = { ...stored, savedAt: Number(stored.savedAt) || Date.now() }; + await chrome.storage.local.set({ sessions }); + return { name, tabs: getSessionTabs(sessions[name]).length }; + } + private async clientsList() { const manifest = chrome.runtime.getManifest(); const alias = await getProfileAlias(); diff --git a/extension/src/types/command-args.ts b/extension/src/types/command-args.ts index 2976a44..f8bfddc 100644 --- a/extension/src/types/command-args.ts +++ b/extension/src/types/command-args.ts @@ -72,24 +72,11 @@ export type DomArgs = ContentArgs & { tabId?: number }; // ── Browser data ──────────────────────────────────────────────────────────────── export interface StorageGetArgs { key?: string; type?: string; tabId?: number; } export interface StorageSetArgs { key?: string; value?: Json; type?: string; tabId?: number; } -export interface CookiesListArgs { url?: string; domain?: string; name?: string; } -export interface CookiesGetArgs { url?: string; name?: string; } -export interface CookiesSetArgs { - url?: string; - name?: string; - value?: string; - domain?: string; - path?: string; - secure?: boolean; - httpOnly?: boolean; - expirationDate?: number; - sameSite?: `${chrome.cookies.SameSiteStatus}`; -} - // ── Session ───────────────────────────────────────────────────────────────────── export interface SessionSaveArgs { name?: string; } export interface SessionRemoveArgs { name?: string; } export interface SessionDiffArgs { nameA?: string; nameB?: string; } +export interface SessionImportArgs { name?: string; session?: Json; overwrite?: boolean; } export interface SessionLoadArgs { name?: string; gentleMode?: string; diff --git a/package.json b/package.json index c3b4538..db1cb67 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js && esbuild extension/src/content-dispatch.ts --bundle --format=iife --target=chrome120 --outfile=extension/content-dispatch.js", "build:tests": "esbuild extension/test/*.test.ts --bundle --format=esm --platform=node --outdir=extension/test-dist --out-extension:.js=.mjs", "test:extension": "npm run build:tests && node --disable-warning=ExperimentalWarning --test extension/test-dist/*.test.mjs", - "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension" + "check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js && npm run test:extension", + "package:extension": "npm run build:extension && python scripts/package_extension.py", + "package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore" }, "devDependencies": { "@types/chrome": "^0.1.40", diff --git a/pyproject.toml b/pyproject.toml index 029bc62..d715a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.12.3" +version = "0.14.1" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ diff --git a/scripts/package_extension.py b/scripts/package_extension.py new file mode 100644 index 0000000..2f80709 --- /dev/null +++ b/scripts/package_extension.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Package the Chrome extension. + +Default builds a local/unpacked-style archive that keeps manifest.key so the +extension ID stays stable for native messaging. ``--webstore`` writes the same +runtime files but strips ``key`` from manifest.json because the Chrome Web Store +rejects that field. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXTENSION_DIR = ROOT / "extension" +DIST_DIR = ROOT / "dist" +RUNTIME_FILES = ( + "manifest.json", + "background.js", + "content-dispatch.js", + "content.js", + "icon.svg", +) +RUNTIME_DIRS = ("icons",) + +def _read_manifest(webstore: bool) -> dict: + manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8")) + if webstore: + manifest.pop("key", None) + return manifest + +def _copy_tree(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + +def package_extension(*, webstore: bool = False, out: Path | None = None) -> Path: + manifest = _read_manifest(webstore) + version = manifest["version"] + suffix = "webstore" if webstore else "local" + out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip" + staging = DIST_DIR / f"extension-package-{suffix}" + + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + out.parent.mkdir(parents=True, exist_ok=True) + + for file_name in RUNTIME_FILES: + source = EXTENSION_DIR / file_name + if file_name == "manifest.json": + (staging / file_name).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + else: + shutil.copy2(source, staging / file_name) + + for dir_name in RUNTIME_DIRS: + _copy_tree(EXTENSION_DIR / dir_name, staging / dir_name) + + if out.exists(): + out.unlink() + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(staging.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(staging).as_posix()) + return out + +def main() -> None: + parser = argparse.ArgumentParser(description="Package browser-cli extension") + parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload") + parser.add_argument("--out", type=Path, default=None, help="output zip path") + args = parser.parse_args() + print(package_extension(webstore=args.webstore, out=args.out)) + +if __name__ == "__main__": + main() diff --git a/tests/test_api.py b/tests/test_api.py index 4846145..053993a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -76,7 +76,7 @@ class TestBrowserCLIInit: def test_namespaces_present_and_bound(self): b = BrowserCLI() for name in ("nav", "tabs", "groups", "windows", "dom", "extract", - "page", "storage", "cookies", "session", "perf", "extension", "decorators"): + "page", "storage", "session", "perf", "extension", "decorators"): ns = getattr(b, name) assert ns is not None assert ns._c is b @@ -792,13 +792,6 @@ class TestPageStorageCookies: "storage.set", {"key": "k", "value": "v", "type": "session", "tabId": None}, profile=None, remote=None, key=None ) - def test_cookies_list(self, b, mock_send): - mock_send.return_value = [{"name": "c"}] - assert b.cookies.list(domain="example.com") == [{"name": "c"}] - mock_send.assert_called_once_with( - "cookies.list", {"url": None, "domain": "example.com", "name": None}, profile=None, remote=None, key=None - ) - class TestPerf: def test_perf_status(self, b, mock_send): mock_send.return_value = {"profile": "auto"} diff --git a/tests/test_commands_cli.py b/tests/test_commands_cli.py index 82aedfc..71fa16d 100644 --- a/tests/test_commands_cli.py +++ b/tests/test_commands_cli.py @@ -168,70 +168,6 @@ def test_cli_dom_poll(): assert result.exit_code == 0 assert "Matched" in result.output -# --------------------------------------------------------------------------- -# cookies commands -# --------------------------------------------------------------------------- - -from browser_cli.commands.cookies import cookies_group - -def test_cli_cookies_list_empty(): - result = _run(cookies_group, ["list"], []) - assert result.exit_code == 0 - assert "No cookies found" in result.output - -def test_cli_cookies_list_with_cookies(): - cookies = [{"name": "session", "value": "abc123", "domain": "example.com", - "path": "/", "secure": True, "httpOnly": False}] - result = _run(cookies_group, ["list"], cookies) - assert result.exit_code == 0 - assert "session" in result.output - assert "example.com" in result.output - -def test_cli_cookies_list_filter_url(): - cookies = [{"name": "x", "value": "y", "domain": "example.com", - "path": "/", "secure": False, "httpOnly": False}] - result = _run(cookies_group, ["list", "--url", "https://example.com"], cookies) - assert result.exit_code == 0 - assert "example.com" in result.output - -def test_cli_cookies_list_filter_domain(): - cookies = [{"name": "x", "value": "y", "domain": "example.com", - "path": "/", "secure": False, "httpOnly": False}] - result = _run(cookies_group, ["list", "--domain", "example.com"], cookies) - assert result.exit_code == 0 - -def test_cli_cookies_list_filter_name(): - cookies = [{"name": "session", "value": "abc", "domain": "example.com", - "path": "/", "secure": False, "httpOnly": True}] - result = _run(cookies_group, ["list", "--name", "session"], cookies) - assert result.exit_code == 0 - assert "session" in result.output - -def test_cli_cookies_get_found(): - cookie = {"name": "tok", "value": "secret123", "domain": "example.com", "path": "/"} - result = _run(cookies_group, ["get", "https://example.com", "tok"], cookie) - assert result.exit_code == 0 - assert "secret123" in result.output - -def test_cli_cookies_get_not_found(): - result = _run(cookies_group, ["get", "https://example.com", "missing"], None) - assert result.exit_code != 0 - assert "not found" in result.output - -def test_cli_cookies_set(): - result = _run(cookies_group, ["set", "https://example.com", "tok", "val"], None) - assert result.exit_code == 0 - assert "Set cookie" in result.output - -def test_cli_cookies_set_with_options(): - result = _run(cookies_group, [ - "set", "https://example.com", "tok", "val", - "--secure", "--http-only", "--path", "/app", - "--same-site", "lax", - ], None) - assert result.exit_code == 0 - assert "Set cookie" in result.output - # --------------------------------------------------------------------------- # page commands # --------------------------------------------------------------------------- diff --git a/tests/test_cookies.py b/tests/test_cookies.py deleted file mode 100644 index 7d36e5b..0000000 --- a/tests/test_cookies.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Integration tests for cookies.* commands — require a live browser.""" -import time - -def test_cookies_list_returns_list(browser, http_tab): - """cookies.list returns a list (may be empty on a plain https://example.com).""" - browser("tabs.active", {"tabId": http_tab["id"]}) - cookies = browser("cookies.list", {}) - assert isinstance(cookies, list) - -def test_cookies_list_has_required_fields(browser, http_tab): - """Every cookie returned has at least name, domain and path fields.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - # Set a known cookie so the list is non-empty - browser("cookies.set", { - "url": "https://example.com", - "name": "__pytest_field_check", - "value": "1", - }) - cookies = browser("cookies.list", {"url": "https://example.com"}) - assert isinstance(cookies, list) - assert len(cookies) > 0 - for c in cookies: - assert "name" in c - assert "domain" in c - assert "path" in c - -def test_cookies_set_and_list(browser, http_tab): - """Set a cookie and verify it appears in the list.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - cookie_name = "__pytest_set_test" - cookie_value = "hello-pytest" - - browser("cookies.set", { - "url": "https://example.com", - "name": cookie_name, - "value": cookie_value, - }) - - cookies = browser("cookies.list", {"url": "https://example.com"}) - found = next((c for c in cookies if c.get("name") == cookie_name), None) - assert found is not None, f"Cookie '{cookie_name}' not found after set" - assert found["value"] == cookie_value - -def test_cookies_get(browser, http_tab): - """Get a single cookie by URL + name.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - name = "__pytest_get_test" - value = "get-value-42" - - browser("cookies.set", {"url": "https://example.com", "name": name, "value": value}) - - cookie = browser("cookies.get", {"url": "https://example.com", "name": name}) - assert cookie is not None - assert cookie.get("value") == value - -def test_cookies_get_missing_returns_none(browser, http_tab): - """Getting a non-existent cookie returns None.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - cookie = browser("cookies.get", { - "url": "https://example.com", - "name": "__pytest_no_such_cookie_zzz", - }) - assert cookie is None - -def test_cookies_list_filter_by_domain(browser, http_tab): - """Filtering by domain only returns matching cookies.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - browser("cookies.set", { - "url": "https://example.com", - "name": "__pytest_domain_filter", - "value": "yes", - }) - cookies = browser("cookies.list", {"domain": "example.com"}) - assert isinstance(cookies, list) - for c in cookies: - assert "example.com" in c.get("domain", "") - -def test_cookies_list_filter_by_name(browser, http_tab): - """Filtering by name only returns cookies with that name.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - name = "__pytest_name_filter_unique" - browser("cookies.set", {"url": "https://example.com", "name": name, "value": "y"}) - - cookies = browser("cookies.list", {"name": name}) - assert isinstance(cookies, list) - assert len(cookies) > 0 - for c in cookies: - assert c["name"] == name - -def test_cookies_set_with_secure_flag(browser, http_tab): - """Setting a cookie with secure=True persists the secure attribute.""" - browser("tabs.active", {"tabId": http_tab["id"]}) - name = "__pytest_secure_cookie" - browser("cookies.set", { - "url": "https://example.com", - "name": name, - "value": "secured", - "secure": True, - }) - cookies = browser("cookies.list", {"url": "https://example.com", "name": name}) - found = next((c for c in cookies if c["name"] == name), None) - assert found is not None - assert found.get("secure") is True diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py new file mode 100644 index 0000000..85bf799 --- /dev/null +++ b/tests/test_extension_packaging.py @@ -0,0 +1,35 @@ +import importlib.util +import json +import zipfile +from pathlib import Path + +def _load_packager(): + path = Path(__file__).resolve().parents[1] / "scripts" / "package_extension.py" + spec = importlib.util.spec_from_file_location("package_extension", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + +def test_webstore_package_strips_manifest_key(tmp_path): + packager = _load_packager() + out = packager.package_extension(webstore=True, out=tmp_path / "webstore.zip") + + with zipfile.ZipFile(out) as zf: + manifest = json.loads(zf.read("manifest.json")) + names = set(zf.namelist()) + + assert "key" not in manifest + assert "background.js" in names + assert "content-dispatch.js" in names + assert "content.js" in names + assert "icons/icon-128.png" in names + +def test_local_package_keeps_manifest_key(tmp_path): + packager = _load_packager() + out = packager.package_extension(webstore=False, out=tmp_path / "local.zip") + + with zipfile.ZipFile(out) as zf: + manifest = json.loads(zf.read("manifest.json")) + + assert "key" in manifest diff --git a/tests/test_new_feature_commands.py b/tests/test_new_feature_commands.py new file mode 100644 index 0000000..4240a3e --- /dev/null +++ b/tests/test_new_feature_commands.py @@ -0,0 +1,105 @@ +import json +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +import pytest + +from browser_cli import BrowserCLI +from browser_cli.cli import main +from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category + +def test_extension_info_cli_renders_capabilities(): + with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}): + result = CliRunner().invoke(main, ["extension", "info"]) + assert result.exit_code == 0 + assert "1.2.3" in result.output + assert "extension.info" in result.output + +def test_script_runs_raw_commands(tmp_path: Path): + script = tmp_path / "workflow.json" + script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8") + with patch("browser_cli.send_command", return_value={"count": 2}) as send_command: + result = CliRunner().invoke(main, ["script", str(script), "--json"]) + assert result.exit_code == 0 + assert "tabs.count" in result.output + send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None) + +def test_session_export_cli_prints_json(): + with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}): + result = CliRunner().invoke(main, ["session", "export", "work"]) + assert result.exit_code == 0 + assert '"name": "work"' in result.output + +def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new(): + calls = [] + + def sender(command, args=None, **kwargs): + calls.append((command, args)) + if command == "tabs.list": + return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}] + return {} + + BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True) + assert calls == [ + ("tabs.list", {}), + ("navigate.to", {"tabId": 7, "url": "https://example.com"}), + ] + +def test_tabs_tree_command_available(): + with patch("browser_cli.send_command", return_value=[]): + result = CliRunner().invoke(main, ["tabs", "tree"]) + assert result.exit_code == 0 + assert "Tabs" in result.output + +def test_doctor_command_reports_connection_failure_cleanly(): + with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ + patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): + result = CliRunner().invoke(main, ["doctor"]) + assert result.exit_code == 1 + assert "Connection" in result.output + +def test_serve_http_no_auth_rejected_on_public_host(): + result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"]) + assert result.exit_code != 0 + assert "--no-auth is only allowed on loopback" in result.output + +def test_raw_command_blocks_dangerous_by_default(): + result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}']) + assert result.exit_code != 0 + assert "blocked by default" in result.output + +def test_raw_command_allows_dangerous_with_explicit_flag(): + with patch("browser_cli.send_command", return_value="Example") as send_command: + result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}']) + assert result.exit_code == 0 + send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None) + +def test_script_blocks_control_without_explicit_flag(tmp_path: Path): + script = tmp_path / "workflow.json" + script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8") + result = CliRunner().invoke(main, ["script", str(script), "--json"]) + assert result.exit_code != 0 + assert "blocked by default" in result.output + +def test_script_allows_control_with_explicit_flag(tmp_path: Path): + script = tmp_path / "workflow.json" + script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8") + with patch("browser_cli.send_command", return_value={}) as send_command: + result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"]) + assert result.exit_code == 0 + send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None) + +def test_command_policy_categories_and_flags(): + assert command_category("tabs.list") == "safe" + assert command_category("extract.text") == "read-page" + assert command_category("dom.click") == "control" + assert command_category("storage.get") == "dangerous" + assert_command_allowed("tabs.list", CommandPolicy()) + with pytest.raises(PermissionError): + assert_command_allowed("extract.text", CommandPolicy()) + assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True)) + with pytest.raises(PermissionError): + assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True)) + assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True)) diff --git a/uv.lock b/uv.lock index 77b180c..92b4ee4 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "browser-cli" -version = "0.12.3" +version = "0.14.1" source = { editable = "." } dependencies = [ { name = "click" },